dsh-config-manager 0.1.39 → 0.1.41

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 (50) hide show
  1. package/lib/client.d.ts +90 -8
  2. package/lib/client.js +1904 -1086
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/cache-cleaner.d.ts +39 -0
  5. package/lib/core/cache-cleaner.js +164 -0
  6. package/lib/core/cache-cleaner.js.map +1 -0
  7. package/lib/index.js +172 -3
  8. package/lib/index.js.map +1 -1
  9. package/lib/market/github-repos.d.ts +19 -8
  10. package/lib/market/github-repos.js +62 -33
  11. package/lib/market/github-repos.js.map +1 -1
  12. package/lib/market/my-repo.d.ts +111 -3
  13. package/lib/market/my-repo.js +309 -26
  14. package/lib/market/my-repo.js.map +1 -1
  15. package/lib/market/prepare.js +3 -2
  16. package/lib/market/prepare.js.map +1 -1
  17. package/lib/market/types.d.ts +2 -0
  18. package/lib/security/secret-scanner.d.ts +18 -3
  19. package/lib/security/secret-scanner.js +144 -20
  20. package/lib/security/secret-scanner.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/client/common/ConfirmDialog.tsx +135 -0
  23. package/src/client/config-manager.module.css +101 -0
  24. package/src/client/market/MarketPanel.tsx +138 -45
  25. package/src/client/market/MyConfigsView.tsx +515 -132
  26. package/src/client/market/disclaimer.test.ts +57 -0
  27. package/src/client/market/disclaimer.ts +43 -0
  28. package/src/client/market/market-api.test.ts +3 -0
  29. package/src/client/market/market-locales.ts +37 -13
  30. package/src/client/market/my-configs-api.ts +20 -2
  31. package/src/client/market/my-configs-view.test.ts +137 -2
  32. package/src/client/market/my-configs-view.ts +174 -2
  33. package/src/client/run-store.test.ts +40 -2
  34. package/src/client/run-store.ts +70 -10
  35. package/src/client/sync/SyncConfirmView.tsx +28 -22
  36. package/src/client/sync/SyncSettingsView.tsx +175 -65
  37. package/src/client/sync/sync-api.ts +19 -0
  38. package/src/client/sync/sync-locales.ts +14 -2
  39. package/src/core/cache-cleaner.test.ts +333 -0
  40. package/src/core/cache-cleaner.ts +196 -0
  41. package/src/index.ts +165 -5
  42. package/src/market/github-repos.test.ts +99 -8
  43. package/src/market/github-repos.ts +67 -35
  44. package/src/market/my-repo.test.ts +218 -27
  45. package/src/market/my-repo.ts +403 -30
  46. package/src/market/prepare.test.ts +71 -1
  47. package/src/market/prepare.ts +3 -2
  48. package/src/market/types.ts +2 -0
  49. package/src/security/secret-scanner.ts +139 -22
  50. package/src/security/security.test.ts +111 -2
@@ -24,20 +24,25 @@ import type { TranslateNS } from '../client-types.ts'
24
24
  import type { ConfigManagerApi } from '../api.ts'
25
25
  import type { ImportResult, ImportPlan } from '../../core/types.ts'
26
26
  import { Badge, Banner, Button, Card, Empty, SectionTitle, Spinner } from '../common/ui.tsx'
27
- import { BUILTIN_MARKET_URL, isOfficialMarket } from '../../market/builtin.ts'
27
+ import { BUILTIN_MARKET_URL } from '../../market/builtin.ts'
28
28
  import type { MarketApi } from './market-api.ts'
29
29
  import type { MyConfigsApi } from './my-configs-api.ts'
30
30
  import type { SyncApi } from '../sync/sync-api.ts'
31
31
  import { MyConfigsView } from './MyConfigsView.tsx'
32
32
  import type { MyItemEntry } from './my-configs-api.ts'
33
33
  import type {
34
- MarketBrowseResponse, MarketDownloadResult, MarketListItem, MarketSummary,
34
+ MarketBrowseResponse, MarketDownloadResult, MarketListItem, MarketStatusResponse,
35
35
  } from '../../market/types.ts'
36
36
  import {
37
37
  approvalRows, approvedAdapterSummary, buildApprovedPlan, collectCategories, defaultApprovals,
38
- filterMarketItems, marketDetailView, marketListSummary, marketStatusText, sourceBadgeKind,
38
+ filterMarketItems, marketDetailView, marketListSummary, sourceBadgeKind,
39
39
  } from './market-view.ts'
40
40
  import type { MarketApprovals } from './market-view.ts'
41
+ import type { MyInstallSlice, MyWizardSlice } from './my-configs-view.ts'
42
+ import { readDisclaimerDismissed, writeDisclaimerDismissed } from './disclaimer.ts'
43
+ import type { DisclaimerKey } from './disclaimer.ts'
44
+ import { ConfirmDialog } from '../common/ConfirmDialog.tsx'
45
+ import { redact } from '../../security/redaction.ts'
41
46
  import { runStore, toMarketStoreSlice, type MarketStoreSlice } from '../run-store.ts'
42
47
  import css from '../config-manager.module.css'
43
48
 
@@ -59,10 +64,14 @@ interface MarketUiState {
59
64
  myItems: MyItemEntry[] | null
60
65
  /** 我的配置:列表加载错误(已 redact;null = 无) */
61
66
  myItemsError: string | null
67
+ /** 我的配置:上传/更新向导持久化切片(非敏感;null = 未开始;镜像 runStore,切 tab/刷新不丢) */
68
+ myWizard: MyWizardSlice | null
69
+ /** 我的配置:装回本地(下载+逐分区批准+导入结果)持久化切片(非敏感;null = 未开始/已关闭) */
70
+ myInstall: MyInstallSlice | null
71
+ /** 我的配置:删除确认弹窗目标条目 id(非敏感;镜像 runStore,切 tab/刷新不丢) */
72
+ myConfirmDeleteId: string | null
62
73
  loading: boolean
63
74
  loadError: string | null
64
- /** 内置市场摘要(status 返回;条目数 / 名称 / 最近拉取) */
65
- market: MarketSummary | null
66
75
  refreshing: boolean
67
76
  browsing: boolean
68
77
  items: MarketListItem[]
@@ -86,9 +95,11 @@ const initial: MarketUiState = {
86
95
  subView: 'browse',
87
96
  myItems: null,
88
97
  myItemsError: null,
98
+ myWizard: null,
99
+ myInstall: null,
100
+ myConfirmDeleteId: null,
89
101
  loading: true,
90
102
  loadError: null,
91
- market: null,
92
103
  refreshing: false,
93
104
  browsing: false,
94
105
  items: [],
@@ -114,6 +125,9 @@ function initFromStore(): MarketUiState {
114
125
  subView: s.subView,
115
126
  myItems: s.myItems,
116
127
  myItemsError: s.myItemsError,
128
+ myWizard: s.myWizard,
129
+ myInstall: s.myInstall,
130
+ myConfirmDeleteId: s.myConfirmDeleteId,
117
131
  search: s.search,
118
132
  category: s.category,
119
133
  items: s.items,
@@ -145,30 +159,83 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
145
159
  }
146
160
  const patch = (p: Partial<MarketUiState>): void => commit({ ...stateRef.current, ...p })
147
161
 
162
+ /* ---------------- 下载弹窗 + 免责前置(2026-08-21) ---------------- */
163
+ /** 下载详情弹窗开关(瞬态 UI,不持久化) */
164
+ const [downloadOpen, setDownloadOpen] = useState(false)
165
+ /** 当前展示的免责弹窗操作(null = 无) */
166
+ const [disclaimerKey, setDisclaimerKey] = useState<DisclaimerKey | null>(null)
167
+ /** 免责弹窗「不再提示」勾选(每次打开重置) */
168
+ const [dontAsk, setDontAsk] = useState(false)
169
+ /** localStorage(浏览器环境;免责「不再提示」跨会话持久化) */
170
+ const storage: Pick<Storage, 'getItem' | 'setItem'> = window.localStorage
171
+
172
+ /** 待下载条目(免责确认后取用;避免免责流程中闭包过期) */
173
+ const pendingDownloadItem = useRef<MarketListItem | null>(null)
174
+ /** 点条目「查看详情」:未勾「不再提示」→ 先弹免责,确认后下载并打开详情弹窗 */
175
+ const openDownload = (item: MarketListItem): void => {
176
+ pendingDownloadItem.current = item
177
+ if (readDisclaimerDismissed('download', storage)) {
178
+ setDownloadOpen(true)
179
+ void runDownload(item)
180
+ return
181
+ }
182
+ setDontAsk(false)
183
+ setDisclaimerKey('download')
184
+ }
185
+ /** 免责弹窗确认:勾选则记录「不再提示」→ 关闭免责 → 打开下载详情弹窗并启动下载 */
186
+ const confirmDownloadDisclaimer = (): void => {
187
+ if (disclaimerKey !== 'download') return
188
+ if (dontAsk) writeDisclaimerDismissed('download', storage)
189
+ setDisclaimerKey(null)
190
+ const item = pendingDownloadItem.current
191
+ setDownloadOpen(true)
192
+ if (item !== null) void runDownload(item)
193
+ }
194
+ /** 关闭下载详情弹窗:清 detail(弹窗即会话,关闭即放弃) */
195
+ const closeDownload = (): void => {
196
+ setDownloadOpen(false)
197
+ patch({ detail: null, importResult: null, error: null, approvals: {} })
198
+ }
199
+
148
200
  /** 卸载时置挂载守卫 + 最后镜像一次(防止「最后一次改动后立即切 tab」时丢状态)。 */
149
201
  useEffect(() => () => {
150
202
  mountedRef.current = false
151
203
  runStore.patch({ market: toMarketStoreSlice(stateRef.current) })
152
204
  }, [])
153
205
 
154
- /** 内置市场 URL(单一权威;来自 Host 内置常量) */
206
+ /** 内置市场 URL(单一权威;来自 Host 内置常量;用于条目来源判定与详情供应链警示) */
155
207
  const marketUrl: string = BUILTIN_MARKET_URL
156
- const official: boolean = isOfficialMarket(marketUrl)
157
208
 
158
- /** 挂载时读取内置市场状态 */
159
- const loadStatus = useCallback(async (): Promise<void> => {
209
+ /** 挂载时读取内置市场状态;返回响应供「首次打开自动更新」判据(bootAutoRefreshed) */
210
+ const loadStatus = useCallback(async (): Promise<MarketStatusResponse | null> => {
160
211
  patch({ loading: true, loadError: null })
161
212
  try {
162
213
  const info = await api.status()
163
- patch({ loading: false, market: info.markets[0] ?? null })
214
+ patch({ loading: false })
215
+ return info
164
216
  } catch (err) {
165
217
  patch({ loading: false, loadError: err instanceof Error ? err.message : String(err) })
218
+ return null
166
219
  }
167
220
  }, [api])
168
221
 
222
+ /**
223
+ * 启动后首次打开市场页 → 自动拉取一次最新 index(需求:dsh 启动后第一次打开市场页面自动更新一次市场)。
224
+ * 判据是 Host 侧进程内存标记 bootAutoRefreshed(dsh 重启后归零;refresh 成功后置位),
225
+ * 因此「每次打开都刷新」/「刷新失败后下次打开重试」都自然成立;无需客户端持久化。
226
+ * bootAutoChecked 同步置位防 StrictMode 双执行/竞态重复触发(同一组件实例只自动刷一次)。
227
+ */
228
+ const bootAutoChecked = useRef(false)
169
229
  useEffect(() => {
170
- void loadStatus()
171
- // api 为注入单例(注册时创建),生命周期内稳定;仅挂载时加载一次
230
+ if (bootAutoChecked.current) return
231
+ bootAutoChecked.current = true
232
+ void (async () => {
233
+ const info = await loadStatus()
234
+ if (info !== null && info.bootAutoRefreshed !== true) {
235
+ await runRefresh()
236
+ }
237
+ })()
238
+ // api 为注入单例(注册时创建),生命周期内稳定;仅挂载时执行一次
172
239
  // eslint-disable-next-line react-hooks/exhaustive-deps
173
240
  }, [])
174
241
 
@@ -233,8 +300,6 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
233
300
  }
234
301
 
235
302
  // ---- 渲染模型装配(全部纯函数,node 已测) ----
236
- // 状态行(共享 marketStatusText:恒 configured 单市场);loading/error 由 spinner/banner 单独呈现
237
- const statusText = marketStatusText({ count: 1 }, uiT)
238
303
  const filtered = filterMarketItems(state.items, state.search, state.category)
239
304
  const summary = marketListSummary(state.items, uiT)
240
305
  const categories = collectCategories(state.items)
@@ -285,29 +350,20 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
285
350
  myItems={state.myItems}
286
351
  myItemsError={state.myItemsError}
287
352
  onMyItemsChange={(items, error) => { patch({ myItems: items, myItemsError: error }) }}
288
- onBack={() => { patch({ subView: 'browse' }) }}
353
+ myWizard={state.myWizard}
354
+ onMyWizardChange={(wizard) => { patch({ myWizard: wizard }) }}
355
+ myInstall={state.myInstall}
356
+ onMyInstallChange={(install) => { patch({ myInstall: install }) }}
357
+ myConfirmDeleteId={state.myConfirmDeleteId}
358
+ onMyConfirmDeleteChange={(id) => { patch({ myConfirmDeleteId: id }) }}
289
359
  />
290
360
  ) : (
291
361
  <>
292
362
  <SectionTitle title={t('section.label')} subtitle={t('section.description')} />
293
363
 
294
- {/* 内置市场头部:固定 URL + 官方徽章 + 拉取最新(不可编辑) */}
364
+ {/* 内置市场操作卡:保留面板标题;移除 URL / 官方徽章 / 名称 / 条目数 / 状态行等文字展示 */}
295
365
  <Card>
296
366
  <span className={css.groupLabel}>{t('config.title')}</span>
297
- <span className={css.hint}>{t('config.builtinHint')}</span>
298
- <div className={css.statRow} style={{ paddingTop: 4 }}>
299
- <Badge kind="info">{marketUrl}</Badge>
300
- {official && <Badge kind="ok">{t('config.official')}</Badge>}
301
- {!official && <Badge kind="warn">{t('config.custom')}</Badge>}
302
- </div>
303
- {state.market !== null && (state.market.name || state.market.itemCount !== undefined) && (
304
- <div className={css.statRow}>
305
- {state.market.name !== undefined && <Badge kind="info">{state.market.name}</Badge>}
306
- {state.market.itemCount !== undefined && (
307
- <Badge kind="info">{t('list.count', { count: String(state.market.itemCount) })}</Badge>
308
- )}
309
- </div>
310
- )}
311
367
  <div className={css.actionRow}>
312
368
  <Button variant="primary" disabled={state.refreshing || state.importing} onClick={() => { void runRefresh() }}>
313
369
  {state.refreshing ? <Spinner label={t('config.refreshing')} /> : t('config.refresh')}
@@ -316,21 +372,36 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
316
372
  {state.browsing ? <Spinner label={t('list.loading')} /> : t('list.browse')}
317
373
  </Button>
318
374
  </div>
319
- <div className={css.statRow}>
320
- <Badge kind={state.loadError !== null ? 'error' : 'ok'}>{statusText}</Badge>
321
- </div>
322
375
  </Card>
323
376
 
324
377
  {state.error !== null && <Banner kind="error">{state.error}</Banner>}
325
378
 
326
- {/* 条目详情(下载 + 校验 + dry-run 预览) */}
327
- {state.detail !== null && detailView !== null && (
328
- <Card>
329
- <div className={css.actionRow}>
330
- <span className={css.groupLabel}>{t('detail.title')}:{state.detail.name}</span>
331
- <Button onClick={() => { patch({ detail: null }) }}>{t('detail.back')}</Button>
332
- </div>
333
-
379
+ {/* 条目详情弹窗(下载 + 校验 + dry-run 预览;点「查看详情」→ 免责 → 弹窗;
380
+ 下载完成前 detail 为 null → 显示 loading) */}
381
+ {downloadOpen && (
382
+ <div
383
+ className={css.dialogMask}
384
+ onMouseDown={(e) => { if (e.target === e.currentTarget && !state.importing) closeDownload() }}
385
+ >
386
+ <div className={`${css.dialogCard} ${css.dialogWide}`} role="dialog" aria-modal="true" aria-label={t('detail.title')}>
387
+ <div className={css.dialogHeaderRow}>
388
+ <span className={css.dialogHeader}>{t('detail.title')}:{state.detail !== null ? state.detail.name : (state.downloadingId ?? '')}</span>
389
+ <button
390
+ type="button"
391
+ className={css.dialogClose}
392
+ aria-label={t('common.close')}
393
+ disabled={state.importing}
394
+ onClick={closeDownload}
395
+ >
396
+ ×
397
+ </button>
398
+ </div>
399
+ <div className={css.dialogBodyScroll}>
400
+ {state.error !== null && <Banner kind="error">{redact(state.error)}</Banner>}
401
+ {state.detail === null && (
402
+ <div className={css.statRow}><Spinner label={t('common.loading')} /></div>
403
+ )}
404
+ {state.detail !== null && detailView !== null && (<>
334
405
  {/* 供应链警示:恒展示(硬约束),确认导入前必经 */}
335
406
  <Banner kind="warn">
336
407
  <strong>{t('detail.needReview')}</strong>
@@ -423,11 +494,14 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
423
494
  {state.importResult.needsRestart && ' · 部分改动需重启 DSH 后生效'}
424
495
  </Banner>
425
496
  )}
426
- </Card>
497
+ </>)}
498
+ </div>
499
+ </div>
500
+ </div>
427
501
  )}
428
502
 
429
503
  {/* 条目列表(浏览) */}
430
- {state.detail === null && (
504
+ {!downloadOpen && (
431
505
  <Card>
432
506
  {state.loadError !== null && <Empty>{t('list.empty')}</Empty>}
433
507
  {state.loadError === null && (
@@ -489,7 +563,7 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
489
563
  </Badge>
490
564
  </div>
491
565
  </div>
492
- <Button disabled={state.downloadingId === it.id} onClick={() => { void runDownload(it) }}>
566
+ <Button disabled={state.downloadingId === it.id} onClick={() => { openDownload(it) }}>
493
567
  {state.downloadingId === it.id ? <Spinner label={t('common.loading')} /> : t('list.download')}
494
568
  </Button>
495
569
  </div>
@@ -499,6 +573,25 @@ export function MarketPanel({ api, myConfigsApi, importApi, syncApi, t }: Market
499
573
  )}
500
574
  </Card>
501
575
  )}
576
+ {/* 免责弹窗(复用 ConfirmDialog + 「不再提示」勾选;download 操作) */}
577
+ <ConfirmDialog
578
+ open={disclaimerKey === 'download'}
579
+ title={t('disclaimer.title')}
580
+ message={t('disclaimer.download.text')}
581
+ confirmLabel={t('disclaimer.confirm')}
582
+ cancelLabel={t('common.cancel')}
583
+ onConfirm={confirmDownloadDisclaimer}
584
+ onCancel={() => { setDisclaimerKey(null) }}
585
+ >
586
+ <label className={css.checkboxRow}>
587
+ <input
588
+ type="checkbox"
589
+ checked={dontAsk}
590
+ onChange={(e: ChangeEvent<HTMLInputElement>) => { setDontAsk(e.target.checked) }}
591
+ />
592
+ <span>{t('disclaimer.dontAsk')}</span>
593
+ </label>
594
+ </ConfirmDialog>
502
595
  </>
503
596
  )}
504
597
  </div>