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 +84 -84
- package/lib/install.js +53 -7
- package/lib/pnpm-compat.js +24 -0
- package/lib/profile.js +65 -7
- package/lib/routes.js +11 -5
- package/lib/store.js +91 -0
- package/lib/types/install.d.ts +25 -2
- package/lib/types/pnpm-compat.d.ts +12 -1
- package/lib/types/profile.d.ts +23 -0
- package/lib/types/store.d.ts +30 -0
- package/package.json +1 -1
- package/src/install.ts +58 -8
- package/src/pnpm-compat.ts +26 -1
- package/src/profile.ts +69 -6
- package/src/routes.ts +9 -3
- package/src/store.ts +89 -0
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
|
|
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
|
-
"
|
|
683
|
-
"
|
|
684
|
-
"
|
|
685
|
-
"
|
|
686
|
-
"
|
|
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
|
-
"
|
|
689
|
-
"
|
|
690
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
699
|
-
"
|
|
700
|
-
"
|
|
701
|
-
"
|
|
702
|
-
"
|
|
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
|
-
"
|
|
713
|
-
"
|
|
714
|
-
"
|
|
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
|
-
"
|
|
720
|
-
"
|
|
721
|
-
"
|
|
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
|
-
"
|
|
724
|
-
"
|
|
725
|
-
"err": "eGUBIq_err",
|
|
726
|
-
"catsWrap": "eGUBIq_catsWrap",
|
|
728
|
+
"spec": "eGUBIq_spec",
|
|
729
|
+
"backupGrid": "eGUBIq_backupGrid",
|
|
727
730
|
"titleRow": "eGUBIq_titleRow",
|
|
728
|
-
"
|
|
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
|
-
"
|
|
752
|
-
"
|
|
753
|
-
"
|
|
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
|
-
"
|
|
758
|
-
"
|
|
759
|
-
"
|
|
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
|
-
"
|
|
761
|
+
"deprecate": "eGUBIq_deprecate",
|
|
762
|
+
"actWarn": "eGUBIq_actWarn",
|
|
763
|
+
"pageEllipsis": "eGUBIq_pageEllipsis",
|
|
764
764
|
"shots": "eGUBIq_shots",
|
|
765
|
-
"
|
|
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
|
-
"
|
|
768
|
-
"
|
|
769
|
-
"
|
|
770
|
-
"
|
|
771
|
-
"
|
|
772
|
-
"
|
|
773
|
-
"
|
|
774
|
-
"
|
|
775
|
-
"
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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)
|
|
122
|
-
|
|
159
|
+
if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
|
|
160
|
+
removedBroken.push(n);
|
|
161
|
+
await run(profile, ['remove', n]);
|
|
162
|
+
continue;
|
|
123
163
|
}
|
|
124
|
-
|
|
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
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -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
|
|
179
|
-
if (
|
|
180
|
-
names.push(
|
|
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
|
-
|
|
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
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
+
}
|
package/lib/types/install.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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
|
*
|
package/lib/types/profile.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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)
|
|
130
|
-
|
|
131
|
-
|
|
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
|
/**
|
package/src/pnpm-compat.ts
CHANGED
|
@@ -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
|
|
180
|
-
if (
|
|
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 —
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
+
}
|