dsh-plugin 1.0.0 → 1.1.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.
Files changed (39) hide show
  1. package/README.en.md +12 -7
  2. package/README.md +12 -7
  3. package/client/client.js +725 -223
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +43 -7
  6. package/lib/services/installed-versions.js +16 -1
  7. package/lib/services/loader.js +12 -5
  8. package/lib/services/npm-resolve.js +67 -0
  9. package/lib/services/task-queue.js +55 -10
  10. package/lib/types/services/install-types.d.ts +8 -0
  11. package/lib/types/services/installed-versions.d.ts +10 -0
  12. package/lib/types/services/loader.d.ts +6 -4
  13. package/lib/types/services/npm-resolve.d.ts +6 -0
  14. package/lib/types/services/task-queue.d.ts +11 -5
  15. package/package.json +1 -1
  16. package/src/client/components/AboutModal.tsx +72 -0
  17. package/src/client/components/CatalogHeader.tsx +30 -5
  18. package/src/client/components/HubUpdateModal.tsx +84 -0
  19. package/src/client/components/NotificationsModal.tsx +84 -58
  20. package/src/client/components/PluginCard.tsx +3 -0
  21. package/src/client/components/PluginHubSection.tsx +73 -14
  22. package/src/client/components/modals.tsx +42 -18
  23. package/src/client/hooks/useCatalog.ts +96 -18
  24. package/src/client/hooks/useTaskQueue.ts +164 -54
  25. package/src/client/lib/catalog.ts +81 -13
  26. package/src/client/lib/failures.ts +14 -2
  27. package/src/client/lib/renderMarkdown.ts +116 -0
  28. package/src/client/locales.ts +47 -4
  29. package/src/client/styles/Header.module.css +83 -3
  30. package/src/client/styles/List.module.css +4 -4
  31. package/src/client/styles/Modal.module.css +320 -5
  32. package/src/client/styles/tokens.module.css +8 -3
  33. package/src/client/types.ts +3 -1
  34. package/src/server/http/routes.ts +42 -7
  35. package/src/server/services/install-types.ts +8 -0
  36. package/src/server/services/installed-versions.ts +20 -1
  37. package/src/server/services/loader.ts +12 -5
  38. package/src/server/services/npm-resolve.ts +67 -0
  39. package/src/server/services/task-queue.ts +57 -10
@@ -113,6 +113,28 @@
113
113
  padding: 6px 10px;
114
114
  }
115
115
 
116
+ /* 可能需要命令行辅助的插件提示(webInstallable=false):中性说明,仍允许尝试一键安装 */
117
+ .cliOnlyHint {
118
+ font-size: 12px;
119
+ line-height: 18px;
120
+ color: var(--hub-text-secondary);
121
+ background: var(--hub-bg-hover);
122
+ border-radius: 6px;
123
+ padding: 6px 10px;
124
+ }
125
+
126
+ /* 安装失败引导:红色醒目,提示复制命令到 dsh 终端手动执行(原生依赖构建脚本需人工放行) */
127
+ .failedCopyHint {
128
+ font-size: 12px;
129
+ line-height: 18px;
130
+ font-weight: 500;
131
+ color: var(--hub-danger-text);
132
+ background: var(--hub-danger-tint);
133
+ border: 1px solid var(--hub-danger-border-soft);
134
+ border-radius: 6px;
135
+ padding: 6px 10px;
136
+ }
137
+
116
138
  .modalRow {
117
139
  display: flex;
118
140
  align-items: baseline;
@@ -723,18 +745,29 @@
723
745
  padding-right: 2px;
724
746
  }
725
747
 
726
- /* 每条通知 = 一张独立卡片:左侧圆形状态徽标 + 右侧内容列,
727
- 徽标垂直居中于整条(失败条目下方还有修复/提 Issue 动作,比成功条目高) */
748
+ /* 每条通知 = 一张独立卡片:纵向布局 —— 上(徽标+内容)行、下(时间戳)行;
749
+ 徽标与文字在 .noticeRowMain 里水平垂直居中,时间戳独立一行不参与居中 */
728
750
  .noticeRow {
729
751
  display: flex;
730
- align-items: center;
731
- gap: 10px;
752
+ flex-direction: column;
753
+ align-items: stretch;
754
+ gap: 6px;
732
755
  padding: 10px 12px;
733
756
  border: 1px solid var(--hub-border-2);
734
757
  border-radius: 8px;
735
758
  background: var(--hub-bg-2);
736
759
  }
737
760
 
761
+ /* 徽标 + 内容列一行:徽标与文字垂直居中(失败条目下方还有修复/提 Issue 动作,
762
+ 徽标仍与该行文字对齐,而非被下方动作拉偏) */
763
+ .noticeRowMain {
764
+ display: flex;
765
+ align-items: center;
766
+ gap: 10px;
767
+ width: 100%;
768
+ min-width: 0;
769
+ }
770
+
738
771
  /* 圆形状态徽标:成功 = 绿底白勾;失败 = 红底白叉(用户要求的「红色圆形 + 白色图形」语义) */
739
772
  .noticeBadgeOk,
740
773
  .noticeBadgeFail {
@@ -867,7 +900,14 @@
867
900
  text-decoration: underline;
868
901
  }
869
902
 
870
- .failTime {
903
+ /* 每条通知底部的完整时间戳:YYYY-MM-DD HH:mm:ss,靠右下角展示 */
904
+ .noticeFoot {
905
+ display: flex;
906
+ justify-content: flex-end;
907
+ align-items: center;
908
+ }
909
+
910
+ .noticeTime {
871
911
  flex-shrink: 0;
872
912
  font-size: 11px;
873
913
  line-height: 16px;
@@ -875,6 +915,40 @@
875
915
  font-variant-numeric: tabular-nums;
876
916
  }
877
917
 
918
+ /* 每条通知右侧的单条删除按钮:小号透明 X,靠右对齐,hover 变红提示可删,可单独移除该条 */
919
+ .noticeRemove {
920
+ margin-left: auto;
921
+ flex-shrink: 0;
922
+ display: inline-flex;
923
+ align-items: center;
924
+ justify-content: center;
925
+ width: 22px;
926
+ height: 22px;
927
+ color: var(--hub-text-tertiary);
928
+ background: transparent;
929
+ border: none;
930
+ border-radius: 5px;
931
+ cursor: pointer;
932
+ user-select: none;
933
+ transition: color 120ms ease, background 120ms ease;
934
+ }
935
+
936
+ .noticeRemove:hover {
937
+ color: var(--hub-danger-text);
938
+ background: var(--hub-danger-tint);
939
+ }
940
+
941
+ /* 成功通知卡片(时间戳已并入头部行):删除按钮不再用 auto margin 顶到最右,
942
+ 由时间戳的 margin-left:auto 把「时间 + 删除」整体推到行尾 —— 图标文字与
943
+ 时间垂直居中,卡片只有一行内容,整体居中不顶头 */
944
+ .noticeRowOk .noticeRemove {
945
+ margin-left: 0;
946
+ }
947
+
948
+ .noticeRowOk .noticeTime {
949
+ margin-left: auto;
950
+ }
951
+
878
952
  /* 记录内的复制按钮:纯灰文字、无边框,低调隐蔽,点击即可复制该条完整错误日志 */
879
953
  .failCopy {
880
954
  flex-shrink: 0;
@@ -963,3 +1037,244 @@
963
1037
  border: 1px solid var(--hub-warning-border);
964
1038
  border-radius: 6px;
965
1039
  }
1040
+
1041
+ /* —— 「关注我们」弹窗 —— */
1042
+
1043
+ /* 关注弹窗:比普通弹窗略宽,给 Markdown 介绍 + 反馈群二维码留出阅读空间;
1044
+ max-width 覆盖普通弹窗的 calc(100vw - 32px),窄屏时仍不贴边 */
1045
+ .aboutModal {
1046
+ width: 460px;
1047
+ max-width: calc(100vw - 48px);
1048
+ }
1049
+
1050
+ /* Markdown 内容区:固定最大高度内滚动,介绍文字 + 二维码不把弹窗撑爆;
1051
+ 样式沿用 hubUpdateNotes 的 12px 基线,仅二维码图片单独约束最大尺寸 */
1052
+ .aboutContent {
1053
+ flex: 1 1 auto;
1054
+ min-height: 0;
1055
+ max-height: 340px;
1056
+ overflow-y: auto;
1057
+ font-size: 12px;
1058
+ line-height: 18px;
1059
+ color: var(--hub-text-secondary);
1060
+ background: var(--hub-bg-2);
1061
+ border: 1px solid var(--hub-border-2);
1062
+ border-radius: 8px;
1063
+ padding: 10px 12px;
1064
+ }
1065
+
1066
+ .aboutContent > *:first-child {
1067
+ margin-top: 0;
1068
+ }
1069
+
1070
+ .aboutContent > *:last-child {
1071
+ margin-bottom: 0;
1072
+ }
1073
+
1074
+ .aboutContent h2,
1075
+ .aboutContent h3,
1076
+ .aboutContent h4,
1077
+ .aboutContent h5 {
1078
+ margin: 10px 0 4px;
1079
+ font-size: 13px;
1080
+ font-weight: 600;
1081
+ line-height: 20px;
1082
+ color: var(--hub-text-primary);
1083
+ }
1084
+
1085
+ .aboutContent h2 {
1086
+ font-size: 14px;
1087
+ }
1088
+
1089
+ .aboutContent p {
1090
+ margin: 4px 0;
1091
+ }
1092
+
1093
+ .aboutContent ul,
1094
+ .aboutContent ol {
1095
+ margin: 4px 0;
1096
+ padding-left: 18px;
1097
+ }
1098
+
1099
+ .aboutContent li {
1100
+ margin: 2px 0;
1101
+ }
1102
+
1103
+ .aboutContent code {
1104
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1105
+ font-size: 11px;
1106
+ background: var(--hub-bg-hover);
1107
+ border-radius: 4px;
1108
+ padding: 1px 4px;
1109
+ color: var(--hub-text-primary);
1110
+ }
1111
+
1112
+ .aboutContent pre {
1113
+ margin: 6px 0;
1114
+ background: var(--hub-bg-1);
1115
+ border: 1px solid var(--hub-border-2);
1116
+ border-radius: 6px;
1117
+ padding: 8px 10px;
1118
+ overflow-x: auto;
1119
+ }
1120
+
1121
+ .aboutContent pre code {
1122
+ background: none;
1123
+ padding: 0;
1124
+ border-radius: 0;
1125
+ }
1126
+
1127
+ .aboutContent blockquote {
1128
+ margin: 6px 0;
1129
+ padding: 2px 10px;
1130
+ border-left: 3px solid var(--hub-brand);
1131
+ background: var(--hub-bg-hover);
1132
+ border-radius: 0 6px 6px 0;
1133
+ color: var(--hub-text-tertiary);
1134
+ }
1135
+
1136
+ .aboutContent a {
1137
+ color: var(--hub-brand);
1138
+ text-decoration: none;
1139
+ }
1140
+
1141
+ .aboutContent a:hover {
1142
+ color: var(--hub-brand-hover);
1143
+ text-decoration: underline;
1144
+ }
1145
+
1146
+ /* 内容里的图片(反馈群二维码):居中、最大高度受控,不撑破弹窗:
1147
+ 当图片自身更窄时收缩到实际宽度(不强行拉满铺平) */
1148
+ .aboutContent img {
1149
+ display: block;
1150
+ margin: 8px auto;
1151
+ max-width: 100%;
1152
+ max-height: 220px;
1153
+ border-radius: 8px;
1154
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
1155
+ }
1156
+
1157
+ /* 更新时间小字:弱化展示在内容区下方 */
1158
+ .aboutMeta {
1159
+ font-size: 11px;
1160
+ line-height: 16px;
1161
+ color: var(--hub-text-tertiary);
1162
+ }
1163
+
1164
+ /* —— Hub 自我更新说明弹窗 —— */
1165
+
1166
+ /* 更新说明弹窗:比普通弹窗略宽,给变更记录留出阅读空间 */
1167
+ .hubUpdateModal {
1168
+ width: 520px;
1169
+ max-width: calc(100vw - 48px);
1170
+ }
1171
+
1172
+ /* 版本 / 发布时间元信息行:小号标签并排,弱化但可读 */
1173
+ .hubUpdateMeta {
1174
+ display: flex;
1175
+ align-items: center;
1176
+ flex-wrap: wrap;
1177
+ gap: 4px 14px;
1178
+ font-size: 12px;
1179
+ line-height: 18px;
1180
+ }
1181
+
1182
+ .hubUpdateMetaItem {
1183
+ color: var(--hub-text-tertiary);
1184
+ }
1185
+
1186
+ /* Markdown 变更记录区:固定最大高度内滚动,长更新记录不把弹窗撑爆;
1187
+ 子元素(标题/列表/代码块/引用)继承 12px 基线,仅标题加大加粗 */
1188
+ .hubUpdateNotes {
1189
+ flex: 1 1 auto;
1190
+ min-height: 0;
1191
+ max-height: 300px;
1192
+ overflow-y: auto;
1193
+ font-size: 12px;
1194
+ line-height: 18px;
1195
+ color: var(--hub-text-secondary);
1196
+ background: var(--hub-bg-2);
1197
+ border: 1px solid var(--hub-border-2);
1198
+ border-radius: 8px;
1199
+ padding: 10px 12px;
1200
+ }
1201
+
1202
+ .hubUpdateNotes > *:first-child {
1203
+ margin-top: 0;
1204
+ }
1205
+
1206
+ .hubUpdateNotes > *:last-child {
1207
+ margin-bottom: 0;
1208
+ }
1209
+
1210
+ .hubUpdateNotes h2,
1211
+ .hubUpdateNotes h3,
1212
+ .hubUpdateNotes h4,
1213
+ .hubUpdateNotes h5 {
1214
+ margin: 10px 0 4px;
1215
+ font-size: 13px;
1216
+ font-weight: 600;
1217
+ line-height: 20px;
1218
+ color: var(--hub-text-primary);
1219
+ }
1220
+
1221
+ .hubUpdateNotes h2 {
1222
+ font-size: 14px;
1223
+ }
1224
+
1225
+ .hubUpdateNotes p {
1226
+ margin: 4px 0;
1227
+ }
1228
+
1229
+ .hubUpdateNotes ul,
1230
+ .hubUpdateNotes ol {
1231
+ margin: 4px 0;
1232
+ padding-left: 18px;
1233
+ }
1234
+
1235
+ .hubUpdateNotes li {
1236
+ margin: 2px 0;
1237
+ }
1238
+
1239
+ .hubUpdateNotes code {
1240
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1241
+ font-size: 11px;
1242
+ background: var(--hub-bg-hover);
1243
+ border-radius: 4px;
1244
+ padding: 1px 4px;
1245
+ color: var(--hub-text-primary);
1246
+ }
1247
+
1248
+ .hubUpdateNotes pre {
1249
+ margin: 6px 0;
1250
+ background: var(--hub-bg-1);
1251
+ border: 1px solid var(--hub-border-2);
1252
+ border-radius: 6px;
1253
+ padding: 8px 10px;
1254
+ overflow-x: auto;
1255
+ }
1256
+
1257
+ .hubUpdateNotes pre code {
1258
+ background: none;
1259
+ padding: 0;
1260
+ border-radius: 0;
1261
+ }
1262
+
1263
+ .hubUpdateNotes blockquote {
1264
+ margin: 6px 0;
1265
+ padding: 2px 10px;
1266
+ border-left: 3px solid var(--hub-brand);
1267
+ background: var(--hub-bg-hover);
1268
+ border-radius: 0 6px 6px 0;
1269
+ color: var(--hub-text-tertiary);
1270
+ }
1271
+
1272
+ .hubUpdateNotes a {
1273
+ color: var(--hub-brand);
1274
+ text-decoration: none;
1275
+ }
1276
+
1277
+ .hubUpdateNotes a:hover {
1278
+ color: var(--hub-brand-hover);
1279
+ text-decoration: underline;
1280
+ }
@@ -53,9 +53,9 @@ body[data-ds-dark-theme] {
53
53
  --hub-btn-hover: var(--dsw-alias-button-primary-hover, #43454a);
54
54
 
55
55
  /* success */
56
- --hub-success: var(--dsw-alias-state-success-primary, #1a7f37);
57
- --hub-success-tint: rgba(34, 197, 94, 0.14);
58
- --hub-success-border: var(--dsw-alias-state-success-secondary, rgba(26, 127, 55, 0.3));
56
+ --hub-success: #16a34a;
57
+ --hub-success-tint: rgba(22, 163, 74, 0.12);
58
+ --hub-success-border: rgba(22, 163, 74, 0.3);
59
59
 
60
60
  /* warning (amber) */
61
61
  --hub-warn: var(--dsw-alias-state-warn-primary, #b8860b);
@@ -83,4 +83,9 @@ body[data-ds-dark-theme] {
83
83
  /* 暗色下的计数胶囊底色:白半透明,深色底上更可辨 */
84
84
  body[data-ds-dark-theme] {
85
85
  --hub-bg-btn: rgba(255, 255, 255, 0.12);
86
+
87
+ /* 暗色下的成功绿:提亮一档,对勾在深底上清晰且不刺眼 */
88
+ --hub-success: #22c55e;
89
+ --hub-success-tint: rgba(34, 197, 94, 0.16);
90
+ --hub-success-border: rgba(34, 197, 94, 0.35);
86
91
  }
@@ -46,7 +46,9 @@ export interface HubPlugin {
46
46
  topics?: string[]
47
47
  features?: string[]
48
48
  description?: string
49
- source?: { repo?: string }
49
+ source?: { repo?: string; npmPackage?: string }
50
+ /** 能否网页一键安装:false = 仅支持命令行/git/本地构建安装(缺省视为 true 可安装) */
51
+ install?: { webInstallable?: boolean }
50
52
  compatibility?: { status?: string }
51
53
  dates?: { repoUpdatedAt?: string; addedAt?: string }
52
54
  stats?: { stargazers_count?: number; forks_count?: number }
@@ -10,7 +10,8 @@ import { homedir, release } from 'node:os'
10
10
  import { dirname, join, resolve } from 'node:path'
11
11
  import { spawn } from 'node:child_process'
12
12
  import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubTarget, hasQueuedTarget, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install.ts'
13
- import { recordInstalledVersion, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.ts'
13
+ import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/installed-versions.ts'
14
+ import { resolveNpmPackage } from '../services/npm-resolve.ts'
14
15
  import { preflightTarget } from '../services/preflight.ts'
15
16
 
16
17
  export interface WebRoute {
@@ -172,17 +173,44 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
172
173
  const rawRepo = typeof body === 'object' && body !== null && typeof (body as { repo?: unknown }).repo === 'string'
173
174
  ? (body as { repo: string }).repo.trim()
174
175
  : ''
176
+ // 前端决策的展示用仓库名(owner/repo):npm 安装时 body.repo 是包名,display 用于
177
+ // 待重启行 / 任务恢复时展示仓库名,保证用户无感知(安装通道变化不改变所见目标)
178
+ const displayRepo = typeof body === 'object' && body !== null && typeof (body as { display?: unknown }).display === 'string'
179
+ ? (body as { display: string }).display.trim()
180
+ : rawRepo
175
181
  // 目标语法两态:owner/repo → github 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装
176
182
  const repoTarget = githubTarget(rawRepo)
177
- const target = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null)
183
+ let target: string | null = repoTarget ?? (repoTarget === null && validPackageName(rawRepo) ? rawRepo : null)
178
184
  if (target === null) {
179
185
  sendJson(response, 400, { error: 'unsupported install target' })
180
186
  return
181
187
  }
188
+ // npm 优先:git 目标先反查该仓库的官方 npm 包,命中则改走 npm 通道。
189
+ // git 分发常缺构建产物/子模块导致 prepare 必败,而 npm 包是作者发布的
190
+ // 完整产物,成功率高得多;未命中或查询失败保留 github 直装(不阻塞安装,
191
+ // 预检仍会兜底拦截 git 分发缺入口文件的情况)。通用机制,不针对具体插件。
192
+ // 反查本身计入「已尝试的安装方式」:组织 scope 与 GitHub 用户名不一致时仅凭
193
+ // 仓库名猜不到包名,失败提 Issue 时作者看到我们查过的命令就能直接指认正确包名。
194
+ const attempts: string[] = []
195
+ if (repoTarget !== null) {
196
+ const npmName = await resolveNpmPackage(rawRepo)
197
+ if (npmName !== null) {
198
+ target = npmName
199
+ attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → found \`${npmName}\``)
200
+ // 持久化 repo → npm 包名映射:目录数据未下发 npmPackage(组织 scope 与 GitHub
201
+ // 用户名不一致)时,客户端仍能通过 /installed 的 versions 把依赖 key 匹配回仓库,
202
+ // 列表立即显示「已安装」,避免安装成功后仍显示可安装
203
+ recordResolvedNpmPackage(profile, rawRepo, npmName)
204
+ } else {
205
+ attempts.push(`npm registry search: \`npm search repository:${rawRepo}\` → no matching package found (falling back to git install)`)
206
+ }
207
+ }
182
208
  // 重复安装防护:非更新请求命中已安装目标时直接拒绝,不重复跑 CLI
183
209
  // (否则 CLI 会因「已存在」失败,且用户看到的是莫名其妙的报错);
184
- // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装
185
- const already = installedByRepo(readInstalled(profile), target)
210
+ // 更新请求(mode: 'update')放行,CLI 的 add 对已存在依赖是 pnpm 原位覆盖重装。
211
+ // 命中判定双通道:npm 包名按 dependencies 键直接命中,github: 目标按 spec 值匹配。
212
+ const installed = readInstalled(profile)
213
+ const already = installedByRepo(installed, target) ?? (installed[target] !== undefined ? target : null)
186
214
  if (already !== null && mode !== 'update') {
187
215
  sendJson(response, 409, { error: `already installed: ${already}` })
188
216
  return
@@ -194,13 +222,16 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
194
222
  }
195
223
  // 安装前预检:仅新安装走预检(github 源分发改入口文件缺失时直接拦截)。
196
224
  // 更新是已信任目标的覆盖重装,跳过预检避免重复下载 tarball。
197
- // 只支持官方默认安装方式(dsh plugin add github:owner/repo),不做任何 npm 兜底降级;
198
- // [packaging] 前缀供客户端归类为「插件未适配官方默认安装方式」并引导去仓库提 Issue
225
+ // npm 包信任 registry 直接放行;github 源拦截时标记 [packaging] 前缀,
226
+ // 供客户端归类为「插件分发不完整」并引导去仓库提 Issue
199
227
  if (already === null) {
200
228
  const preflight = await preflightTarget(target)
201
229
  if (!preflight.ok) {
230
+ // 预检拦截(git 分发缺入口文件):同步 400 返回,附上已尝试的安装方式,
231
+ // 客户端失败弹窗提 Issue 时一并贴给作者
202
232
  sendJson(response, 400, {
203
- error: `[packaging] ${target}: does not support the official default install method — the git distribution lacks build output (the entry file ${preflight.missing ?? '?'} declared in package.json is not in the repository). Report to the author.`,
233
+ error: `[packaging] ${target}: plugin distribution is incomplete — the entry file ${preflight.missing ?? '?'} declared in package.json is not present in the git distribution (build output not committed). Report to the author.`,
234
+ attempts,
204
235
  })
205
236
  return
206
237
  }
@@ -217,6 +248,10 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
217
248
  action: 'add',
218
249
  profile,
219
250
  target,
251
+ // npm 安装时 target 是包名:displayTarget 固定展示仓库名,待重启行/前端恢复不受通道变化影响
252
+ displayTarget: githubTarget(displayRepo) ?? undefined,
253
+ // 已尝试的安装方式(npm 反查):实际执行命令由 spawn 时追加,失败 issue 一并展示
254
+ attempts,
220
255
  timeoutMs: COMMAND_TIMEOUT_MS,
221
256
  env: { ...process.env, CI: 'true' },
222
257
  })
@@ -13,6 +13,12 @@ export interface InstallTask {
13
13
  id: number
14
14
  /** 操作目标:`github:<owner>/<repo>`(安装)或 npm 包名(卸载),冲突/恢复时需要展示给用户 */
15
15
  target: string
16
+ /** 展示用目标(owner/repo):npm 安装时 target 是包名,前端恢复用此字段显示仓库名,保持用户无感知 */
17
+ displayTarget?: string
18
+ /** 尝试过的安装方式(npm registry 反查 + 实际执行的 CLI 命令,按先后顺序):
19
+ * 失败提 Issue 时如实贴给作者,作者据此反推正确的 npm 包名(组织 scope 与 GitHub 用户名不一致时
20
+ * 仅凭仓库名猜不到,作者看到我们查过/试过的命令就能直接指认)。 */
21
+ attempts: string[]
16
22
  action: 'add' | 'remove'
17
23
  /** 队列语义:pending 排队中 / running 执行中 / done / failed / cancelled(用户取消) */
18
24
  status: 'pending' | 'running' | 'done' | 'failed' | 'cancelled'
@@ -22,6 +28,8 @@ export interface InstallTask {
22
28
  progress: number
23
29
  /** Newest output lines first (consumer shows the tail). */
24
30
  lines: string[]
31
+ /** 完成后是否仍需宿主重启才生效:卸载时 loader 已即时移除 → false;否则 true(弹窗据此给重启选项) */
32
+ needsRestart: boolean
25
33
  }
26
34
 
27
35
  /** 子进程启动参数(复用宿主 dsh 入口,或回退到 PATH 上的 `dsh`)。 */
@@ -27,6 +27,9 @@ export interface InstalledVersionRecord {
27
27
  /** 安装时的仓库最近更新时间(ISO,GitHub pushed_at);无 release 仓库的更新依据 */
28
28
  updatedAt: string
29
29
  installedAt: string
30
+ /** npm 优先通道反查命中的官方 npm 包名(repo → 包名映射):目录数据可能未下发
31
+ * npmPackage(组织 scope 与 GitHub 用户名不一致),客户端据此把依赖 key 匹配回仓库 */
32
+ npmPackage?: string
30
33
  }
31
34
 
32
35
  export type InstalledVersionTable = Record<string, InstalledVersionRecord>
@@ -47,7 +50,23 @@ export function readInstalledVersions(profile: string): InstalledVersionTable {
47
50
  /** Record the catalog signals (version + repo updated time) present at install time for one repo. */
48
51
  export function recordInstalledVersion(profile: string, repo: string, version: string, updatedAt: string): void {
49
52
  const table = readInstalledVersions(profile)
50
- table[repo.toLowerCase()] = { version, updatedAt, installedAt: new Date().toISOString() }
53
+ const key = repo.toLowerCase()
54
+ // 合并写入:保留已记录的 npmPackage(npm 优先通道的 repo → 包名映射),
55
+ // 客户端同步版本信号时不能把它覆盖掉
56
+ table[key] = { ...table[key], version, updatedAt, installedAt: new Date().toISOString() }
57
+ writeVersions(profile, table)
58
+ }
59
+
60
+ /**
61
+ * Record the npm package name resolved for a repo (npm-first install channel).
62
+ * Called at `/install` time so the mapping exists even before the task finishes;
63
+ * harmless when the repo is not (yet) installed — the client only treats a repo
64
+ * as installed when its dependency key actually exists.
65
+ */
66
+ export function recordResolvedNpmPackage(profile: string, repo: string, npmPackage: string): void {
67
+ const table = readInstalledVersions(profile)
68
+ const key = repo.toLowerCase()
69
+ table[key] = { ...table[key], npmPackage }
51
70
  writeVersions(profile, table)
52
71
  }
53
72
 
@@ -60,13 +60,15 @@ function nameMatches(entryName: string | undefined, target: string): boolean {
60
60
  }
61
61
 
62
62
  /**
63
- * 卸载成功后从运行中 loader 停用指定包的条目,使卸载立即生效(刷新页面不再
64
- * 加载已卸载的 client bundle,也无需重启)。做法:按 name 扫描
63
+ * 卸载成功后从运行中 loader 停用指定包的条目。做法:按 name 扫描
65
64
  * `loader.entries()`(含嵌套子树),对每个匹配条目
66
65
  * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
67
66
  * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
68
- * 返回 true = 无需重启(条目已停用,或本就没加载过);false = 未能停用,
69
- * 需登记「待重启清理」兜底。宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
67
+ * 返回 true = 无需重启(仅当宿主从未加载过它:磁盘已干净);
68
+ * 其余一律 false = 需要重启:要么 disable 失败需「待重启清理」兜底,
69
+ * 要么 disable 成功但该插件曾在 loader 中存活——带 UI 的插件(侧边栏面板等
70
+ * 宿主启动时渲染的槽位)disable 后不会主动摘除,不重启面板一直残留。
71
+ * 宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
70
72
  */
71
73
  export async function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<boolean> {
72
74
  if (name === 'dsh-plugin' || name.startsWith('@deepseek-ai/')) return false
@@ -95,8 +97,13 @@ export async function removeLoadedEntry(loader: LoaderHandle, name: string): Pro
95
97
  if (!found) {
96
98
  // 运行中 loader 无该条目:宿主从未加载过它(装完未重启),磁盘已干净,无需重启
97
99
  console.log(`[hub-uninstall] no live entry for ${name}; nothing to disable`)
100
+ return true
98
101
  }
99
- return true
102
+ // 找到并停用了 live entry:逻辑层已卸载(刷新页面不再加载其 client bundle),
103
+ // 但带 UI 的插件(侧边栏面板等)是宿主启动时渲染的槽位,disable 不会主动摘除,
104
+ // 不重启面板会一直残留 → 返回「需要重启」,弹窗给出「立即重启」,面板立即消失
105
+ console.log(`[hub-uninstall] disabled live entry for ${name}; UI may persist until restart`)
106
+ return false
100
107
  })()
101
108
  const result = await Promise.race([
102
109
  removal.catch((error) => {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * npm 包反查:git 分发不完整(缺构建产物/子模块)导致安装失败时,用 npm
3
+ * registry 的搜索接口按 repository 地址反查该仓库对应的官方 npm 包。
4
+ *
5
+ * 这是通用机制,不针对任何具体插件:只要作者发布了 repository 指向该
6
+ * GitHub 仓库的 npm 包(scope 名与 GitHub 用户名是否一致无所谓),就能命中,
7
+ * 从而把「git 直装必败」的插件切换到 npm 通道安装。命中结果带内存缓存,
8
+ * 避免同一会话内重复查询 registry。
9
+ */
10
+ import { get } from 'node:https'
11
+
12
+ /** 反查缓存:repo 小写 → npm 包名(null 表示已确认无对应 npm 包)。 */
13
+ const cache = new Map<string, string | null>()
14
+
15
+ /** 单次 registry 查询超时(ms):慢网络下失败返回 null,不阻塞安装流程。 */
16
+ const REQUEST_TIMEOUT_MS = 8000
17
+
18
+ /**
19
+ * 反查 repo(`owner/repo`)对应的官方 npm 包名;未命中、网络异常或超时返回 null。
20
+ * 返回 null 不代表仓库一定没有 npm 包,只代表本次未能确认 —— 调用方应保留
21
+ * 原有错误路径,反查只是额外的一次尝试。
22
+ */
23
+ export function resolveNpmPackage(repo: string): Promise<string | null> {
24
+ const key = repo.toLowerCase()
25
+ if (cache.has(key)) return Promise.resolve(cache.get(key) ?? null)
26
+ const name = searchRepo(repo)
27
+ name.then((found) => cache.set(key, found)).catch(() => cache.set(key, null))
28
+ return name
29
+ }
30
+
31
+ /** 用 npm search 接口按 repository 地址反查,并做铁证校验(包元数据必须指向该仓库)。 */
32
+ function searchRepo(repo: string): Promise<string | null> {
33
+ const [owner, name] = repo.split('/')
34
+ if (owner === undefined || name === undefined || name === '') return Promise.resolve(null)
35
+ const url = `https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(`repository:${owner}/${name}`)}&size=10`
36
+ return new Promise((resolve) => {
37
+ const req = get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
38
+ if (res.statusCode !== 200) {
39
+ res.resume()
40
+ resolve(null)
41
+ return
42
+ }
43
+ let body = ''
44
+ res.on('data', (chunk: Buffer) => { body += chunk.toString() })
45
+ res.on('end', () => {
46
+ try {
47
+ const data = JSON.parse(body) as { objects?: Array<{ package?: { name?: unknown; links?: { repository?: unknown }; repository?: { url?: unknown } } }> }
48
+ const needle = `github.com/${owner}/${name}`.toLowerCase()
49
+ for (const obj of data.objects ?? []) {
50
+ const pkg = obj.package
51
+ if (typeof pkg?.name !== 'string' || pkg.name === '') continue
52
+ const repoUrl = String(pkg.repository?.url ?? pkg.links?.repository ?? '')
53
+ if (repoUrl.toLowerCase().includes(needle)) {
54
+ resolve(pkg.name)
55
+ return
56
+ }
57
+ }
58
+ resolve(null)
59
+ } catch {
60
+ resolve(null)
61
+ }
62
+ })
63
+ })
64
+ req.on('timeout', () => req.destroy())
65
+ req.on('error', () => resolve(null))
66
+ })
67
+ }