dshmarket 1.10.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -668,7 +668,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
668
668
  });
669
669
  }
670
670
  //#endregion
671
- //#region \0dsh-css:/home/runner/work/dsh-market/dsh-market/src/client/Market.module.css.mjs
671
+ //#region \0dsh-css:src/client/Market.module.css.mjs
672
672
  const css = ".eGUBIq_root{min-width:0;height:100%;color:var(--dsw-alias-label-primary,#1f2328);flex-direction:column;display:flex;position:relative}.eGUBIq_head{flex-direction:column;gap:12px;padding:4px 4px 12px;display:flex}.eGUBIq_title{margin:0;font-size:16px;font-weight:500;line-height:24px}.eGUBIq_sub{color:var(--dsw-alias-label-tertiary,#8b93a1);align-items:center;gap:8px;margin:0;font-size:14px;line-height:22px;display:flex}.eGUBIq_tabs{border-bottom:1px solid var(--dsw-alias-border-l2,#e5e7eb);align-items:flex-end;gap:2px;display:flex}.eGUBIq_tab{font:inherit;color:var(--dsw-alias-label-secondary,#6b7280);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-bottom:2px solid #0000;padding:7px 12px;font-size:13px}.eGUBIq_tab.eGUBIq_on{color:var(--dsw-alias-brand-primary,#4f6ef7);border-bottom-color:var(--dsw-alias-brand-primary,#4f6ef7);font-weight:600}.eGUBIq_banner{background:var(--dsw-alias-bg-layer-2,#fdf3e3);border:1px solid var(--dsw-alias-border-l2,#f3e3c3);border-radius:8px;align-items:center;gap:8px;margin:0;padding:8px 12px;font-size:12px;display:flex}.eGUBIq_bannerIcon{color:var(--dsw-alias-label-secondary,#6b7280);flex-shrink:0}.eGUBIq_bannerHint{color:var(--dsw-alias-label-tertiary,#8b93a1);cursor:help;display:inline-flex}.eGUBIq_body{flex:1;padding:12px 4px 24px;overflow-x:hidden;overflow-y:auto}.eGUBIq_cats{z-index:5;background:var(--dsw-alias-bg-layer-2,#f7f8fa);margin:-12px -4px 2px;padding:12px 4px 4px;position:sticky;top:-13px}.eGUBIq_catsRow{align-items:flex-start;gap:8px;display:flex;position:relative}.eGUBIq_star{color:var(--dsw-alias-label-secondary,#9ca3af);font-size:11px}.eGUBIq_top{z-index:20;display:inline-flex;position:absolute;bottom:18px;right:18px}.eGUBIq_topBtn{border-radius:99px;width:38px;height:38px;padding:0}.eGUBIq_tag{border:1px solid var(--dsw-alias-border-l3,#d9dde3);color:var(--dsw-alias-label-secondary,#6b7280);border-radius:4px;flex-shrink:0;padding:1px 6px;font-size:11px;line-height:16px}.eGUBIq_okState{color:var(--dsw-alias-state-success-primary,#16a34a);white-space:nowrap;font-size:12px;font-weight:600}.eGUBIq_catsWrap{flex-wrap:wrap;flex:1;align-items:center;gap:6px;min-width:0;display:flex}.eGUBIq_catsCollapsed{max-height:62px;overflow:hidden}.eGUBIq_catsToggle.eGUBIq_catsToggle{height:26px;min-height:26px;color:var(--dsw-alias-label-secondary,#6b7280);padding:0 6px}.eGUBIq_shots{-webkit-overflow-scrolling:touch;scrollbar-width:thin;gap:8px;margin:0 0 8px;padding:2px 0 6px;display:flex;overflow-x:auto}.eGUBIq_shot{object-fit:cover;border:1px solid var(--dsw-alias-border-default,#e5e7eb);background:var(--dsw-alias-bg-layer-2,#f3f4f6);border-radius:8px;flex:none;max-width:260px;height:150px}.eGUBIq_cmd{background:var(--dsw-alias-bg-layer-2,#f3f4f6);word-break:break-all;border-radius:6px;margin:8px 0 0;padding:8px 10px;font-family:ui-monospace,Menlo,monospace;font-size:11px;line-height:18px}.eGUBIq_warnLine{color:var(--dsw-alias-state-warn-primary,#b45309);align-items:center;gap:4px;margin:0;font-size:12px;font-weight:600;line-height:18px;display:flex}.eGUBIq_modalNote{color:var(--dsw-alias-label-tertiary,#8b93a1);align-items:center;gap:4px;margin:12px 0 0;font-size:12px;line-height:18px;display:flex}.eGUBIq_grid{grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;display:grid}.eGUBIq_swatches{border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:8px;gap:0;height:34px;display:flex;overflow:hidden}.eGUBIq_themesGrid{margin-bottom:12px}.eGUBIq_swatches i{flex:1}.eGUBIq_card{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:12px;flex-direction:column;gap:12px;padding:12px 14px;display:flex}.eGUBIq_row1{align-items:center;gap:10px;min-width:0;display:flex}.eGUBIq_srcBtn{flex-shrink:0;align-self:flex-start}.eGUBIq_av{color:#fff;object-fit:cover;background:var(--dsw-alias-bg-layer-2,#f3f4f6);border-radius:8px;flex-shrink:0;place-items:center;width:32px;height:32px;font-size:14px;font-weight:700;display:grid}.eGUBIq_nm{text-overflow:ellipsis;white-space:nowrap;font-size:14px;font-weight:500;line-height:22px;overflow:hidden}.eGUBIq_owner{color:var(--dsw-alias-label-secondary,#9ca3af);font-size:11px}.eGUBIq_desc{color:var(--dsw-alias-label-tertiary,#8b93a1);min-height:36px;margin:0;font-size:12px;line-height:18px}.eGUBIq_foot{align-items:center;gap:8px;margin-top:auto;display:flex}.eGUBIq_grow{flex:1}.eGUBIq_titleRow{align-items:center;gap:10px;display:flex}.eGUBIq_descTight{min-height:0}.eGUBIq_src{color:var(--dsw-alias-label-secondary,#9ca3af);font-size:11px;text-decoration:none}.eGUBIq_src:hover{color:var(--dsw-alias-brand-primary,#4f6ef7)}.eGUBIq_dot{vertical-align:2px;margin-left:5px}.eGUBIq_act{flex-wrap:wrap;align-items:center;gap:6px;margin-top:6px;font-size:11px;display:flex}.eGUBIq_actLive{color:var(--dsw-alias-state-success-primary,#16a34a);align-items:center;gap:4px;font-weight:600;display:inline-flex}.eGUBIq_actWarn{color:var(--dsw-alias-state-warn-primary,#b45309);align-items:center;gap:4px;font-weight:600;display:inline-flex}.eGUBIq_actBroken{color:var(--dsw-alias-state-error-primary,#dc2626);align-items:center;gap:4px;font-weight:600;display:inline-flex}.eGUBIq_actWhy{color:var(--dsw-alias-label-secondary,#6b7280);margin-top:2px}.eGUBIq_loading{color:var(--dsw-alias-label-secondary,#9ca3af);flex-direction:column;align-items:center;gap:12px;padding:48px;font-size:13px;display:flex}.eGUBIq_spin{color:var(--dsw-alias-brand-primary,#4f6ef7);flex-shrink:0;animation:.8s linear infinite eGUBIq_sp;display:inline-flex}@keyframes eGUBIq_sp{to{transform:rotate(360deg)}}.eGUBIq_progress{background:var(--dsw-alias-bg-layer-2,#f3f4f6);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);color:var(--dsw-alias-label-secondary,#6b7280);border-radius:8px;flex-wrap:wrap;align-items:center;gap:9px;margin:0;padding:8px 12px;font-size:12px;display:flex}.eGUBIq_bar{background:var(--dsw-alias-border-l1,#e5e7eb);border-radius:99px;width:100%;height:4px;overflow:hidden}.eGUBIq_barFill{background:var(--dsw-alias-brand-primary,#4f6ef7);border-radius:99px;height:100%;transition:width .6s}.eGUBIq_barWave{width:30%;animation:1.2s ease-in-out infinite eGUBIq_dshmSlide}@keyframes eGUBIq_dshmSlide{0%{margin-left:-30%}to{margin-left:100%}}.eGUBIq_irow .eGUBIq_progress{margin-top:8px}.eGUBIq_progress code{text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,Menlo,monospace;font-size:11px;overflow:hidden}.eGUBIq_empty{color:var(--dsw-alias-label-secondary,#9ca3af);text-align:center;padding:32px;font-size:13px}.eGUBIq_err{color:var(--dsw-alias-state-error-primary,#dc2626);white-space:pre-wrap;word-break:break-all;margin:8px 0;font-size:12px}.eGUBIq_irow{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:12px;align-items:center;gap:10px;margin-bottom:8px;padding:12px 14px;display:flex}.eGUBIq_irowMissing{filter:grayscale();opacity:.5}.eGUBIq_irow>.eGUBIq_src,.eGUBIq_irow>.eGUBIq_owner,.eGUBIq_irow button{white-space:nowrap;flex-shrink:0}.eGUBIq_tabSearchRow{padding:0 4px 10px;display:flex}.eGUBIq_tabSearch{width:260px}.eGUBIq_spec{color:var(--dsw-alias-label-secondary,#9ca3af);font-family:ui-monospace,Menlo,monospace;font-size:11px}.eGUBIq_staleAction{margin-top:8px}.eGUBIq_pct{color:var(--dsw-alias-label-secondary,#6b7280);flex-shrink:0;font-size:11px;font-weight:600}.eGUBIq_pager{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;margin:16px 0 4px;display:flex}.eGUBIq_pagerPages{flex-wrap:wrap;flex:1;justify-content:center;align-items:center;gap:6px;min-width:0;display:flex}.eGUBIq_pageEllipsis{color:var(--dsw-alias-label-secondary,#9ca3af);padding:0 2px;font-size:12px}.eGUBIq_pageInfo{color:var(--dsw-alias-label-secondary,#6b7280);white-space:nowrap;font-size:12px}.eGUBIq_depBadge{border:1px solid var(--dsw-alias-state-warn-primary,#b45309);color:var(--dsw-alias-state-warn-primary,#b45309);white-space:nowrap;border-radius:4px;flex-shrink:0;margin-left:6px;padding:1px 6px;font-size:11px;font-weight:600;line-height:16px}.eGUBIq_deprecate{color:var(--dsw-alias-state-warn-primary,#b45309);background:var(--dsw-alias-bg-layer-2,#fdf3e3);border:1px solid var(--dsw-alias-border-l2,#f3e3c3);border-radius:8px;margin:0;padding:8px 10px;font-size:12px;line-height:18px}.eGUBIq_deprecate a{color:var(--dsw-alias-state-warn-primary,#b45309);text-decoration:underline}.eGUBIq_deprecate .eGUBIq_src{margin-left:8px}.eGUBIq_depLine{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.eGUBIq_switch{border:1px solid var(--dsw-alias-border-l2,#d9dde3);background:var(--dsw-alias-bg-layer-2,#e5e7eb);cursor:pointer;border-radius:99px;flex-shrink:0;width:38px;height:22px;padding:0;transition:background .15s,border-color .15s;position:relative}.eGUBIq_switchOn{background:var(--dsw-alias-state-success-primary,#16a34a);border-color:var(--dsw-alias-state-success-primary,#16a34a)}.eGUBIq_switchMixed{background:var(--dsw-alias-state-warn-primary,#b45309);border-color:var(--dsw-alias-state-warn-primary,#b45309)}.eGUBIq_switchKnob{background:#fff;border-radius:99px;width:16px;height:16px;transition:left .15s;position:absolute;top:2px;left:2px;box-shadow:0 1px 2px #00000040}.eGUBIq_switchOn .eGUBIq_switchKnob,.eGUBIq_switchMixed .eGUBIq_switchKnob{left:18px}.eGUBIq_switch:disabled{opacity:.5;cursor:default}.eGUBIq_viewBar{border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:8px;align-items:center;gap:2px;width:fit-content;margin-bottom:12px;padding:2px;display:flex}.eGUBIq_viewBtn{font:inherit;color:var(--dsw-alias-label-secondary,#6b7280);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:6px;padding:4px 10px;font-size:12px;line-height:18px}.eGUBIq_viewBtn:hover{color:var(--dsw-alias-brand-primary,#4f6ef7)}.eGUBIq_viewOn{background:var(--dsw-alias-bg-layer-2,#eef0f4);color:var(--dsw-alias-label-primary,#1f2328);font-weight:600}.eGUBIq_groupRow{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:12px;margin-bottom:10px;padding:12px 14px}.eGUBIq_groupHead{align-items:center;gap:10px;min-width:0;display:flex}.eGUBIq_groupName{text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;line-height:20px;overflow:hidden}.eGUBIq_groupActions{flex-shrink:0;align-items:center;gap:6px;display:flex}.eGUBIq_groupMembers{flex-direction:column;gap:6px;margin-top:10px;display:flex}.eGUBIq_groupMember{background:var(--dsw-alias-bg-layer-2,#f7f8fa);border-radius:8px;align-items:center;gap:8px;padding:6px 8px;font-size:12px;line-height:18px;display:flex}.eGUBIq_groupMember .eGUBIq_nm{flex:1;min-width:0;font-size:12px}.eGUBIq_groupAddPanel{border-top:1px dashed var(--dsw-alias-border-l2,#e5e7eb);flex-direction:column;gap:6px;margin-top:10px;padding-top:10px;display:flex}.eGUBIq_groupCreate{align-items:center;gap:8px;margin-bottom:10px;display:flex}.eGUBIq_inlineInput{flex:1;min-width:120px}.eGUBIq_assignRow{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.eGUBIq_assignSelect{border:1px solid var(--dsw-alias-border-l2,#e5e7eb);background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#1f2328);font:inherit;border-radius:6px;padding:3px 6px;font-size:12px;line-height:18px}.eGUBIq_groupHint{color:var(--dsw-alias-label-tertiary,#8b93a1);font-size:11px}.eGUBIq_sectAction{color:var(--dsw-alias-label-secondary,#6b7280);align-items:center;gap:8px;margin:14px 2px 8px;font-size:12px;font-weight:600;display:flex}.eGUBIq_backupGrid{grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px;display:grid}.eGUBIq_backupCard{background:var(--dsw-alias-bg-layer-1,#fff);border:1px solid var(--dsw-alias-border-l2,#e5e7eb);border-radius:12px;flex-direction:column;gap:10px;padding:16px;display:flex}.eGUBIq_backupCard h3{margin:0;font-size:14px}.eGUBIq_backupCard p{color:var(--dsw-alias-label-secondary,#6b7280);margin:0;font-size:12px;line-height:18px}.eGUBIq_backupActions{flex-wrap:wrap;gap:8px;display:flex;position:relative}.eGUBIq_hiddenFile{opacity:0;pointer-events:none;width:1px;height:1px;position:absolute}.eGUBIq_backupInput{box-sizing:border-box;width:100%}.eGUBIq_backupCheck{cursor:pointer;align-items:center;gap:6px;font-size:12px;display:flex}.eGUBIq_backupWarn{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-warn-primary,#b45309)!important}.eGUBIq_backupMessage{color:var(--dsw-alias-label-secondary,#6b7280);grid-column:1/-1;font-size:12px}";
673
673
  const tagId = "dshmarket/Market.module.css";
674
674
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
@@ -679,105 +679,105 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
679
679
  document.head.appendChild(tag);
680
680
  }
681
681
  var Market_module_css_default = {
682
- "actLive": "eGUBIq_actLive",
683
- "backupMessage": "eGUBIq_backupMessage",
684
- "pct": "eGUBIq_pct",
685
- "depLine": "eGUBIq_depLine",
686
- "sub": "eGUBIq_sub",
682
+ "groupHead": "eGUBIq_groupHead",
683
+ "shot": "eGUBIq_shot",
684
+ "sp": "eGUBIq_sp",
685
+ "owner": "eGUBIq_owner",
686
+ "title": "eGUBIq_title",
687
+ "swatches": "eGUBIq_swatches",
687
688
  "inlineInput": "eGUBIq_inlineInput",
688
- "groupHint": "eGUBIq_groupHint",
689
- "assignRow": "eGUBIq_assignRow",
690
- "tab": "eGUBIq_tab",
689
+ "backupCheck": "eGUBIq_backupCheck",
690
+ "viewOn": "eGUBIq_viewOn",
691
+ "groupName": "eGUBIq_groupName",
692
+ "catsWrap": "eGUBIq_catsWrap",
693
+ "tag": "eGUBIq_tag",
694
+ "groupMembers": "eGUBIq_groupMembers",
695
+ "assignSelect": "eGUBIq_assignSelect",
696
+ "backupMessage": "eGUBIq_backupMessage",
697
+ "descTight": "eGUBIq_descTight",
691
698
  "progress": "eGUBIq_progress",
692
- "backupActions": "eGUBIq_backupActions",
693
- "grow": "eGUBIq_grow",
694
- "modalNote": "eGUBIq_modalNote",
695
- "root": "eGUBIq_root",
696
- "pager": "eGUBIq_pager",
699
+ "star": "eGUBIq_star",
697
700
  "cmd": "eGUBIq_cmd",
698
- "pageEllipsis": "eGUBIq_pageEllipsis",
699
- "warnLine": "eGUBIq_warnLine",
700
- "swatches": "eGUBIq_swatches",
701
- "title": "eGUBIq_title",
702
- "themesGrid": "eGUBIq_themesGrid",
703
- "row1": "eGUBIq_row1",
704
- "desc": "eGUBIq_desc",
705
- "empty": "eGUBIq_empty",
701
+ "head": "eGUBIq_head",
702
+ "src": "eGUBIq_src",
703
+ "tabSearchRow": "eGUBIq_tabSearchRow",
704
+ "spin": "eGUBIq_spin",
705
+ "bar": "eGUBIq_bar",
706
706
  "viewBtn": "eGUBIq_viewBtn",
707
- "viewOn": "eGUBIq_viewOn",
708
- "sectAction": "eGUBIq_sectAction",
709
- "irowMissing": "eGUBIq_irowMissing",
710
- "body": "eGUBIq_body",
711
707
  "on": "eGUBIq_on",
712
- "staleAction": "eGUBIq_staleAction",
713
- "banner": "eGUBIq_banner",
714
- "top": "eGUBIq_top",
715
- "src": "eGUBIq_src",
716
- "backupGrid": "eGUBIq_backupGrid",
717
- "groupMembers": "eGUBIq_groupMembers",
708
+ "actLive": "eGUBIq_actLive",
709
+ "root": "eGUBIq_root",
710
+ "themesGrid": "eGUBIq_themesGrid",
718
711
  "act": "eGUBIq_act",
719
- "actWarn": "eGUBIq_actWarn",
720
- "deprecate": "eGUBIq_deprecate",
721
- "groupName": "eGUBIq_groupName",
712
+ "pagerPages": "eGUBIq_pagerPages",
713
+ "depLine": "eGUBIq_depLine",
714
+ "tab": "eGUBIq_tab",
715
+ "row1": "eGUBIq_row1",
716
+ "modalNote": "eGUBIq_modalNote",
717
+ "av": "eGUBIq_av",
718
+ "nm": "eGUBIq_nm",
719
+ "loading": "eGUBIq_loading",
720
+ "pct": "eGUBIq_pct",
721
+ "groupActions": "eGUBIq_groupActions",
722
+ "backupActions": "eGUBIq_backupActions",
723
+ "backupInput": "eGUBIq_backupInput",
724
+ "bannerHint": "eGUBIq_bannerHint",
725
+ "bannerIcon": "eGUBIq_bannerIcon",
726
+ "cats": "eGUBIq_cats",
722
727
  "foot": "eGUBIq_foot",
723
- "switchMixed": "eGUBIq_switchMixed",
724
- "viewBar": "eGUBIq_viewBar",
725
- "err": "eGUBIq_err",
726
- "catsWrap": "eGUBIq_catsWrap",
728
+ "spec": "eGUBIq_spec",
729
+ "backupGrid": "eGUBIq_backupGrid",
727
730
  "titleRow": "eGUBIq_titleRow",
728
- "assignSelect": "eGUBIq_assignSelect",
731
+ "actWhy": "eGUBIq_actWhy",
732
+ "viewBar": "eGUBIq_viewBar",
733
+ "hiddenFile": "eGUBIq_hiddenFile",
734
+ "warnLine": "eGUBIq_warnLine",
735
+ "backupWarn": "eGUBIq_backupWarn",
736
+ "banner": "eGUBIq_banner",
737
+ "catsRow": "eGUBIq_catsRow",
729
738
  "dot": "eGUBIq_dot",
730
739
  "switch": "eGUBIq_switch",
731
- "groupActions": "eGUBIq_groupActions",
732
- "actWhy": "eGUBIq_actWhy",
733
- "tabs": "eGUBIq_tabs",
734
- "bannerIcon": "eGUBIq_bannerIcon",
735
- "spin": "eGUBIq_spin",
736
- "okState": "eGUBIq_okState",
737
- "loading": "eGUBIq_loading",
738
- "tabSearchRow": "eGUBIq_tabSearchRow",
739
- "groupAddPanel": "eGUBIq_groupAddPanel",
740
- "backupCard": "eGUBIq_backupCard",
741
- "switchKnob": "eGUBIq_switchKnob",
742
- "dshmSlide": "eGUBIq_dshmSlide",
743
- "switchOn": "eGUBIq_switchOn",
744
- "av": "eGUBIq_av",
745
- "tag": "eGUBIq_tag",
746
- "grid": "eGUBIq_grid",
747
- "shot": "eGUBIq_shot",
748
- "barFill": "eGUBIq_barFill",
749
- "catsCollapsed": "eGUBIq_catsCollapsed",
750
740
  "topBtn": "eGUBIq_topBtn",
751
- "groupRow": "eGUBIq_groupRow",
752
- "head": "eGUBIq_head",
753
- "backupInput": "eGUBIq_backupInput",
754
- "nm": "eGUBIq_nm",
741
+ "barFill": "eGUBIq_barFill",
742
+ "assignRow": "eGUBIq_assignRow",
743
+ "srcBtn": "eGUBIq_srcBtn",
755
744
  "irow": "eGUBIq_irow",
745
+ "groupMember": "eGUBIq_groupMember",
746
+ "switchOn": "eGUBIq_switchOn",
747
+ "switchKnob": "eGUBIq_switchKnob",
748
+ "depBadge": "eGUBIq_depBadge",
749
+ "top": "eGUBIq_top",
750
+ "sub": "eGUBIq_sub",
751
+ "tabs": "eGUBIq_tabs",
752
+ "catsCollapsed": "eGUBIq_catsCollapsed",
753
+ "grow": "eGUBIq_grow",
754
+ "barWave": "eGUBIq_barWave",
755
+ "dshmSlide": "eGUBIq_dshmSlide",
756
756
  "card": "eGUBIq_card",
757
- "catsRow": "eGUBIq_catsRow",
758
- "srcBtn": "eGUBIq_srcBtn",
759
- "descTight": "eGUBIq_descTight",
760
- "bar": "eGUBIq_bar",
761
- "tabSearch": "eGUBIq_tabSearch",
757
+ "staleAction": "eGUBIq_staleAction",
758
+ "catsToggle": "eGUBIq_catsToggle",
759
+ "pager": "eGUBIq_pager",
762
760
  "pageInfo": "eGUBIq_pageInfo",
763
- "cats": "eGUBIq_cats",
761
+ "deprecate": "eGUBIq_deprecate",
762
+ "actWarn": "eGUBIq_actWarn",
763
+ "pageEllipsis": "eGUBIq_pageEllipsis",
764
764
  "shots": "eGUBIq_shots",
765
- "owner": "eGUBIq_owner",
765
+ "switchMixed": "eGUBIq_switchMixed",
766
+ "groupRow": "eGUBIq_groupRow",
767
+ "err": "eGUBIq_err",
768
+ "body": "eGUBIq_body",
769
+ "tabSearch": "eGUBIq_tabSearch",
770
+ "groupAddPanel": "eGUBIq_groupAddPanel",
766
771
  "actBroken": "eGUBIq_actBroken",
767
- "groupMember": "eGUBIq_groupMember",
768
- "hiddenFile": "eGUBIq_hiddenFile",
769
- "depBadge": "eGUBIq_depBadge",
770
- "backupCheck": "eGUBIq_backupCheck",
771
- "pagerPages": "eGUBIq_pagerPages",
772
- "backupWarn": "eGUBIq_backupWarn",
773
- "bannerHint": "eGUBIq_bannerHint",
774
- "sp": "eGUBIq_sp",
775
- "star": "eGUBIq_star",
776
- "spec": "eGUBIq_spec",
777
- "catsToggle": "eGUBIq_catsToggle",
778
- "barWave": "eGUBIq_barWave",
779
- "groupHead": "eGUBIq_groupHead",
780
- "groupCreate": "eGUBIq_groupCreate"
772
+ "empty": "eGUBIq_empty",
773
+ "groupCreate": "eGUBIq_groupCreate",
774
+ "groupHint": "eGUBIq_groupHint",
775
+ "grid": "eGUBIq_grid",
776
+ "okState": "eGUBIq_okState",
777
+ "sectAction": "eGUBIq_sectAction",
778
+ "backupCard": "eGUBIq_backupCard",
779
+ "desc": "eGUBIq_desc",
780
+ "irowMissing": "eGUBIq_irowMissing"
781
781
  };
782
782
  //#endregion
783
783
  //#region src/client/MarketSection.tsx
package/lib/install.js CHANGED
@@ -7,12 +7,21 @@
7
7
  import { existsSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { classifyPnpmFailure } from './pnpm-compat.js';
10
- import { hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled } from './profile.js';
10
+ import { conflictingEntryIds, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readProfileBundles } from './profile.js';
11
11
  import { logEvent } from './log.js';
12
+ import { cleanOrphanedStore } from './store.js';
12
13
  /** One-shot bypass for pnpm's fresh-release hold; scoped to a single command. */
13
14
  export const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0';
14
15
  /**
15
- * Run one plugin command with automatic recovery from two known pnpm traps:
16
+ * Longer per-request fetch timeout for one retried command. pnpm's default
17
+ * 60-second limit aborts large tarball downloads (github: sources fetch the
18
+ * WHOLE repo even for a `#path:` subdirectory plugin) on slow networks; a
19
+ * plain retry fails again at the same limit, so the recovery re-runs with
20
+ * this override once. Scoped to a single command like RELEASE_AGE_OVERRIDE.
21
+ */
22
+ export const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000';
23
+ /**
24
+ * Run one plugin command with automatic recovery from three known pnpm traps:
16
25
  *
17
26
  * - pnpm-major drift (#20 bug 2): a modules directory built by a different
18
27
  * pnpm major fails mutation; pnpm's documented remedy is one `install` to
@@ -22,6 +31,10 @@ export const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0';
22
31
  * retry once with the one-shot minimumReleaseAge bypass (safe: the young
23
32
  * package is already installed; the bypass only lets pnpm touch the
24
33
  * lockfile again).
34
+ * - per-request fetch timeout: large tarballs (github: sources fetch the
35
+ * whole repo even for a `#path:` subdirectory) on slow networks blow
36
+ * pnpm's default 60-second limit; a plain retry fails again at the same
37
+ * limit, so retry once with a longer fetchTimeout.
25
38
  *
26
39
  * Any recognized failure that survives gets its bilingual explanation
27
40
  * appended to stderr so the UI shows an actionable message instead of a
@@ -54,8 +67,23 @@ export async function withHoistRecovery(run, profile, pluginArgs) {
54
67
  logEvent('warn', 'install', `transient network failure while pnpm replayed the dependency tree (#83) — retrying once`);
55
68
  result = await run(profile, pluginArgs);
56
69
  }
70
+ else if (failure?.code === 'fetch-timeout'
71
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
72
+ && !pluginArgs.includes(FETCH_TIMEOUT_OVERRIDE)) {
73
+ // Large tarball / slow network: pnpm's default 60s per-request limit
74
+ // aborted the download. A plain retry fails again at the same limit,
75
+ // so retry once with a longer fetchTimeout.
76
+ logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`);
77
+ result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)]);
78
+ }
57
79
  }
58
80
  if (!ok(result) && !result.cancelled) {
81
+ // A failed, timed-out, or killed run never finishes pnpm's staging, so
82
+ // its store tmp dirs (the WHOLE repo tarball for github: sources) are
83
+ // orphaned — reclaim them now that no pnpm is running. Safe by
84
+ // construction: directories are only removed when their owning pid is
85
+ // gone (the name carries it), so a live download is never touched.
86
+ await cleanOrphanedStore(run, profile);
59
87
  const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`);
60
88
  if (failure !== null)
61
89
  result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` };
@@ -107,26 +135,44 @@ export async function retargetCollections(run, profile, before, target, explicit
107
135
  * piece without a dsh manifest or without its declared entry artifact
108
136
  * (source-only checkout, build blocked by pnpm allowBuilds) would brick the
109
137
  * next boot, so it is removed on the spot.
110
- * @returns names kept and names removed as broken.
138
+ *
139
+ * Since #122 this also covers duplicate loader entry ids: cordis refuses to
140
+ * load a tree containing two entries with one id, so a TUI bundle landing in
141
+ * a web profile (both declare `id: storage`) leaves DSH unable to START —
142
+ * an error naming neither plugin, from which the market's own page is
143
+ * unreachable. Such a package is removed like any other bricking piece.
144
+ * @returns names kept, names removed as broken, and the id conflicts found.
111
145
  */
112
146
  export async function validateAddedPlugins(run, profile, before, explicitDir) {
113
147
  const dir = profileDir(profile, explicitDir);
114
148
  const addedNow = Object.keys(readInstalled(profile, dir)).filter(n => !before.has(n));
115
149
  const keep = [];
116
150
  const removedBroken = [];
151
+ const conflicts = [];
152
+ // Compare against what the profile already loads, minus this install's own
153
+ // additions — two pieces of one plugin are not "already installed".
154
+ const existingBundles = readProfileBundles(dir).filter(name => !addedNow.includes(name));
117
155
  for (const n of addedNow) {
118
156
  const packageDir = join(dir, 'node_modules', n);
119
157
  // hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
120
158
  // ship no entry of their own (#103) and must not be uninstalled here.
121
- if (hasDshManifest(packageDir) && hasLoadableEntry(dir, n)) {
122
- keep.push(n);
159
+ if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
160
+ removedBroken.push(n);
161
+ await run(profile, ['remove', n]);
162
+ continue;
123
163
  }
124
- else {
164
+ const clash = conflictingEntryIds(dir, n, existingBundles);
165
+ if (clash.length > 0) {
166
+ // Keeping it would make the NEXT BOOT fail outright (#122).
167
+ conflicts.push(...clash.map(hit => ({ name: n, ...hit })));
125
168
  removedBroken.push(n);
169
+ logEvent('error', 'install', `${n}: loader entry id conflict with ${clash[0].owner} (${clash.map(hit => hit.id).join(', ')}) — removing, it would break the next boot`);
126
170
  await run(profile, ['remove', n]);
171
+ continue;
127
172
  }
173
+ keep.push(n);
128
174
  }
129
- return { keep, removedBroken };
175
+ return { keep, removedBroken, conflicts };
130
176
  }
131
177
  /**
132
178
  * Whether a clean-exit update actually changed nothing — pnpm's
@@ -40,6 +40,19 @@ export function pluginArgsFor(profileDir, pluginArgs) {
40
40
  export function isTransientPnpmFailure(output) {
41
41
  return /ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL|FetchError|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|socket hang up|network timeout/i.test(output);
42
42
  }
43
+ /**
44
+ * pnpm's per-request fetch timeout: the abort surfaces as a DOMException
45
+ * ("The operation was aborted due to timeout", code 23) through undici —
46
+ * pnpm logs it as `GET … error (23)` before giving up. This is the failure
47
+ * shape for large tarballs (github: sources download the WHOLE repo, even
48
+ * for a `#path:` subdirectory plugin) on slow networks: pnpm's default
49
+ * 60-second limit is simply not enough, so a plain retry fails again at the
50
+ * same limit. The market's recovery re-runs once with a longer
51
+ * fetchTimeout (see withHoistRecovery).
52
+ */
53
+ export function isFetchTimeoutFailure(output) {
54
+ return /operation was aborted due to timeout|TimeoutError|error \(23\)/i.test(output);
55
+ }
43
56
  /**
44
57
  * Map a failed pnpm run's combined output to a known failure mode.
45
58
  *
@@ -134,6 +147,17 @@ export function classifyPnpmFailure(output) {
134
147
  message: '拉取依赖时网络临时失败(不一定是你正在装的插件——安装会重放整个依赖树,任何一个既有依赖抖动都会中断)。已自动重试一次仍失败,请稍后再试 / a transient network failure while fetching dependencies (not necessarily the plugin you are installing — installs replay the whole dependency tree, so any existing dependency can hiccup); one automatic retry failed too — please try again shortly',
135
148
  };
136
149
  }
150
+ // pnpm's per-request fetch timeout (#…): large tarballs (github: sources
151
+ // fetch the whole repo even for a `#path:` subdirectory) on slow networks
152
+ // blow pnpm's default 60s limit. A plain retry fails again at the same
153
+ // limit, so withHoistRecovery retries with a longer fetchTimeout.
154
+ if (isFetchTimeoutFailure(output)) {
155
+ return {
156
+ code: 'fetch-timeout',
157
+ recoverable: false,
158
+ message: '下载超时:这个插件的安装包较大(github 源会下载整个仓库)或网络较慢,pnpm 默认的单次请求 60 秒限制不够用。市场已用更长的超时自动重试一次;若仍失败,请稍后再试或检查网络 / download timed out: this plugin ships a large tarball (github sources download the whole repository) or your network is slow, and pnpm\'s default 60-second per-request limit was not enough; the market retries once with a longer timeout — if it still fails, try again later or check the network',
159
+ };
160
+ }
137
161
  if (output.includes('pnpm not found on PATH')) {
138
162
  return {
139
163
  code: 'pnpm-missing',
package/lib/profile.js CHANGED
@@ -161,27 +161,85 @@ export function entryArtifactExists(dir) {
161
161
  * bundle bring in" any name row counts.
162
162
  */
163
163
  export function bundlePatchTargets(dir) {
164
+ return readBundlePatchRows(dir).names;
165
+ }
166
+ /**
167
+ * Loader entry ids a bundle patch inserts. Cordis refuses to boot a tree
168
+ * with a duplicate entry id ("duplicate loader entry id: storage", #122), so
169
+ * these are what two bundles can collide on.
170
+ */
171
+ export function bundlePatchEntryIds(dir) {
172
+ return readBundlePatchRows(dir).ids;
173
+ }
174
+ /**
175
+ * `name:` and `id:` rows of the package's declared bundle patch. Line-wise
176
+ * on purpose: the strict hot-mount parser rejects config/expression rows,
177
+ * but for "what does this bundle bring in" any row counts.
178
+ */
179
+ function readBundlePatchRows(dir) {
164
180
  let patchPath;
165
181
  try {
166
182
  const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
167
183
  const declared = manifest.dsh?.bundle?.patch;
168
184
  if (typeof declared !== 'string' || declared === '')
169
- return [];
185
+ return { names: [], ids: [] };
170
186
  patchPath = join(dir, declared);
171
187
  }
172
188
  catch {
173
- return [];
189
+ return { names: [], ids: [] };
174
190
  }
175
191
  const names = [];
192
+ const ids = [];
176
193
  try {
177
194
  for (const line of readFileSync(patchPath, 'utf8').split('\n')) {
178
- const m = /^\s*name:\s*['"]?([^'"\s]+)/.exec(line);
179
- if (m !== null && !names.includes(m[1]))
180
- names.push(m[1]);
195
+ const name = /^\s*-?\s*name:\s*['"]?([^'"\s]+)/.exec(line);
196
+ if (name !== null && !names.includes(name[1]))
197
+ names.push(name[1]);
198
+ const id = /^\s*-?\s*id:\s*['"]?([^'"\s]+)/.exec(line);
199
+ if (id !== null && !ids.includes(id[1]))
200
+ ids.push(id[1]);
201
+ }
202
+ }
203
+ catch { /* unreadable patch — nothing to report */ }
204
+ return { names, ids };
205
+ }
206
+ /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
207
+ export function readProfileBundles(profileDirectory) {
208
+ try {
209
+ const manifest = JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8'));
210
+ const bundles = manifest.dsh?.profile?.bundles;
211
+ return Array.isArray(bundles) ? bundles.filter((name) => typeof name === 'string') : [];
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ }
217
+ /**
218
+ * Loader entry ids a newly added package would collide on with bundles the
219
+ * profile ALREADY loads (#122).
220
+ *
221
+ * Cordis hard-fails the whole tree on a duplicate id, so this is not a
222
+ * cosmetic conflict: installing a TUI bundle into a web profile (both
223
+ * declare `id: storage`) leaves DSH unable to start at all, with an error
224
+ * naming neither plugin. Checked against the profile's own bundle list so a
225
+ * package is never compared with itself.
226
+ * @returns colliding ids mapped to the already-installed bundle that owns them.
227
+ */
228
+ export function conflictingEntryIds(profileDirectory, candidate, installedBundles) {
229
+ const mine = bundlePatchEntryIds(join(profileDirectory, 'node_modules', candidate));
230
+ if (mine.length === 0)
231
+ return [];
232
+ const conflicts = [];
233
+ for (const bundle of installedBundles) {
234
+ if (bundle === candidate)
235
+ continue;
236
+ const theirs = new Set(bundlePatchEntryIds(join(profileDirectory, 'node_modules', bundle)));
237
+ for (const id of mine) {
238
+ if (theirs.has(id) && !conflicts.some(hit => hit.id === id))
239
+ conflicts.push({ id, owner: bundle });
181
240
  }
182
241
  }
183
- catch { /* unreadable patch — no targets */ }
184
- return names;
242
+ return conflicts;
185
243
  }
186
244
  /**
187
245
  * Whether the loader has anything to load for this package: its own entry
package/lib/routes.js CHANGED
@@ -1149,9 +1149,11 @@ export function mountMarketRoutes(host, config, commandRuntime) {
1149
1149
  // must never survive to brick the next boot.
1150
1150
  let notAPlugin = false;
1151
1151
  let removedBroken = [];
1152
+ let conflicts = [];
1152
1153
  if (result.exitCode === 0 && !result.timedOut && !cancelled) {
1153
1154
  const validated = await validateAddedPlugins(runPlugin, config.profile, before, activeProfileDir);
1154
1155
  removedBroken = validated.removedBroken;
1156
+ conflicts = validated.conflicts;
1155
1157
  if (removedBroken.length > 0) {
1156
1158
  logEvent('warn', 'install', `${target}: removed uninstallable pieces (no dsh manifest or missing build artifacts): ${removedBroken.join(', ')}`);
1157
1159
  }
@@ -1208,11 +1210,15 @@ export function mountMarketRoutes(host, config, commandRuntime) {
1208
1210
  // Blocked build scripts are expected (pnpm >= 10 blocks them by
1209
1211
  // default): surface the approve-builds banner instead of scaring
1210
1212
  // the user with pnpm's raw stack.
1211
- error: notAPlugin
1212
- ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志'
1213
- : Array.isArray(ignoredBuilds) && ignoredBuilds.length > 0
1214
- ? `构建脚本被 pnpm 默认拦截(${ignoredBuilds.join(', ')}),请点击上方按钮放行后重试 / build scripts are blocked by pnpm by default (${ignoredBuilds.join(', ')}); click "Allow build scripts and retry" above`
1215
- : undefined,
1213
+ // A loader-id clash is the most actionable failure of all: the
1214
+ // plugin is fine, it just cannot coexist with this profile (#122).
1215
+ error: conflicts.length > 0
1216
+ ? `「${conflicts[0].name}」与已安装的「${conflicts[0].owner}」使用了相同的 loader 条目 id(${[...new Set(conflicts.map(hit => hit.id))].join(', ')}),两者无法在同一个 profile 共存——装上会导致 DSH 下次启动失败,因此已自动移除。这类插件(例如终端 TUI 插件)请装到单独的 profile。 / "${conflicts[0].name}" declares the same loader entry id(s) as the installed "${conflicts[0].owner}" (${[...new Set(conflicts.map(hit => hit.id))].join(', ')}); they cannot coexist in one profile — keeping it would stop DSH from starting, so it was removed. Install this kind of plugin (e.g. a terminal TUI bundle) into its own profile.`
1217
+ : notAPlugin
1218
+ ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志'
1219
+ : Array.isArray(ignoredBuilds) && ignoredBuilds.length > 0
1220
+ ? `构建脚本被 pnpm 默认拦截(${ignoredBuilds.join(', ')}),请点击上方按钮放行后重试 / build scripts are blocked by pnpm by default (${ignoredBuilds.join(', ')}); click "Allow build scripts and retry" above`
1221
+ : undefined,
1216
1222
  exitCode: result.exitCode,
1217
1223
  timedOut: result.timedOut,
1218
1224
  stdout: result.stdout,
package/lib/store.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * pnpm store hygiene: reclaim staging directories orphaned by aborted runs.
3
+ *
4
+ * pnpm extracts every fetched tarball under the store's `tmp/` directory as
5
+ * `tmp/_tmp_<pid>_<hex>/`. A run that is killed, cancelled, or timed out —
6
+ * or one that hard-fails mid-fetch — never finishes that staging step, so
7
+ * the directory survives. For `github:` sources the staged payload is the
8
+ * WHOLE repository tarball, so a single aborted install can leave hundreds
9
+ * of megabytes behind (e.g. an OpenViking monorepo install killed at ~88MB).
10
+ *
11
+ * The directory name carries the owning pnpm process id, which makes
12
+ * reclamation safe by construction: when that pid is gone, no live download
13
+ * can be using the directory. Live pnpm tmp dirs are never touched.
14
+ */
15
+ import { readdirSync, rmSync } from 'node:fs';
16
+ import { isAbsolute, join } from 'node:path';
17
+ import { logEvent } from './log.js';
18
+ /** pnpm store tmp staging prefix: `_tmp_<pid>_<random-hex>/`. */
19
+ const ORPHAN_TMP_RE = /^_tmp_(\d+)_/;
20
+ /** True when the process with this pid is still running (EPERM = exists). */
21
+ function pidAlive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch (error) {
27
+ return error.code === 'EPERM';
28
+ }
29
+ }
30
+ /**
31
+ * Remove every orphaned staging directory under a pnpm store's `tmp/` whose
32
+ * owning pid is no longer alive. Directories that do not match the pnpm
33
+ * staging shape, and any that are locked or in use, are left alone.
34
+ * @param storePath - the pnpm store root (as printed by `pnpm store path`).
35
+ * @returns the removed directory names.
36
+ */
37
+ export function cleanOrphanedStoreTmp(storePath) {
38
+ const tmp = join(storePath, 'tmp');
39
+ let entries;
40
+ try {
41
+ entries = readdirSync(tmp, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ return []; // no store tmp dir — nothing to reclaim
45
+ }
46
+ const removed = [];
47
+ for (const entry of entries) {
48
+ if (!entry.isDirectory())
49
+ continue;
50
+ const m = ORPHAN_TMP_RE.exec(entry.name);
51
+ if (m === null)
52
+ continue;
53
+ const pid = Number(m[1]);
54
+ if (pid > 0 && pidAlive(pid))
55
+ continue;
56
+ try {
57
+ rmSync(join(tmp, entry.name), { recursive: true, force: true });
58
+ removed.push(entry.name);
59
+ }
60
+ catch {
61
+ // Locked or still being written — leave it for a later run.
62
+ }
63
+ }
64
+ return removed;
65
+ }
66
+ /**
67
+ * Resolve the active profile's pnpm store root through the same runner the
68
+ * market uses for installs (so both the web and Desktop pnpm paths agree)
69
+ * and reclaim its orphaned staging directories.
70
+ * @returns the removed directory names, empty when the store cannot be resolved.
71
+ */
72
+ export async function cleanOrphanedStore(run, profile) {
73
+ let result;
74
+ try {
75
+ result = await run(profile, ['store', 'path']);
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ if (result.exitCode !== 0 || result.cancelled)
81
+ return [];
82
+ const lines = result.stdout.split('\n').map(line => line.trim()).filter(line => line !== '');
83
+ const storePath = lines[lines.length - 1] ?? '';
84
+ if (storePath === '' || !isAbsolute(storePath))
85
+ return [];
86
+ const removed = cleanOrphanedStoreTmp(storePath);
87
+ if (removed.length > 0) {
88
+ logEvent('info', 'install', `removed ${removed.length} orphaned pnpm store tmp dir(s) under ${storePath}: ${removed.slice(0, 3).join(', ')}${removed.length > 3 ? ', …' : ''}`);
89
+ }
90
+ return removed;
91
+ }
@@ -8,7 +8,15 @@ import type { InstallResult, PluginRunner } from './dsh-cli.ts';
8
8
  /** One-shot bypass for pnpm's fresh-release hold; scoped to a single command. */
9
9
  export declare const RELEASE_AGE_OVERRIDE = "--config.minimumReleaseAge=0";
10
10
  /**
11
- * Run one plugin command with automatic recovery from two known pnpm traps:
11
+ * Longer per-request fetch timeout for one retried command. pnpm's default
12
+ * 60-second limit aborts large tarball downloads (github: sources fetch the
13
+ * WHOLE repo even for a `#path:` subdirectory plugin) on slow networks; a
14
+ * plain retry fails again at the same limit, so the recovery re-runs with
15
+ * this override once. Scoped to a single command like RELEASE_AGE_OVERRIDE.
16
+ */
17
+ export declare const FETCH_TIMEOUT_OVERRIDE = "--config.fetchTimeout=600000";
18
+ /**
19
+ * Run one plugin command with automatic recovery from three known pnpm traps:
12
20
  *
13
21
  * - pnpm-major drift (#20 bug 2): a modules directory built by a different
14
22
  * pnpm major fails mutation; pnpm's documented remedy is one `install` to
@@ -18,6 +26,10 @@ export declare const RELEASE_AGE_OVERRIDE = "--config.minimumReleaseAge=0";
18
26
  * retry once with the one-shot minimumReleaseAge bypass (safe: the young
19
27
  * package is already installed; the bypass only lets pnpm touch the
20
28
  * lockfile again).
29
+ * - per-request fetch timeout: large tarballs (github: sources fetch the
30
+ * whole repo even for a `#path:` subdirectory) on slow networks blow
31
+ * pnpm's default 60-second limit; a plain retry fails again at the same
32
+ * limit, so retry once with a longer fetchTimeout.
21
33
  *
22
34
  * Any recognized failure that survives gets its bilingual explanation
23
35
  * appended to stderr so the UI shows an actionable message instead of a
@@ -38,11 +50,22 @@ export declare function retargetCollections(run: PluginRunner, profile: string,
38
50
  * piece without a dsh manifest or without its declared entry artifact
39
51
  * (source-only checkout, build blocked by pnpm allowBuilds) would brick the
40
52
  * next boot, so it is removed on the spot.
41
- * @returns names kept and names removed as broken.
53
+ *
54
+ * Since #122 this also covers duplicate loader entry ids: cordis refuses to
55
+ * load a tree containing two entries with one id, so a TUI bundle landing in
56
+ * a web profile (both declare `id: storage`) leaves DSH unable to START —
57
+ * an error naming neither plugin, from which the market's own page is
58
+ * unreachable. Such a package is removed like any other bricking piece.
59
+ * @returns names kept, names removed as broken, and the id conflicts found.
42
60
  */
43
61
  export declare function validateAddedPlugins(run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string): Promise<{
44
62
  keep: string[];
45
63
  removedBroken: string[];
64
+ conflicts: {
65
+ name: string;
66
+ id: string;
67
+ owner: string;
68
+ }[];
46
69
  }>;
47
70
  /**
48
71
  * Whether a clean-exit update actually changed nothing — pnpm's
@@ -25,7 +25,7 @@
25
25
  export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]): string[];
26
26
  /** One recognized pnpm failure, with a bilingual explanation for the UI. */
27
27
  export interface PnpmFailure {
28
- code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'fetch-404' | 'transient-network';
28
+ code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'fetch-404' | 'transient-network' | 'fetch-timeout';
29
29
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
30
30
  message: string;
31
31
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
@@ -38,6 +38,17 @@ export interface PnpmFailure {
38
38
  * deliberately absent: retrying those just doubles the wait for bad news.
39
39
  */
40
40
  export declare function isTransientPnpmFailure(output: string): boolean;
41
+ /**
42
+ * pnpm's per-request fetch timeout: the abort surfaces as a DOMException
43
+ * ("The operation was aborted due to timeout", code 23) through undici —
44
+ * pnpm logs it as `GET … error (23)` before giving up. This is the failure
45
+ * shape for large tarballs (github: sources download the WHOLE repo, even
46
+ * for a `#path:` subdirectory plugin) on slow networks: pnpm's default
47
+ * 60-second limit is simply not enough, so a plain retry fails again at the
48
+ * same limit. The market's recovery re-runs once with a longer
49
+ * fetchTimeout (see withHoistRecovery).
50
+ */
51
+ export declare function isFetchTimeoutFailure(output: string): boolean;
41
52
  /**
42
53
  * Map a failed pnpm run's combined output to a known failure mode.
43
54
  *
@@ -49,6 +49,29 @@ export declare function entryArtifactExists(dir: string): boolean;
49
49
  * bundle bring in" any name row counts.
50
50
  */
51
51
  export declare function bundlePatchTargets(dir: string): string[];
52
+ /**
53
+ * Loader entry ids a bundle patch inserts. Cordis refuses to boot a tree
54
+ * with a duplicate entry id ("duplicate loader entry id: storage", #122), so
55
+ * these are what two bundles can collide on.
56
+ */
57
+ export declare function bundlePatchEntryIds(dir: string): string[];
58
+ /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
59
+ export declare function readProfileBundles(profileDirectory: string): string[];
60
+ /**
61
+ * Loader entry ids a newly added package would collide on with bundles the
62
+ * profile ALREADY loads (#122).
63
+ *
64
+ * Cordis hard-fails the whole tree on a duplicate id, so this is not a
65
+ * cosmetic conflict: installing a TUI bundle into a web profile (both
66
+ * declare `id: storage`) leaves DSH unable to start at all, with an error
67
+ * naming neither plugin. Checked against the profile's own bundle list so a
68
+ * package is never compared with itself.
69
+ * @returns colliding ids mapped to the already-installed bundle that owns them.
70
+ */
71
+ export declare function conflictingEntryIds(profileDirectory: string, candidate: string, installedBundles: readonly string[]): {
72
+ id: string;
73
+ owner: string;
74
+ }[];
52
75
  /**
53
76
  * Whether the loader has anything to load for this package: its own entry
54
77
  * artifact, or — for CARRIER bundles — patch rows naming other packages that
@@ -0,0 +1,30 @@
1
+ /**
2
+ * pnpm store hygiene: reclaim staging directories orphaned by aborted runs.
3
+ *
4
+ * pnpm extracts every fetched tarball under the store's `tmp/` directory as
5
+ * `tmp/_tmp_<pid>_<hex>/`. A run that is killed, cancelled, or timed out —
6
+ * or one that hard-fails mid-fetch — never finishes that staging step, so
7
+ * the directory survives. For `github:` sources the staged payload is the
8
+ * WHOLE repository tarball, so a single aborted install can leave hundreds
9
+ * of megabytes behind (e.g. an OpenViking monorepo install killed at ~88MB).
10
+ *
11
+ * The directory name carries the owning pnpm process id, which makes
12
+ * reclamation safe by construction: when that pid is gone, no live download
13
+ * can be using the directory. Live pnpm tmp dirs are never touched.
14
+ */
15
+ import type { PluginRunner } from './dsh-cli.ts';
16
+ /**
17
+ * Remove every orphaned staging directory under a pnpm store's `tmp/` whose
18
+ * owning pid is no longer alive. Directories that do not match the pnpm
19
+ * staging shape, and any that are locked or in use, are left alone.
20
+ * @param storePath - the pnpm store root (as printed by `pnpm store path`).
21
+ * @returns the removed directory names.
22
+ */
23
+ export declare function cleanOrphanedStoreTmp(storePath: string): string[];
24
+ /**
25
+ * Resolve the active profile's pnpm store root through the same runner the
26
+ * market uses for installs (so both the web and Desktop pnpm paths agree)
27
+ * and reclaim its orphaned staging directories.
28
+ * @returns the removed directory names, empty when the store cannot be resolved.
29
+ */
30
+ export declare function cleanOrphanedStore(run: PluginRunner, profile: string): Promise<string[]>;
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.10.0",
4
+ "version": "1.10.1",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/install.ts CHANGED
@@ -9,14 +9,24 @@ import { existsSync } from 'node:fs'
9
9
  import { join } from 'node:path'
10
10
  import type { InstallResult, PluginRunner } from './dsh-cli.ts'
11
11
  import { classifyPnpmFailure, isTransientPnpmFailure } from './pnpm-compat.ts'
12
- import { hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled } from './profile.ts'
12
+ import { conflictingEntryIds, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readProfileBundles } from './profile.ts'
13
13
  import { logEvent } from './log.ts'
14
+ import { cleanOrphanedStore } from './store.ts'
14
15
 
15
16
  /** One-shot bypass for pnpm's fresh-release hold; scoped to a single command. */
16
17
  export const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
17
18
 
18
19
  /**
19
- * Run one plugin command with automatic recovery from two known pnpm traps:
20
+ * Longer per-request fetch timeout for one retried command. pnpm's default
21
+ * 60-second limit aborts large tarball downloads (github: sources fetch the
22
+ * WHOLE repo even for a `#path:` subdirectory plugin) on slow networks; a
23
+ * plain retry fails again at the same limit, so the recovery re-runs with
24
+ * this override once. Scoped to a single command like RELEASE_AGE_OVERRIDE.
25
+ */
26
+ export const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000'
27
+
28
+ /**
29
+ * Run one plugin command with automatic recovery from three known pnpm traps:
20
30
  *
21
31
  * - pnpm-major drift (#20 bug 2): a modules directory built by a different
22
32
  * pnpm major fails mutation; pnpm's documented remedy is one `install` to
@@ -26,6 +36,10 @@ export const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
26
36
  * retry once with the one-shot minimumReleaseAge bypass (safe: the young
27
37
  * package is already installed; the bypass only lets pnpm touch the
28
38
  * lockfile again).
39
+ * - per-request fetch timeout: large tarballs (github: sources fetch the
40
+ * whole repo even for a `#path:` subdirectory) on slow networks blow
41
+ * pnpm's default 60-second limit; a plain retry fails again at the same
42
+ * limit, so retry once with a longer fetchTimeout.
29
43
  *
30
44
  * Any recognized failure that survives gets its bilingual explanation
31
45
  * appended to stderr so the UI shows an actionable message instead of a
@@ -58,9 +72,25 @@ export async function withHoistRecovery(run: PluginRunner, profile: string, plug
58
72
  // Do that retry ourselves instead of reporting a false failure.
59
73
  logEvent('warn', 'install', `transient network failure while pnpm replayed the dependency tree (#83) — retrying once`)
60
74
  result = await run(profile, pluginArgs)
75
+ } else if (
76
+ failure?.code === 'fetch-timeout'
77
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
78
+ && !pluginArgs.includes(FETCH_TIMEOUT_OVERRIDE)
79
+ ) {
80
+ // Large tarball / slow network: pnpm's default 60s per-request limit
81
+ // aborted the download. A plain retry fails again at the same limit,
82
+ // so retry once with a longer fetchTimeout.
83
+ logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`)
84
+ result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)])
61
85
  }
62
86
  }
63
87
  if (!ok(result) && !result.cancelled) {
88
+ // A failed, timed-out, or killed run never finishes pnpm's staging, so
89
+ // its store tmp dirs (the WHOLE repo tarball for github: sources) are
90
+ // orphaned — reclaim them now that no pnpm is running. Safe by
91
+ // construction: directories are only removed when their owning pid is
92
+ // gone (the name carries it), so a live download is never touched.
93
+ await cleanOrphanedStore(run, profile)
64
94
  const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`)
65
95
  if (failure !== null) result = { ...result, stderr: `${result.stderr}\n\n${failure.message}` }
66
96
  }
@@ -113,27 +143,47 @@ export async function retargetCollections(
113
143
  * piece without a dsh manifest or without its declared entry artifact
114
144
  * (source-only checkout, build blocked by pnpm allowBuilds) would brick the
115
145
  * next boot, so it is removed on the spot.
116
- * @returns names kept and names removed as broken.
146
+ *
147
+ * Since #122 this also covers duplicate loader entry ids: cordis refuses to
148
+ * load a tree containing two entries with one id, so a TUI bundle landing in
149
+ * a web profile (both declare `id: storage`) leaves DSH unable to START —
150
+ * an error naming neither plugin, from which the market's own page is
151
+ * unreachable. Such a package is removed like any other bricking piece.
152
+ * @returns names kept, names removed as broken, and the id conflicts found.
117
153
  */
118
154
  export async function validateAddedPlugins(
119
155
  run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string,
120
- ): Promise<{ keep: string[]; removedBroken: string[] }> {
156
+ ): Promise<{ keep: string[]; removedBroken: string[]; conflicts: { name: string; id: string; owner: string }[] }> {
121
157
  const dir = profileDir(profile, explicitDir)
122
158
  const addedNow = Object.keys(readInstalled(profile, dir)).filter(n => !before.has(n))
123
159
  const keep: string[] = []
124
160
  const removedBroken: string[] = []
161
+ const conflicts: { name: string; id: string; owner: string }[] = []
162
+ // Compare against what the profile already loads, minus this install's own
163
+ // additions — two pieces of one plugin are not "already installed".
164
+ const existingBundles = readProfileBundles(dir).filter(name => !addedNow.includes(name))
125
165
  for (const n of addedNow) {
126
166
  const packageDir = join(dir, 'node_modules', n)
127
167
  // hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
128
168
  // ship no entry of their own (#103) and must not be uninstalled here.
129
- if (hasDshManifest(packageDir) && hasLoadableEntry(dir, n)) {
130
- keep.push(n)
131
- } else {
169
+ if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
170
+ removedBroken.push(n)
171
+ await run(profile, ['remove', n])
172
+ continue
173
+ }
174
+ const clash = conflictingEntryIds(dir, n, existingBundles)
175
+ if (clash.length > 0) {
176
+ // Keeping it would make the NEXT BOOT fail outright (#122).
177
+ conflicts.push(...clash.map(hit => ({ name: n, ...hit })))
132
178
  removedBroken.push(n)
179
+ logEvent('error', 'install',
180
+ `${n}: loader entry id conflict with ${clash[0].owner} (${clash.map(hit => hit.id).join(', ')}) — removing, it would break the next boot`)
133
181
  await run(profile, ['remove', n])
182
+ continue
134
183
  }
184
+ keep.push(n)
135
185
  }
136
- return { keep, removedBroken }
186
+ return { keep, removedBroken, conflicts }
137
187
  }
138
188
 
139
189
  /**
@@ -36,7 +36,7 @@ export function pluginArgsFor(profileDir: string, pluginArgs: string[]): string[
36
36
  /** One recognized pnpm failure, with a bilingual explanation for the UI. */
37
37
  export interface PnpmFailure {
38
38
  code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation'
39
- | 'ignored-builds' | 'git-prepare-not-allowed' | 'fetch-404' | 'transient-network'
39
+ | 'ignored-builds' | 'git-prepare-not-allowed' | 'fetch-404' | 'transient-network' | 'fetch-timeout'
40
40
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
41
41
  message: string
42
42
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
@@ -53,6 +53,20 @@ export function isTransientPnpmFailure(output: string): boolean {
53
53
  return /ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL|FetchError|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|socket hang up|network timeout/i.test(output)
54
54
  }
55
55
 
56
+ /**
57
+ * pnpm's per-request fetch timeout: the abort surfaces as a DOMException
58
+ * ("The operation was aborted due to timeout", code 23) through undici —
59
+ * pnpm logs it as `GET … error (23)` before giving up. This is the failure
60
+ * shape for large tarballs (github: sources download the WHOLE repo, even
61
+ * for a `#path:` subdirectory plugin) on slow networks: pnpm's default
62
+ * 60-second limit is simply not enough, so a plain retry fails again at the
63
+ * same limit. The market's recovery re-runs once with a longer
64
+ * fetchTimeout (see withHoistRecovery).
65
+ */
66
+ export function isFetchTimeoutFailure(output: string): boolean {
67
+ return /operation was aborted due to timeout|TimeoutError|error \(23\)/i.test(output)
68
+ }
69
+
56
70
  /**
57
71
  * Map a failed pnpm run's combined output to a known failure mode.
58
72
  *
@@ -147,6 +161,17 @@ export function classifyPnpmFailure(output: string): PnpmFailure | null {
147
161
  message: '拉取依赖时网络临时失败(不一定是你正在装的插件——安装会重放整个依赖树,任何一个既有依赖抖动都会中断)。已自动重试一次仍失败,请稍后再试 / a transient network failure while fetching dependencies (not necessarily the plugin you are installing — installs replay the whole dependency tree, so any existing dependency can hiccup); one automatic retry failed too — please try again shortly',
148
162
  }
149
163
  }
164
+ // pnpm's per-request fetch timeout (#…): large tarballs (github: sources
165
+ // fetch the whole repo even for a `#path:` subdirectory) on slow networks
166
+ // blow pnpm's default 60s limit. A plain retry fails again at the same
167
+ // limit, so withHoistRecovery retries with a longer fetchTimeout.
168
+ if (isFetchTimeoutFailure(output)) {
169
+ return {
170
+ code: 'fetch-timeout',
171
+ recoverable: false,
172
+ message: '下载超时:这个插件的安装包较大(github 源会下载整个仓库)或网络较慢,pnpm 默认的单次请求 60 秒限制不够用。市场已用更长的超时自动重试一次;若仍失败,请稍后再试或检查网络 / download timed out: this plugin ships a large tarball (github sources download the whole repository) or your network is slow, and pnpm\'s default 60-second per-request limit was not enough; the market retries once with a longer timeout — if it still fails, try again later or check the network',
173
+ }
174
+ }
150
175
  if (output.includes('pnpm not found on PATH')) {
151
176
  return {
152
177
  code: 'pnpm-missing',
package/src/profile.ts CHANGED
@@ -162,25 +162,88 @@ export function entryArtifactExists(dir: string): boolean {
162
162
  * bundle bring in" any name row counts.
163
163
  */
164
164
  export function bundlePatchTargets(dir: string): string[] {
165
+ return readBundlePatchRows(dir).names
166
+ }
167
+
168
+ /**
169
+ * Loader entry ids a bundle patch inserts. Cordis refuses to boot a tree
170
+ * with a duplicate entry id ("duplicate loader entry id: storage", #122), so
171
+ * these are what two bundles can collide on.
172
+ */
173
+ export function bundlePatchEntryIds(dir: string): string[] {
174
+ return readBundlePatchRows(dir).ids
175
+ }
176
+
177
+ /**
178
+ * `name:` and `id:` rows of the package's declared bundle patch. Line-wise
179
+ * on purpose: the strict hot-mount parser rejects config/expression rows,
180
+ * but for "what does this bundle bring in" any row counts.
181
+ */
182
+ function readBundlePatchRows(dir: string): { names: string[]; ids: string[] } {
165
183
  let patchPath: string
166
184
  try {
167
185
  const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
168
186
  dsh?: { bundle?: { patch?: unknown } }
169
187
  }
170
188
  const declared = manifest.dsh?.bundle?.patch
171
- if (typeof declared !== 'string' || declared === '') return []
189
+ if (typeof declared !== 'string' || declared === '') return { names: [], ids: [] }
172
190
  patchPath = join(dir, declared)
173
191
  } catch {
174
- return []
192
+ return { names: [], ids: [] }
175
193
  }
176
194
  const names: string[] = []
195
+ const ids: string[] = []
177
196
  try {
178
197
  for (const line of readFileSync(patchPath, 'utf8').split('\n')) {
179
- const m = /^\s*name:\s*['"]?([^'"\s]+)/.exec(line)
180
- if (m !== null && !names.includes(m[1])) names.push(m[1])
198
+ const name = /^\s*-?\s*name:\s*['"]?([^'"\s]+)/.exec(line)
199
+ if (name !== null && !names.includes(name[1])) names.push(name[1])
200
+ const id = /^\s*-?\s*id:\s*['"]?([^'"\s]+)/.exec(line)
201
+ if (id !== null && !ids.includes(id[1])) ids.push(id[1])
181
202
  }
182
- } catch { /* unreadable patch — no targets */ }
183
- return names
203
+ } catch { /* unreadable patch — nothing to report */ }
204
+ return { names, ids }
205
+ }
206
+
207
+ /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
208
+ export function readProfileBundles(profileDirectory: string): string[] {
209
+ try {
210
+ const manifest = JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8')) as {
211
+ dsh?: { profile?: { bundles?: unknown } }
212
+ }
213
+ const bundles = manifest.dsh?.profile?.bundles
214
+ return Array.isArray(bundles) ? bundles.filter((name): name is string => typeof name === 'string') : []
215
+ } catch {
216
+ return []
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Loader entry ids a newly added package would collide on with bundles the
222
+ * profile ALREADY loads (#122).
223
+ *
224
+ * Cordis hard-fails the whole tree on a duplicate id, so this is not a
225
+ * cosmetic conflict: installing a TUI bundle into a web profile (both
226
+ * declare `id: storage`) leaves DSH unable to start at all, with an error
227
+ * naming neither plugin. Checked against the profile's own bundle list so a
228
+ * package is never compared with itself.
229
+ * @returns colliding ids mapped to the already-installed bundle that owns them.
230
+ */
231
+ export function conflictingEntryIds(
232
+ profileDirectory: string,
233
+ candidate: string,
234
+ installedBundles: readonly string[],
235
+ ): { id: string; owner: string }[] {
236
+ const mine = bundlePatchEntryIds(join(profileDirectory, 'node_modules', candidate))
237
+ if (mine.length === 0) return []
238
+ const conflicts: { id: string; owner: string }[] = []
239
+ for (const bundle of installedBundles) {
240
+ if (bundle === candidate) continue
241
+ const theirs = new Set(bundlePatchEntryIds(join(profileDirectory, 'node_modules', bundle)))
242
+ for (const id of mine) {
243
+ if (theirs.has(id) && !conflicts.some(hit => hit.id === id)) conflicts.push({ id, owner: bundle })
244
+ }
245
+ }
246
+ return conflicts
184
247
  }
185
248
 
186
249
  /**
package/src/routes.ts CHANGED
@@ -1177,9 +1177,11 @@ export function mountMarketRoutes(
1177
1177
  // must never survive to brick the next boot.
1178
1178
  let notAPlugin = false
1179
1179
  let removedBroken: string[] = []
1180
+ let conflicts: { name: string; id: string; owner: string }[] = []
1180
1181
  if (result.exitCode === 0 && !result.timedOut && !cancelled) {
1181
1182
  const validated = await validateAddedPlugins(runPlugin, config.profile, before, activeProfileDir)
1182
1183
  removedBroken = validated.removedBroken
1184
+ conflicts = validated.conflicts
1183
1185
  if (removedBroken.length > 0) {
1184
1186
  logEvent('warn', 'install', `${target}: removed uninstallable pieces (no dsh manifest or missing build artifacts): ${removedBroken.join(', ')}`)
1185
1187
  }
@@ -1234,9 +1236,13 @@ export function mountMarketRoutes(
1234
1236
  // Blocked build scripts are expected (pnpm >= 10 blocks them by
1235
1237
  // default): surface the approve-builds banner instead of scaring
1236
1238
  // the user with pnpm's raw stack.
1237
- error: notAPlugin
1238
- ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志'
1239
- : Array.isArray(ignoredBuilds) && ignoredBuilds.length > 0
1239
+ // A loader-id clash is the most actionable failure of all: the
1240
+ // plugin is fine, it just cannot coexist with this profile (#122).
1241
+ error: conflicts.length > 0
1242
+ ? `「${conflicts[0].name}」与已安装的「${conflicts[0].owner}」使用了相同的 loader 条目 id(${[...new Set(conflicts.map(hit => hit.id))].join(', ')}),两者无法在同一个 profile 共存——装上会导致 DSH 下次启动失败,因此已自动移除。这类插件(例如终端 TUI 插件)请装到单独的 profile。 / "${conflicts[0].name}" declares the same loader entry id(s) as the installed "${conflicts[0].owner}" (${[...new Set(conflicts.map(hit => hit.id))].join(', ')}); they cannot coexist in one profile — keeping it would stop DSH from starting, so it was removed. Install this kind of plugin (e.g. a terminal TUI bundle) into its own profile.`
1243
+ : notAPlugin
1244
+ ? 'nothing installable: the plugin(s) need a build step (blocked by default, see allowBuilds) or ship no prebuilt artifacts / 没有可安装的内容:插件需要构建授权(allowBuilds,默认拦截)或未附带构建产物,详见导出日志'
1245
+ : Array.isArray(ignoredBuilds) && ignoredBuilds.length > 0
1240
1246
  ? `构建脚本被 pnpm 默认拦截(${ignoredBuilds.join(', ')}),请点击上方按钮放行后重试 / build scripts are blocked by pnpm by default (${ignoredBuilds.join(', ')}); click "Allow build scripts and retry" above`
1241
1247
  : undefined,
1242
1248
  exitCode: result.exitCode,
package/src/store.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * pnpm store hygiene: reclaim staging directories orphaned by aborted runs.
3
+ *
4
+ * pnpm extracts every fetched tarball under the store's `tmp/` directory as
5
+ * `tmp/_tmp_<pid>_<hex>/`. A run that is killed, cancelled, or timed out —
6
+ * or one that hard-fails mid-fetch — never finishes that staging step, so
7
+ * the directory survives. For `github:` sources the staged payload is the
8
+ * WHOLE repository tarball, so a single aborted install can leave hundreds
9
+ * of megabytes behind (e.g. an OpenViking monorepo install killed at ~88MB).
10
+ *
11
+ * The directory name carries the owning pnpm process id, which makes
12
+ * reclamation safe by construction: when that pid is gone, no live download
13
+ * can be using the directory. Live pnpm tmp dirs are never touched.
14
+ */
15
+
16
+ import { readdirSync, rmSync, type Dirent } from 'node:fs'
17
+ import { isAbsolute, join } from 'node:path'
18
+ import type { InstallResult, PluginRunner } from './dsh-cli.ts'
19
+ import { logEvent } from './log.ts'
20
+
21
+ /** pnpm store tmp staging prefix: `_tmp_<pid>_<random-hex>/`. */
22
+ const ORPHAN_TMP_RE = /^_tmp_(\d+)_/
23
+
24
+ /** True when the process with this pid is still running (EPERM = exists). */
25
+ function pidAlive(pid: number): boolean {
26
+ try {
27
+ process.kill(pid, 0)
28
+ return true
29
+ } catch (error) {
30
+ return (error as NodeJS.ErrnoException).code === 'EPERM'
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Remove every orphaned staging directory under a pnpm store's `tmp/` whose
36
+ * owning pid is no longer alive. Directories that do not match the pnpm
37
+ * staging shape, and any that are locked or in use, are left alone.
38
+ * @param storePath - the pnpm store root (as printed by `pnpm store path`).
39
+ * @returns the removed directory names.
40
+ */
41
+ export function cleanOrphanedStoreTmp(storePath: string): string[] {
42
+ const tmp = join(storePath, 'tmp')
43
+ let entries: Dirent[]
44
+ try {
45
+ entries = readdirSync(tmp, { withFileTypes: true })
46
+ } catch {
47
+ return [] // no store tmp dir — nothing to reclaim
48
+ }
49
+ const removed: string[] = []
50
+ for (const entry of entries) {
51
+ if (!entry.isDirectory()) continue
52
+ const m = ORPHAN_TMP_RE.exec(entry.name)
53
+ if (m === null) continue
54
+ const pid = Number(m[1])
55
+ if (pid > 0 && pidAlive(pid)) continue
56
+ try {
57
+ rmSync(join(tmp, entry.name), { recursive: true, force: true })
58
+ removed.push(entry.name)
59
+ } catch {
60
+ // Locked or still being written — leave it for a later run.
61
+ }
62
+ }
63
+ return removed
64
+ }
65
+
66
+ /**
67
+ * Resolve the active profile's pnpm store root through the same runner the
68
+ * market uses for installs (so both the web and Desktop pnpm paths agree)
69
+ * and reclaim its orphaned staging directories.
70
+ * @returns the removed directory names, empty when the store cannot be resolved.
71
+ */
72
+ export async function cleanOrphanedStore(run: PluginRunner, profile: string): Promise<string[]> {
73
+ let result: InstallResult
74
+ try {
75
+ result = await run(profile, ['store', 'path'])
76
+ } catch {
77
+ return []
78
+ }
79
+ if (result.exitCode !== 0 || result.cancelled) return []
80
+ const lines = result.stdout.split('\n').map(line => line.trim()).filter(line => line !== '')
81
+ const storePath = lines[lines.length - 1] ?? ''
82
+ if (storePath === '' || !isAbsolute(storePath)) return []
83
+ const removed = cleanOrphanedStoreTmp(storePath)
84
+ if (removed.length > 0) {
85
+ logEvent('info', 'install',
86
+ `removed ${removed.length} orphaned pnpm store tmp dir(s) under ${storePath}: ${removed.slice(0, 3).join(', ')}${removed.length > 3 ? ', …' : ''}`)
87
+ }
88
+ return removed
89
+ }