dsh-install-radar 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.
package/README.md CHANGED
@@ -10,7 +10,20 @@
10
10
  | **目录快照对比**(基线 vs 现在) | 已知监视根里凭空多出来的条目、明显变大的目录 | 需要先立基线;监视根以外的目录看不见 |
11
11
  | **时间戳扫描**(`今天` 栏) | 「今天/近 N 天,哪个目录在吃盘」——包括临时目录、其它工具偷偷写的 | 只看时间窗口,不判断「该不该存在」 |
12
12
 
13
- ## v0.12 变更
13
+ ## v1.1.1 变更
14
+
15
+ - **卸载结果有回执**:点「一键卸载」后出一张结果条——成功是绿色 `✅ 卸载成功 · 释放 X`(+ 输出尾部),失败是红色 `❌ 卸载失败:winget 退出码 5`,下面跟 `🛠 解决办法` 列表(按工具给:程序还在运行 / 需要管理员 / 包 id 不对 / 指定解释器 / 路径被占用……)与「知道了」收起。
16
+ - 特别处理「退出码 0 但其实没装」:`pip` 的 `Skipping … not installed`、`winget` 的 `No installed package found` 都会被判为失败并给出对应提示。
17
+ - 目录型卸载失败(路径不存在 / 被占用 / 权限不足)同样给提示,不再静默。
18
+ - **所有行都能多选**:`非会话新增` 里每一行都有勾选框(含 🔴 danger、含 <50 MB 的小项);单行的「清理」按钮仍保留门槛(≥50 MB 且非 danger),但**批量删除对所有勾选项开放**。
19
+ - **批量结果条**:删完显示 `✅ 删除 N 项 · 释放 X`;若其中有被拒(不在临时目录白名单、被占用等),变红并逐条列出 `路径 — 原因`,一眼知道哪些没删掉、为什么。
20
+
21
+
22
+ - **风险筛选**:`非会话新增` 顶部三个彩色按钮 `🟢 N / 🟡 N / 🔴 N`,点一下只看该档,再点取消;右侧显示 `当前显示 / 总数`。
23
+ - **一键卸载**:会话条目多了 `一键卸载` 按钮(两步确认,先显示将执行的命令)。按生态真正执行:`winget uninstall --id … --silent`、`python -m pip uninstall -y …`、`npm rm -g …`(项目内依赖 `npm rm …` 用项目 cwd)、`conda env remove -n … -y`;目录型(git clone / venv / 安装包)走删除。结果回显退出码与命令输出尾部,并写 `uninstall-log.jsonl`。
24
+ - **首次扫描进度**:面板在扫描期间每秒轮询 `/api/install-radar/progress`,显示阶段文字(扫描安装根目录 → 比对基线 → 读注册表 → 解析会话日志 → 解析条目 → 整理报告)与百分比进度条。
25
+ - **临时目录根可勾选**:`%LOCALAPPDATA%\Temp` 这类根行现在也能勾,语义是「清空里面的子项」——服务端会把根展开成它的一级子项逐个判定(仍受白名单、50 MB 门槛、danger 排除约束),不会整根删除。
26
+
14
27
 
15
28
  - **条目分卡**:展开一个会话后,每条安装记录都是一张独立小卡(交替底色 + 左侧 3px 色条 + 8px 间距),相邻两行不再糊成一片。
16
29
  - **「为什么装」**:每条记录多一行 `🎯 为什么装:…`。来源优先级:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-install-radar",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Per-session install radar for DeepSeek Harness: what each conversation installed (Python / npm / git / winget), with size, path, created time, purpose, why it was installed and what removing it costs — plus a sidebar panel and process→disk causal audit.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/client.js CHANGED
@@ -49,6 +49,13 @@ window.__ModuleLoader__.load({
49
49
  copy: '复制路径',
50
50
  copied: '已复制',
51
51
  uninstall: '卸载命令',
52
+ uninstallNow: '一键卸载',
53
+ confirmUninstall: '确认卸载',
54
+ doUninstall: '执行卸载',
55
+ gotIt: '知道了',
56
+ uninstallOk: '卸载成功',
57
+ uninstallFail: '卸载失败',
58
+ howToFix: '解决办法',
52
59
  openFolder: '打开文件夹',
53
60
  tabSessions: '按会话',
54
61
  tabEco: '按类型',
@@ -88,6 +95,8 @@ window.__ModuleLoader__.load({
88
95
  freed: '已释放',
89
96
  noTime: '未知时间',
90
97
  recentHead: '今天零点以来新增/写入的东西(不看基线,直接按时间戳扫)',
98
+ riskFilter: '按风险筛选',
99
+ scanningDetail: '首次扫描要遍历安装根目录、会话日志和注册表,通常 20-60 秒。',
91
100
  recentEmpty: '今天没有扫到新增文件。',
92
101
  error: '出错了',
93
102
  },
@@ -116,6 +125,13 @@ window.__ModuleLoader__.load({
116
125
  copy: 'Copy path',
117
126
  copied: 'Copied',
118
127
  uninstall: 'Uninstall',
128
+ uninstallNow: 'Uninstall now',
129
+ confirmUninstall: 'Confirm uninstall',
130
+ doUninstall: 'Run uninstall',
131
+ gotIt: 'Got it',
132
+ uninstallOk: 'Uninstalled',
133
+ uninstallFail: 'Uninstall failed',
134
+ howToFix: 'How to fix',
119
135
  openFolder: 'Open folder',
120
136
  tabSessions: 'Sessions',
121
137
  tabEco: 'By kind',
@@ -155,6 +171,8 @@ window.__ModuleLoader__.load({
155
171
  freed: 'freed',
156
172
  noTime: 'no date',
157
173
  recentHead: 'Written or created since midnight (timestamps, not baseline)',
174
+ riskFilter: 'Filter by risk',
175
+ scanningDetail: 'First scan walks install roots, session logs and the registry — usually 20-60s.',
158
176
  recentEmpty: 'Nothing new today.',
159
177
  error: 'Failed',
160
178
  },
@@ -257,6 +275,11 @@ window.__ModuleLoader__.load({
257
275
  .dsh-ir-itemCard--alt{border-left-color:rgba(148,163,184,.7);background:rgba(148,163,184,.085)}
258
276
  .dsh-ir-itemCard .dsh-ir-item{background:transparent;border-left:none;margin-left:0}
259
277
  .dsh-ir-why{padding:2px 12px 2px;font-size:12px;color:var(--dsw-alias-state-business-primary,#6ea8ff);opacity:.95}
278
+ .dsh-ir-prog{padding:18px 22px}
279
+ .dsh-ir-progHead{font-size:12px;color:var(--dsw-alias-label-secondary,#666);margin-bottom:8px;display:flex;gap:8px;align-items:center}
280
+ .dsh-ir-progBar{height:6px;border-radius:3px;background:var(--dsw-alias-bg-l2,rgba(0,0,0,.1));overflow:hidden}
281
+ .dsh-ir-progFill{height:100%;border-radius:3px;background:linear-gradient(90deg,#4d90f0,#6ea8ff);transition:width .4s ease}
282
+ .dsh-ir-progTip{font-size:11px;color:var(--dsw-alias-label-tertiary,#8a8a8a);margin-top:8px}
260
283
  .dsh-ir-risk{display:inline-flex;align-items:center;gap:5px;font-size:11px;margin-top:3px}
261
284
  .dsh-ir-risk::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor;flex:none}
262
285
  .dsh-ir-risk--safe{color:#2f9e5f}
@@ -412,6 +435,26 @@ window.__ModuleLoader__.load({
412
435
  if (item.confidence === 'none') tags.push(t('notFound'))
413
436
  if (item.confidence === 'low') tags.push(t('guessed'))
414
437
  const alt = typeof index === 'number' && index % 2 === 1
438
+ const [confirming, setConfirming] = useState(false)
439
+ const [running, setRunning] = useState(false)
440
+ const [result, setResult] = useState(null)
441
+
442
+ const runUninstall = () => {
443
+ if (!rpc) return
444
+ setRunning(true)
445
+ callRpc(rpc, 'uninstall', { eco: item.eco, name: item.name, path: item.path, confirm: true })
446
+ .then((value) => {
447
+ setResult(value)
448
+ setRunning(false)
449
+ setConfirming(false)
450
+ })
451
+ .catch((err) => {
452
+ setResult({ error: err && err.message ? err.message : String(err) })
453
+ setRunning(false)
454
+ setConfirming(false)
455
+ })
456
+ }
457
+
415
458
  return h(
416
459
  'div',
417
460
  { className: `dsh-ir-itemCard${alt ? ' dsh-ir-itemCard--alt' : ''}` },
@@ -449,7 +492,65 @@ window.__ModuleLoader__.load({
449
492
  )
450
493
  : null,
451
494
  item.uninstall ? h(CopyChip, { key: 'un', text: item.uninstall, label: t('uninstall'), title: item.uninstall }) : null,
495
+ item.eco || item.path
496
+ ? h(
497
+ 'button',
498
+ {
499
+ key: 'run',
500
+ type: 'button',
501
+ className: 'dsh-ir-chip dsh-ir-chip--danger',
502
+ disabled: running,
503
+ onClick: () => setConfirming(true),
504
+ title: item.uninstall || '',
505
+ },
506
+ running ? '…' : t('uninstallNow'),
507
+ )
508
+ : null,
452
509
  ]),
510
+ confirming
511
+ ? h(
512
+ 'div',
513
+ { className: 'dsh-ir-confirm' },
514
+ h('span', null, `🔴 ${t('confirmUninstall')} ${item.name}(${item.eco || 'path'})`),
515
+ item.uninstall ? h('code', { className: 'dsh-ir-path' }, item.uninstall) : null,
516
+ h('button', { type: 'button', className: 'dsh-ir-chip dsh-ir-chip--danger', disabled: running, onClick: runUninstall }, t('doUninstall')),
517
+ h('button', { type: 'button', className: 'dsh-ir-chip', disabled: running, onClick: () => setConfirming(false) }, t('cancelClean')),
518
+ )
519
+ : null,
520
+ result
521
+ ? h(
522
+ 'div',
523
+ {
524
+ className: 'dsh-ir-confirm',
525
+ style: {
526
+ background: result.ok === false ? 'rgba(221,51,51,.10)' : 'rgba(47,158,95,.12)',
527
+ borderColor: result.ok === false ? 'rgba(221,51,51,.35)' : 'rgba(47,158,95,.4)',
528
+ flexWrap: 'wrap',
529
+ },
530
+ },
531
+ h(
532
+ 'span',
533
+ null,
534
+ result.ok === false
535
+ ? `❌ ${t('uninstallFail')}${result.error ? `:${result.error}` : ''}`
536
+ : result.mode === 'delete'
537
+ ? `✅ ${t('uninstallOk')} · 释放 ${formatSize(result.freed || 0)}`
538
+ : `✅ ${t('uninstallOk')}(退出码 0)`,
539
+ ),
540
+ result.ok !== false && result.results && result.results[0] && result.results[0].tail
541
+ ? h('div', { className: 'dsh-ir-evidence', style: { width: '100%', padding: 0 } }, String(result.results[0].tail).slice(0, 300))
542
+ : null,
543
+ result.ok === false && result.hints && result.hints.length > 0
544
+ ? h(
545
+ 'div',
546
+ { style: { width: '100%' } },
547
+ h('div', { className: 'dsh-ir-eco' }, `🛠 ${t('howToFix')}:`),
548
+ ...result.hints.map((hint, index) => h('div', { key: `hint-${index}`, className: 'dsh-ir-eco' }, `${index + 1}. ${hint}`)),
549
+ )
550
+ : null,
551
+ h('button', { type: 'button', className: 'dsh-ir-chip', onClick: () => setResult(null) }, t('gotIt')),
552
+ )
553
+ : null,
453
554
  item.why && item.why.text
454
555
  ? h('div', { className: 'dsh-ir-why' }, `🎯 ${t('whyInstalled')}:${item.why.text}`)
455
556
  : null,
@@ -489,7 +590,8 @@ window.__ModuleLoader__.load({
489
590
 
490
591
  /** 「今天」的一行:一个归属目录(可勾选批量清理 / 单条两段式清理) */
491
592
  function RecentRow({ item, rpc, pending, cleanBusy, onAsk, onCancel, onConfirm, selected, onToggle }) {
492
- const checkable = item.cleanable !== false
593
+ const checkable = item.selectable !== false
594
+ const perRowClean = item.cleanable !== false
493
595
  return h(
494
596
  'div',
495
597
  { className: `dsh-ir-recent${selected ? ' dsh-ir-recent--picked' : ''}` },
@@ -507,7 +609,8 @@ window.__ModuleLoader__.load({
507
609
  onChange: () => onToggle && onToggle(item.path),
508
610
  title: item.path,
509
611
  })
510
- : h('span', { title: '不可一键删(风险高或小于 50 MB)' }, '🔴'),
612
+ : null,
613
+ h('span', { title: item.risk ? `风险:${item.risk}` : '' }, item.risk === 'danger' ? '🔴' : item.risk === 'safe' ? '🟢' : '🟡'),
511
614
  h('span', { className: 'dsh-ir-size' }, item.size),
512
615
  ),
513
616
  h(
@@ -522,7 +625,7 @@ window.__ModuleLoader__.load({
522
625
  { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
523
626
  h('button', { type: 'button', className: 'dsh-ir-chip', onClick: () => openTarget(rpc, item.path) }, t('openFolder')),
524
627
  h(CopyChip, { text: item.path, label: t('copy') }),
525
- onAsk && checkable
628
+ onAsk && perRowClean
526
629
  ? h(
527
630
  'button',
528
631
  { type: 'button', className: 'dsh-ir-chip dsh-ir-chip--danger', onClick: onAsk, disabled: cleanBusy },
@@ -805,6 +908,9 @@ window.__ModuleLoader__.load({
805
908
  const [cleanBusy, setCleanBusy] = useState(false)
806
909
  const [selected, setSelected] = useState([])
807
910
  const [batchPending, setBatchPending] = useState(false)
911
+ const [riskFilter, setRiskFilter] = useState('all')
912
+ const [progress, setProgress] = useState(null)
913
+ const [batchResult, setBatchResult] = useState(null)
808
914
  const [trend, setTrend] = useState(null)
809
915
  const [trendBusy, setTrendBusy] = useState(false)
810
916
  const [autoClean, setAutoClean] = useState(null)
@@ -848,6 +954,27 @@ window.__ModuleLoader__.load({
848
954
  }
849
955
  }, [load])
850
956
 
957
+ /** 扫描进度:报告还在跑时每秒问一次宿主 */
958
+ useEffect(() => {
959
+ if (!busy || !rpc) {
960
+ setProgress(null)
961
+ return undefined
962
+ }
963
+ let alive = true
964
+ const tick = () =>
965
+ callRpc(rpc, 'progress', {})
966
+ .then((value) => {
967
+ if (alive) setProgress(value)
968
+ })
969
+ .catch(() => {})
970
+ tick()
971
+ const timer = setInterval(tick, 1000)
972
+ return () => {
973
+ alive = false
974
+ clearInterval(timer)
975
+ }
976
+ }, [busy, rpc])
977
+
851
978
  const loadRecent = useCallback(
852
979
  (days) => {
853
980
  if (!rpc) return
@@ -985,6 +1112,13 @@ window.__ModuleLoader__.load({
985
1112
  setCleanBusy(false)
986
1113
  setBatchPending(false)
987
1114
  applyRemoved(value)
1115
+ const failed = (value && value.items ? value.items : []).filter((item) => item.ok === false)
1116
+ setBatchResult({
1117
+ freed: (value && value.freed) || 0,
1118
+ removed: (value && value.removed) || 0,
1119
+ rejected: failed.slice(0, 12),
1120
+ rejectedCount: failed.length,
1121
+ })
988
1122
  })
989
1123
  .catch((err) => {
990
1124
  setCleanBusy(false)
@@ -1074,7 +1208,14 @@ window.__ModuleLoader__.load({
1074
1208
  ]
1075
1209
 
1076
1210
  // 多选:只有「可一键清理」的行能勾(>=50 MB 且不是 danger)
1077
- const cleanableItems = ((recent && recent.items) || []).filter((item) => item.cleanable !== false)
1211
+ const allRecent = (recent && recent.items) || []
1212
+ const riskCount = {
1213
+ safe: allRecent.filter((item) => item.risk === 'safe').length,
1214
+ caution: allRecent.filter((item) => item.risk === 'caution').length,
1215
+ danger: allRecent.filter((item) => item.risk === 'danger').length,
1216
+ }
1217
+ const visibleItems = riskFilter === 'all' ? allRecent : allRecent.filter((item) => item.risk === riskFilter)
1218
+ const cleanableItems = ((recent && recent.items) || []).filter((item) => item.selectable !== false)
1078
1219
  const safeItems = cleanableItems.filter((item) => item.risk === 'safe')
1079
1220
  const selectedSet = new Set(selected)
1080
1221
  const selectedBytes = cleanableItems
@@ -1121,7 +1262,19 @@ window.__ModuleLoader__.load({
1121
1262
  data.generatedAt ? h('span', { style: { marginLeft: 'auto' } }, formatWhen(data.generatedAt)) : null,
1122
1263
  )
1123
1264
  : null,
1124
- busy && !data ? h('div', { className: 'dsh-ir-empty' }, `${t('scanning')} 首次扫描要遍历 Program Files 等目录,十几秒左右。`) : null,
1265
+ busy && !data
1266
+ ? h(
1267
+ 'div',
1268
+ { className: 'dsh-ir-prog' },
1269
+ h(
1270
+ 'div',
1271
+ { className: 'dsh-ir-progHead' },
1272
+ h('span', null, `${progress && progress.phase ? progress.phase : t('scanning')} · ${progress && progress.pct ? progress.pct : 0}%`),
1273
+ ),
1274
+ h('div', { className: 'dsh-ir-progBar' }, h('div', { className: 'dsh-ir-progFill', style: { width: `${(progress && progress.pct) || 2}%` } })),
1275
+ h('div', { className: 'dsh-ir-progTip' }, t('scanningDetail')),
1276
+ )
1277
+ : null,
1125
1278
  error ? h('div', { className: 'dsh-ir-empty' }, `${t('error')}: ${error}`) : null,
1126
1279
  h(
1127
1280
  'div',
@@ -1198,6 +1351,31 @@ window.__ModuleLoader__.load({
1198
1351
  ? h('div', { key: 'head', className: 'dsh-ir-recentHead' }, `${t('recentHead')} · 起点 ${recent.sinceLabel} · 合计 ${formatSize(recent.totalBytes ?? 0)}`)
1199
1352
  : null,
1200
1353
  recent && recent.items.length === 0 ? h('div', { key: 'empty2', className: 'dsh-ir-empty' }, t('recentEmpty')) : null,
1354
+ batchResult
1355
+ ? h(
1356
+ 'div',
1357
+ {
1358
+ key: 'batchResult',
1359
+ className: 'dsh-ir-confirm',
1360
+ style: {
1361
+ background: batchResult.rejectedCount > 0 ? 'rgba(221,51,51,.10)' : 'rgba(47,158,95,.12)',
1362
+ borderColor: batchResult.rejectedCount > 0 ? 'rgba(221,51,51,.35)' : 'rgba(47,158,95,.4)',
1363
+ flexWrap: 'wrap',
1364
+ },
1365
+ },
1366
+ h(
1367
+ 'span',
1368
+ null,
1369
+ batchResult.rejectedCount > 0
1370
+ ? `⚠️ 删除 ${batchResult.removed} 项 · 释放 ${formatSize(batchResult.freed)} · 被拒 ${batchResult.rejectedCount} 项`
1371
+ : `✅ 删除 ${batchResult.removed} 项 · 释放 ${formatSize(batchResult.freed)}`,
1372
+ ),
1373
+ ...batchResult.rejected.map((item, index) =>
1374
+ h('div', { key: `rj-${index}`, className: 'dsh-ir-eco', style: { width: '100%' } }, `${item.path} — ${item.reason || '被拒绝'}`),
1375
+ ),
1376
+ h('button', { key: 'close', type: 'button', className: 'dsh-ir-chip', onClick: () => setBatchResult(null) }, t('gotIt')),
1377
+ )
1378
+ : null,
1201
1379
  recent && recent.items.length > 0
1202
1380
  ? h(SelBar, {
1203
1381
  key: 'selbar',
@@ -1216,8 +1394,36 @@ window.__ModuleLoader__.load({
1216
1394
  onCancel: () => setBatchPending(false),
1217
1395
  })
1218
1396
  : null,
1397
+ recent && recent.items.length > 0
1398
+ ? h(
1399
+ 'div',
1400
+ { key: 'riskbar', className: 'dsh-ir-selbarWrap', style: { position: 'static', background: 'transparent', boxShadow: 'none', borderBottom: 'none' } },
1401
+ h('span', { className: 'dsh-ir-eco' }, t('riskFilter')),
1402
+ ...[
1403
+ ['safe', '🟢'],
1404
+ ['caution', '🟡'],
1405
+ ['danger', '🔴'],
1406
+ ].map(([level, emoji]) =>
1407
+ h(
1408
+ 'button',
1409
+ {
1410
+ key: level,
1411
+ type: 'button',
1412
+ className: `dsh-ir-subtab${riskFilter === level ? ' dsh-ir-subtab--on' : ''}`,
1413
+ onClick: () => setRiskFilter((prev) => (prev === level ? 'all' : level)),
1414
+ },
1415
+ `${emoji} ${riskCount[level]}`,
1416
+ ),
1417
+ ),
1418
+ riskFilter !== 'all'
1419
+ ? h('button', { key: 'all', type: 'button', className: 'dsh-ir-chip', onClick: () => setRiskFilter('all') }, t('clearSel'))
1420
+ : null,
1421
+ h('span', { key: 'sp', className: 'dsh-ir-selbarSpacer' }),
1422
+ h('span', { className: 'dsh-ir-eco' }, `${visibleItems.length} / ${allRecent.length}`),
1423
+ )
1424
+ : null,
1219
1425
  recent
1220
- ? recent.items.map((item, index) =>
1426
+ ? visibleItems.map((item, index) =>
1221
1427
  h(RecentRow, {
1222
1428
  key: `${item.path}-${index}`,
1223
1429
  item,
@@ -58,7 +58,19 @@ export async function cleanTargets(paths, opts = {}) {
58
58
  const list = Array.isArray(paths) ? paths : []
59
59
  const items = []
60
60
  for (const raw of list) {
61
- const expanded = await expand(raw)
61
+ let expanded = await expand(raw)
62
+ // 临时目录根本身不允许整根删:自动展开成里面的子项,逐个判定
63
+ if (expanded.length === 1 && !checkAllowed(expanded[0]).ok) {
64
+ const root = resolve(expanded[0])
65
+ if (root.toLowerCase() === String(expanded[0]).toLowerCase() && allowedRoots().includes(root.toLowerCase())) {
66
+ try {
67
+ const children = await readdir(root, { withFileTypes: true })
68
+ expanded = children.map((child) => join(root, child.name))
69
+ } catch {
70
+ expanded = []
71
+ }
72
+ }
73
+ }
62
74
  if (expanded.length === 0) {
63
75
  items.push({ path: raw, ok: false, reason: '没有匹配到目录', bytes: 0 })
64
76
  continue
@@ -10,6 +10,17 @@ import { execFile } from 'node:child_process'
10
10
  import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
11
11
  import { join } from 'node:path'
12
12
 
13
+ /** 把命令行里的密钥类片段打码,避免审计日志变成泄密文件 */
14
+ export function redact(text) {
15
+ if (typeof text !== 'string' || text.length === 0) return ''
16
+ return text
17
+ .replace(/\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{8,}\b/g, '$1_[REDACTED]')
18
+ .replace(/\bnpm_[A-Za-z0-9]{8,}\b/g, 'npm_[REDACTED]')
19
+ .replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{8,}\b/g, '$1-[REDACTED]')
20
+ .replace(/(_authToken=|_auth=|--otp=|token=)([^\s"'&]+)/gi, '$1[REDACTED]')
21
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/g, '$1 [REDACTED]')
22
+ }
23
+
13
24
  /** 类别 → 命令行特征 */
14
25
  const PATTERNS = [
15
26
  { key: 'cdp', re: /--remote-debugging-(port|pipe)/i, label: 'CDP 调试端口' },
@@ -106,7 +117,7 @@ export async function findWatchedProcesses() {
106
117
  const chain = []
107
118
  let cursor = byPid.get(proc.ppid)
108
119
  for (let depth = 0; cursor && depth < 4; depth++) {
109
- chain.push({ pid: cursor.pid, name: cursor.name, command: cursor.command.slice(0, 400) })
120
+ chain.push({ pid: cursor.pid, name: cursor.name, command: redact(cursor.command).slice(0, 400) })
110
121
  cursor = byPid.get(cursor.ppid)
111
122
  }
112
123
  const dataDir = /--user-data-dir=("[^"]+"|\S+)/i.exec(proc.command)
@@ -114,7 +125,7 @@ export async function findWatchedProcesses() {
114
125
  hits.push({
115
126
  pid: proc.pid,
116
127
  name: proc.name,
117
- command: proc.command.slice(0, 600),
128
+ command: redact(proc.command).slice(0, 600),
118
129
  categories,
119
130
  userDataDir: dataDir ? dataDir[1].replace(/^["']|["']*$/g, '') : '',
120
131
  port: port ? Number(port[1]) : null,
@@ -164,7 +175,7 @@ export async function sampleWatchedProcesses({ home }) {
164
175
  label: category.label,
165
176
  pid: hit.pid,
166
177
  name: hit.name,
167
- command: hit.command,
178
+ command: redact(hit.command),
168
179
  paths: hit.paths,
169
180
  userDataDir: hit.userDataDir,
170
181
  startedAt: hit.startedAt,
@@ -231,7 +242,7 @@ export async function readProcWatch({ home, limit = 200 }) {
231
242
  bucket.lastAt = Math.max(bucket.lastAt, record.at || 0)
232
243
  for (const path of item.paths || []) bucket.paths.add(path)
233
244
  if (item.userDataDir) bucket.paths.add(item.userDataDir)
234
- if (bucket.samples.length < 2) bucket.samples.push(item.command.slice(0, 160))
245
+ if (bucket.samples.length < 2) bucket.samples.push(redact(item.command).slice(0, 160))
235
246
  rows.set(key, bucket)
236
247
 
237
248
  const cat = byCategory.get(item.category) || { category: item.category, label: item.label, count: 0 }
@@ -148,13 +148,18 @@ export function diffSnapshot(baseline, snapshot) {
148
148
  */
149
149
  export async function buildReport(opts = {}) {
150
150
  const home = opts.home || dshHome()
151
+ const progress = typeof opts.onProgress === 'function' ? opts.onProgress : () => {}
152
+ progress('扫描安装根目录', 5)
151
153
  await initSizeCache(home)
152
154
  const snapshot = await currentSnapshot({ ...opts, home })
155
+ progress('比对基线', 40)
153
156
  const ensured = await ensureBaseline({ ...opts, home, snapshot })
154
157
  const baseline = ensured.baseline
155
158
  const delta = diffSnapshot(baseline, snapshot)
159
+ progress('读取已安装程序注册表', 45)
156
160
  const env = await probeEnvironment()
157
161
  const programs = await loadInstalledPrograms()
162
+ progress('解析会话日志', 55)
158
163
 
159
164
  let sessions = []
160
165
  let scannedSessions = 0
@@ -163,6 +168,7 @@ export async function buildReport(opts = {}) {
163
168
  sessions = collected.sessions
164
169
  scannedSessions = collected.scannedSessions
165
170
  }
171
+ progress(`解析安装条目(${sessions.length} 个会话)`, 65)
166
172
 
167
173
  let audit = null
168
174
  try {
@@ -227,6 +233,7 @@ export async function buildReport(opts = {}) {
227
233
  })
228
234
  }
229
235
  perSession.sort((a, b) => (b.lastActive ?? 0) - (a.lastActive ?? 0))
236
+ progress('整理报告', 95)
230
237
 
231
238
  const unattributed = delta.newEntries.slice(0, 60).map((entry) => {
232
239
  const info = classify(entry.name, entry.path)
@@ -303,7 +310,11 @@ export async function buildRecent(opts = {}) {
303
310
  const risk = assessRisk(item.path, { bytes: item.bytes })
304
311
  item.risk = risk.level
305
312
  item.consequence = risk.consequence
306
- item.cleanable = item.bytes >= CLEAN_MIN_BYTES && risk.level !== 'danger' && risk.isRoot !== true
313
+ // 临时目录根本身也可以勾:服务端会把它展开成里面的子项逐个处理
314
+ item.isRoot = risk.isRoot === true
315
+ // 勾选不设门槛:所有行都能多选(danger 也能勾,提交后服务端会逐条拒绝并给原因)
316
+ item.selectable = true
317
+ item.cleanable = item.bytes >= CLEAN_MIN_BYTES && risk.level !== 'danger'
307
318
  }
308
319
  raw.sinceLabel = new Date(raw.since).toLocaleString('sv-SE')
309
320
  raw.totalSize = formatBytes(raw.totalBytes)
package/src/index.js CHANGED
@@ -49,7 +49,7 @@ export const name = 'install-radar'
49
49
  export const inject = ['tools']
50
50
 
51
51
  const ROUTE_PREFIX = '/api/install-radar/'
52
- const METHODS = ['report', 'sessions', 'baseline', 'diff', 'refresh', 'recent', 'open', 'procs', 'clean', 'history', 'repro', 'trend', 'autoclean']
52
+ const METHODS = ['report', 'sessions', 'baseline', 'diff', 'refresh', 'recent', 'open', 'procs', 'clean', 'history', 'repro', 'trend', 'autoclean', 'uninstall', 'progress']
53
53
 
54
54
  const OUTPUT = {
55
55
  type: 'object',
@@ -141,6 +141,160 @@ function renderSessionTable(report) {
141
141
  export function apply(ctx, config = {}) {
142
142
  const base = sessionConfig(config)
143
143
 
144
+ /** 首次扫描进度:report 路由在跑的时候,面板轮询这里画进度条 */
145
+ let progressState = null
146
+
147
+ /** 有些工具「没装」也返回 0(pip / winget 都有这种),必须看输出才能判失败 */
148
+ function saysNotInstalled(text) {
149
+ return /not installed|no installed package|找不到与输入条件匹配|未找到已安装|is not installed/i.test(String(text || ''))
150
+ }
151
+
152
+ /** 卸载失败时给「怎么办」,按工具 + 报错内容挑 */
153
+ function hintsFor(step, outcome) {
154
+ const tail = `${outcome.err || ''}\n${outcome.out || ''}`.toLowerCase()
155
+ if (outcome.code === -1 || /enoent|不是内部或外部命令|not recognized/.test(tail)) {
156
+ return [`没找到 ${step.cmd} 命令:确认它已安装并在 PATH 里`, '或者复制「卸载命令」到终端里手动执行']
157
+ }
158
+ if (/elevat|admin|access is denied|拒绝访问|0x80070005/.test(tail)) {
159
+ return ['需要管理员权限:用「以管理员身份运行」的终端重试这条命令']
160
+ }
161
+ if (step.cmd === 'winget') {
162
+ return [
163
+ 'winget 卸载最常见的原因是程序还在运行——先完全退出(含托盘图标)再试',
164
+ '包 id 可能不准确:先跑 winget list 查确切 id',
165
+ '源没更新:winget source update 后再试',
166
+ '仍失败就把「卸载命令」复制到管理员终端手动执行,看完整报错',
167
+ ]
168
+ }
169
+ if (step.cmd === 'npm') {
170
+ return ['全局卸载偶尔因权限失败:用管理员终端重试', '确认用的是 npm 包名(不是 import 名)', '可先跑 npm ls -g --depth=0 核对']
171
+ }
172
+ if (step.cmd === 'python') {
173
+ return ['确认这个包装在当前 python 下:python -m pip show <包名>', '多个 Python 并存时指定解释器:py -3.14 -m pip uninstall <包名>', '权限问题可试 --user']
174
+ }
175
+ if (step.cmd === 'conda') {
176
+ return ['确认环境名:conda env list', '环境正在被使用时无法删除,先退出该环境里的程序']
177
+ }
178
+ return ['查看下方原始输出定位原因', '或复制命令到终端手动执行']
179
+ }
180
+
181
+ /** 删除型卸载失败的提示 */
182
+ function deleteHints(items) {
183
+ if (!items.some((item) => item.ok === false)) return []
184
+ return [
185
+ '文件被占用:先退出正在使用它的程序(浏览器 / 编辑器 / 正在跑的脚本)',
186
+ '权限不足:用管理员终端重试',
187
+ '路径可能变了:点「重扫」刷新后再看',
188
+ ]
189
+ }
190
+
191
+ /**
192
+ * 一键卸载:按生态执行真正的卸载命令(面板里两步确认后才调到这里)。
193
+ * 只接受报告里出现过的条目,避免被当成任意命令执行器。
194
+ */
195
+ async function runUninstall(payload) {
196
+ const eco = String(payload?.eco || '')
197
+ const name = String(payload?.name || '')
198
+ const target = String(payload?.path || '')
199
+ const confirmed = payload?.confirm === true
200
+ if (!confirmed) return { confirm: false, eco, name, target }
201
+ if (!name && !target) throw new Error('缺少 name / path')
202
+
203
+ const plan = []
204
+ if (eco === 'winget') plan.push({ cmd: 'winget', args: ['uninstall', '--id', name, '-e', '--silent', '--accept-source-agreements'] })
205
+ else if (eco === 'choco') plan.push({ cmd: 'choco', args: ['uninstall', name, '-y'] })
206
+ else if (eco === 'python') plan.push({ cmd: 'python', args: ['-m', 'pip', 'uninstall', '-y', name] })
207
+ else if (eco === 'node') {
208
+ if (/[\\/]node_modules[\\/]/i.test(target)) {
209
+ const project = target.split(/[\\/]node_modules[\\/]/i)[0]
210
+ plan.push({ cmd: 'npm', args: ['rm', name], cwd: project })
211
+ } else plan.push({ cmd: 'npm', args: ['rm', '-g', name] })
212
+ } else if (eco === 'conda') plan.push({ cmd: 'conda', args: ['env', 'remove', '-n', name, '-y'] })
213
+
214
+ // 目录型(git clone / venv / 安装包 / 其它):走删除
215
+ if (plan.length === 0) {
216
+ if (!target || !existsSync(target)) {
217
+ return {
218
+ confirm: true,
219
+ mode: 'delete',
220
+ ok: false,
221
+ error: `路径不存在:${target || '(空)'}`,
222
+ hints: ['这个东西可能已经被手动删掉了', '点「重扫」刷新后再看当前状态'],
223
+ freed: 0,
224
+ removed: 0,
225
+ items: [],
226
+ }
227
+ }
228
+ const result = await cleanTargets([target], { confirm: true, home: base.home, note: 'uninstall' })
229
+ const failed = result.items.filter((item) => item.ok === false)
230
+ return {
231
+ confirm: true,
232
+ mode: 'delete',
233
+ ok: failed.length === 0,
234
+ freed: result.freed,
235
+ removed: result.removed,
236
+ items: result.items,
237
+ hints: deleteHints(result.items),
238
+ error: failed.length > 0 ? failed.map((item) => `${item.path}:${item.reason}`).join(';') : '',
239
+ }
240
+ }
241
+
242
+ const results = []
243
+ for (const step of plan) {
244
+ const started = Date.now()
245
+ const outcome = await new Promise((done) => {
246
+ let out = ''
247
+ let err = ''
248
+ const child = spawn(step.cmd, step.args, { cwd: step.cwd, windowsHide: true, shell: process.platform === 'win32' })
249
+ child.stdout?.on('data', (chunk) => (out += chunk))
250
+ child.stderr?.on('data', (chunk) => (err += chunk))
251
+ child.on('error', (error) => done({ ok: false, code: -1, out, err: String(error.message) }))
252
+ child.on('close', (code) => done({ ok: code === 0, code, out, err }))
253
+ })
254
+ const tail = (outcome.err || outcome.out || '').slice(-400)
255
+ const notInstalled = saysNotInstalled(tail)
256
+ const ok = outcome.ok && !notInstalled
257
+ results.push({
258
+ ...step,
259
+ ...outcome,
260
+ ok,
261
+ notInstalled,
262
+ ms: Date.now() - started,
263
+ tail,
264
+ hints: ok
265
+ ? []
266
+ : notInstalled
267
+ ? [
268
+ `这条在 ${step.cmd} 里没找到,说明它可能装在别的解释器/别的用户下`,
269
+ step.cmd === 'python' ? '先确认解释器:py -0p,再用对应版本重试 python -m pip uninstall <包名>' : '先跑一次 list 命令(winget list / npm ls -g)核对确切名字',
270
+ '也可能已经被卸掉了——点「重扫」看当前状态',
271
+ ]
272
+ : hintsFor(step, outcome),
273
+ })
274
+ }
275
+
276
+ const ok = results.every((row) => row.ok)
277
+ try {
278
+ const dir = stateDir(base.home)
279
+ await mkdir(dir, { recursive: true })
280
+ await writeFile(
281
+ join(dir, 'uninstall-log.jsonl'),
282
+ `${JSON.stringify({ at: Date.now(), eco, name, target, ok, results: results.map((r) => ({ cmd: r.cmd, code: r.code })) })}\n`,
283
+ { encoding: 'utf8', flag: 'a' },
284
+ )
285
+ } catch {
286
+ /* 日志写失败不影响结果 */
287
+ }
288
+ return {
289
+ confirm: true,
290
+ mode: 'command',
291
+ ok,
292
+ results,
293
+ hints: results.flatMap((row) => row.hints || []),
294
+ error: ok ? '' : results.filter((row) => !row.ok).map((row) => `${row.cmd} 退出码 ${row.code}`).join(';'),
295
+ }
296
+ }
297
+
144
298
  /**
145
299
  * 复现线索要挑「哪一轮」:优先用历史侧写的第一名;
146
300
  * 如果临时目录已经被清掉了,就退回到「浏览器命令最多的那一轮」。
@@ -485,7 +639,20 @@ export function apply(ctx, config = {}) {
485
639
  async function reportPayload(force) {
486
640
  const ttl = 45_000
487
641
  if (!force && cached.value && Date.now() - cached.at < ttl) return cached.value
488
- const report = await buildReport({ ...base, includeSessions: true, force })
642
+ progressState = { phase: '开始扫描', pct: 2, startedAt: Date.now() }
643
+ let report
644
+ try {
645
+ report = await buildReport({
646
+ ...base,
647
+ includeSessions: true,
648
+ force,
649
+ onProgress: (phase, pct) => {
650
+ progressState = { phase, pct, startedAt: progressState?.startedAt || Date.now() }
651
+ },
652
+ })
653
+ } finally {
654
+ progressState = null
655
+ }
489
656
  const value = panelPayload(report)
490
657
  const config = await readConfig(base.home)
491
658
  value.autoClean = { enabled: false, minAgeDays: 7, ...(config.autoClean || {}) }
@@ -603,6 +770,12 @@ export function apply(ctx, config = {}) {
603
770
  }
604
771
  return { autoClean: settings, result }
605
772
  },
773
+ async uninstall(body) {
774
+ return runUninstall(body?.payload || {})
775
+ },
776
+ async progress() {
777
+ return progressState || { phase: '', pct: 0, idle: true }
778
+ },
606
779
  async repro(body) {
607
780
  const payload = body?.payload || {}
608
781
  const picked = await pickSessionForRepro(payload.sessionId)