dsh-disk-cleaner 1.6.5 → 1.6.6
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 +19 -17
- package/lib/catalog.js +41 -21
- package/lib/client.js +223 -12
- package/lib/history.js +71 -0
- package/lib/index.js +64 -16
- package/package.json +2 -1
- package/smoke.mjs +102 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<!-- badges -->
|
|
6
6
|
| | |
|
|
7
7
|
| --- | --- |
|
|
8
|
-
| **version** | `1.6.
|
|
8
|
+
| **version** | `1.6.6` |
|
|
9
9
|
| **DSH** | `0.2.1-rc.5`(开发与实测版本;用到 `plugins.bundle.config`、`settings.section`) |
|
|
10
10
|
| **license** | [MIT](LICENSE) |
|
|
11
11
|
| **Node** | `>= 22`(仅运行冒烟测试需要) |
|
|
@@ -166,16 +166,16 @@ These are enforced in `lib/catalog.js` and must not be relaxed:
|
|
|
166
166
|
|
|
167
167
|
Listed in the curated [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) catalog, which powers **dshmarket → Discover** ([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970), merged): search **dsh-disk-cleaner** there and install it in one click. The direct installs below do the same thing by hand.
|
|
168
168
|
|
|
169
|
-
Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner) — latest `1.6.
|
|
169
|
+
Published on npm as [`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner) — latest `1.6.6`:
|
|
170
170
|
|
|
171
171
|
```bash
|
|
172
172
|
npm i dsh-disk-cleaner
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
-
A prebuilt tarball is attached to the [v1.6.
|
|
175
|
+
A prebuilt tarball is attached to the [v1.6.6 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.6); installing from it skips DSH's build-approval step:
|
|
176
176
|
|
|
177
177
|
```bash
|
|
178
|
-
dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.
|
|
178
|
+
dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.6/dsh-disk-cleaner-1.6.6.tgz
|
|
179
179
|
```
|
|
180
180
|
|
|
181
181
|
Then enable it in the profile — in `$DSH_PROFILE_DIR/package.json`:
|
|
@@ -183,7 +183,7 @@ Then enable it in the profile — in `$DSH_PROFILE_DIR/package.json`:
|
|
|
183
183
|
```json
|
|
184
184
|
{
|
|
185
185
|
"dependencies": {
|
|
186
|
-
"dsh-disk-cleaner": "^1.6.
|
|
186
|
+
"dsh-disk-cleaner": "^1.6.6"
|
|
187
187
|
},
|
|
188
188
|
"dsh": {
|
|
189
189
|
"profile": {
|
|
@@ -204,7 +204,7 @@ The bundle's own `cordis.patch.yml` inserts the plugin row; no profile patch edi
|
|
|
204
204
|
### Development
|
|
205
205
|
|
|
206
206
|
```bash
|
|
207
|
-
node smoke.mjs #
|
|
207
|
+
node smoke.mjs # 110 assertions, zero dependencies
|
|
208
208
|
node --check lib/client.js # and lib/index.js, lib/catalog.js, lib/audit.js, lib/plan.js
|
|
209
209
|
```
|
|
210
210
|
|
|
@@ -258,22 +258,23 @@ What the smoke test deliberately does **not** cover: anything that walks a real
|
|
|
258
258
|
```
|
|
259
259
|
├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
|
|
260
260
|
├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
|
|
261
|
-
├─ lib/index.js (
|
|
261
|
+
├─ lib/index.js (482) Host: HTTP route + disk_cleaner tool, same run() for both; reveal
|
|
262
262
|
├─ lib/catalog.js (1222) The 32 targets, measurePath / purgePath / resolveTargets / drives
|
|
263
263
|
├─ lib/audit.js (365) Read-only assessment, 10-minute cache, areaPaths, exposes no deletion
|
|
264
264
|
├─ lib/plan.js (150) Pure: measurements → ordered steps
|
|
265
|
-
├─ lib/client.js (
|
|
265
|
+
├─ lib/client.js (1808) Client half: slots, panel, preferences, zh/en dictionaries, CSS
|
|
266
266
|
├─ locale/{en,zh}.json Display metadata (meta.title / meta.description)
|
|
267
267
|
├─ icon.svg Plugin icon
|
|
268
268
|
├─ README.md This file
|
|
269
269
|
├─ LICENSE MIT
|
|
270
|
-
└─ smoke.mjs
|
|
270
|
+
└─ smoke.mjs 110 assertions, zero dependencies
|
|
271
271
|
```
|
|
272
272
|
|
|
273
273
|
Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode), `main` (keyed by the package name), `settings.section` (order 30, disposed in sidebar-only mode), `plugins.bundle.config` (keyed by the package name).
|
|
274
274
|
|
|
275
275
|
### What's new
|
|
276
276
|
|
|
277
|
+
- **1.6.6** — The results the plugin already computed are now shown. A **Dry run** button next to *Clean* asks the exact same question without `confirm`, so you see what a delete would free before anything goes; the result banner then survives the rescan that follows it (it used to be erased a line later). Plan steps name targets in words instead of printing `npm-cache, chrome-cache, pip-cache`, an empty plan says "nothing worth doing" instead of rendering a hole, and route errors arrive as a stable `code` the panel translates, so a Chinese user never reads `origin rejected` or `unknown target: x`. Every real clean now appends one line to `$DSH_HOME/dsh-disk-cleaner/history.jsonl`, and a **Clean history** panel reads it back with a *Copy as CSV* button. Under the hood: scan walks four targets at a time instead of all 32 at once, `SystemRoot` decides where the Windows targets live instead of a hardcoded `C:`, a request without an `Origin` header is refused, and a refused directory removal is counted separately from a legitimately full one.
|
|
277
278
|
- **1.6.5** — Documentation only: the install section now points at the Release it names (the prebuilt-tarball link is labelled v1.6.5 and resolves to the v1.6.5 tag), and the catalog paragraph says the awesome-dsh-plugin entry is merged rather than pending review. No code change.
|
|
278
279
|
- **1.6.4** — The panel now paints itself with [dsh-design-baseline](https://github.com/stallenlu/dsh-design-baseline): the stylesheet is inlined into the client bundle, the panel root carries `dsb-root`, and every colour resolves through a `--dsb-*` token instead of a host `--dsw-alias-*` name. That means it follows the host's light and dark themes (including `color-scheme`, so native `<select>` menus and scrollbars match), and colour is spent only on data that has state — the primary button and the active chip are now inverted neutrals rather than brand blue. No behaviour changes.
|
|
279
280
|
- **1.6.3** — Safety and honesty rather than new features. The Recycle Bin is no longer filed under `safe`: emptying it cannot be undone, so it is never preselected, never picked by "select all", and carries its own `irreversible` note instead of the generic "rebuilt; next start is slower". The confirm dialog now names each target and the total instead of asking a generic "delete these?". A target that names no environment root is bounded by the volume it sits on rather than skipping the boundary check. `measure` and `purge` now count entries in the same unit, and a walk that hits the 2,000,000-entry cap says so (`truncated`) instead of claiming exact totals. A directory removal refused for a reason other than "not empty" is counted as `blocked` instead of being swallowed, and a clean that left files behind reports a warning rather than a green success. The orphan target is named for what it actually measures (the QQGuild folder).
|
|
@@ -437,16 +438,16 @@ Client-half slots: `sidebar.panellist` (order 50, disposed in settings-only mode
|
|
|
437
438
|
|
|
438
439
|
已收录进精选目录 [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin),它是 **dshmarket → 发现** 的数据源([PR #12970](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin/pull/12970),已合并):在 dshmarket 里搜索 **dsh-disk-cleaner** 即可一键安装。下面的直接安装方式就是手动做同一件事。
|
|
439
440
|
|
|
440
|
-
已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.
|
|
441
|
+
已发布到 npm:[`dsh-disk-cleaner`](https://www.npmjs.com/package/dsh-disk-cleaner),最新版 `1.6.6`。
|
|
441
442
|
|
|
442
443
|
```bash
|
|
443
444
|
npm i dsh-disk-cleaner
|
|
444
445
|
```
|
|
445
446
|
|
|
446
|
-
[v1.6.
|
|
447
|
+
[v1.6.6 Release](https://github.com/stallenlu/dsh-disk-cleaner/releases/tag/v1.6.6) 还附带一个预构建 tarball,从它安装可跳过 DSH 的构建授权步骤:
|
|
447
448
|
|
|
448
449
|
```bash
|
|
449
|
-
dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.
|
|
450
|
+
dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner/releases/download/v1.6.6/dsh-disk-cleaner-1.6.6.tgz
|
|
450
451
|
```
|
|
451
452
|
|
|
452
453
|
然后在 profile 里启用 —— 在 `$DSH_PROFILE_DIR/package.json`:
|
|
@@ -454,7 +455,7 @@ dsh plugin --profile <profile> add https://github.com/stallenlu/dsh-disk-cleaner
|
|
|
454
455
|
```json
|
|
455
456
|
{
|
|
456
457
|
"dependencies": {
|
|
457
|
-
"dsh-disk-cleaner": "^1.6.
|
|
458
|
+
"dsh-disk-cleaner": "^1.6.6"
|
|
458
459
|
},
|
|
459
460
|
"dsh": {
|
|
460
461
|
"profile": {
|
|
@@ -475,7 +476,7 @@ bundle 自带的 `cordis.patch.yml` 会插入插件行,无需改 profile 的 p
|
|
|
475
476
|
### 开发
|
|
476
477
|
|
|
477
478
|
```bash
|
|
478
|
-
node smoke.mjs #
|
|
479
|
+
node smoke.mjs # 110 条断言,零依赖
|
|
479
480
|
node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、lib/audit.js、lib/plan.js
|
|
480
481
|
```
|
|
481
482
|
|
|
@@ -529,22 +530,23 @@ node --check lib/client.js # 以及 lib/index.js、lib/catalog.js、li
|
|
|
529
530
|
```
|
|
530
531
|
├─ package.json dsh.bundle.patch + dsh.client { platform: "web" }
|
|
531
532
|
├─ cordis.patch.yml - insert: id: dsh-disk-cleaner
|
|
532
|
-
├─ lib/index.js (
|
|
533
|
+
├─ lib/index.js (482) Host:HTTP 路由 + disk_cleaner 工具,共用同一个 run(),含 reveal
|
|
533
534
|
├─ lib/catalog.js (1222) 32 个目标,measurePath / purgePath / resolveTargets / drives
|
|
534
535
|
├─ lib/audit.js (365) 只读评估,10 分钟缓存,带 areaPaths,不暴露任何删除
|
|
535
536
|
├─ lib/plan.js (150) 纯函数:测量结果 → 有序步骤
|
|
536
|
-
├─ lib/client.js (
|
|
537
|
+
├─ lib/client.js (1808) Client 半面:slot、面板、偏好、zh/en 字典、CSS
|
|
537
538
|
├─ locale/{en,zh}.json 展示元数据(meta.title / meta.description)
|
|
538
539
|
├─ icon.svg 插件图标
|
|
539
540
|
├─ README.md 本文件
|
|
540
541
|
├─ LICENSE MIT
|
|
541
|
-
└─ smoke.mjs
|
|
542
|
+
└─ smoke.mjs 110 条断言,零依赖
|
|
542
543
|
```
|
|
543
544
|
|
|
544
545
|
Client 半面注册的 slot:`sidebar.panellist`(order 50,仅设置页模式下拆除)、`main`(以包名为 key)、`settings.section`(order 30,仅侧边栏模式下拆除)、`plugins.bundle.config`(以包名为 key)。
|
|
545
546
|
|
|
546
547
|
### 更新记录
|
|
547
548
|
|
|
549
|
+
- **1.6.6** — 把插件本来就算出来的结果真正显示出来。*清理* 旁边多了 **预演** 按钮:发同一个请求但不带 `confirm`,删之前就能看到会释放多少;清理结果横幅不再被紧随其后的重新扫描抹掉(以前下一行就被清空)。计划步骤改用目标名称,不再直出 `npm-cache, chrome-cache, pip-cache`;没有可做的事时说明「现在没有值得动手的地方」,而不是留一片空白;接口错误改带稳定 `code`,由面板翻成中文,不会再出现 `origin rejected` 或 `unknown target: x`。每次真正清理都会往 `$DSH_HOME/dsh-disk-cleaner/history.jsonl` 追加一行,新增的 **清理记录** 面板可读回并按 *复制为 CSV* 导出。底层:扫描从「32 个目标同时跑」改为每次 4 个,Windows 目标路径改由 `SystemRoot` 决定而不再硬编码 `C:`,缺少 `Origin` 头的请求会被拒绝,目录删除被拒与非空目录被跳过分别计数。
|
|
548
550
|
- **1.6.5** — 仅文档修正:安装段现在指向它所写的那个 Release(预构建 tarball 的链接标为 v1.6.5,且指向 v1.6.5 的 tag);目录段落也从「审核中」改为「已合并」。代码无变化。
|
|
549
551
|
- **1.6.4** — 面板改用 [dsh-design-baseline](https://github.com/stallenlu/dsh-design-baseline) 上色:样式表内联进 client bundle,根节点带 `dsb-root`,所有颜色都走 `--dsb-*` 令牌而不是宿主的 `--dsw-alias-*` 名字。因此它跟随宿主的明暗主题(含 `color-scheme`,原生 `<select>` 弹层与滚动条也跟着变),并且颜色只花在有状态的数据上——主按钮与选中项改成中性反色,不再是品牌蓝。行为没有变化。
|
|
550
552
|
- **1.6.3** — 改的是安全与诚实,不是新功能。回收站不再归入「安全」:清空不可恢复,所以它永不被预选、不被「全选」勾中,并单独带 `irreversible` 注意码,而不是笼统的「重建,下次启动慢一些」。确认框现在逐条列出目标名与合计字节,不再只问一句「删除选中缓存目录里的文件?」。没有声明根目录的目标,改为按所在卷做边界校验,而不是直接跳过检查。`measure` 与 `purge` 现在用同一单位计数,走查触到 200 万条上限时会如实标 `truncated`,不再声称精确总量。目录删除失败若原因不是「非空」会计入 `blocked`(此前被静默吞掉),清理后仍有残留的目标会报警告而不是绿色成功。orphan 目标按它实际测量的东西重新命名(QQ 频道目录)。
|
package/lib/catalog.js
CHANGED
|
@@ -643,7 +643,9 @@ const CATALOG = [
|
|
|
643
643
|
// the user to an elevated prompt that changed nothing and hid the bytes from the plan.
|
|
644
644
|
note: 'in-use',
|
|
645
645
|
roots: ['SystemRoot'],
|
|
646
|
-
|
|
646
|
+
// SystemRoot, not a hardcoded C: — the boundary check above already trusted it,
|
|
647
|
+
// so a machine whose Windows sits on another volume must delete from the same place.
|
|
648
|
+
paths: () => [path.join(env('SystemRoot') ?? 'C:\\Windows', 'Temp')],
|
|
647
649
|
},
|
|
648
650
|
{
|
|
649
651
|
id: 'npm-cache',
|
|
@@ -717,7 +719,7 @@ const CATALOG = [
|
|
|
717
719
|
risk: 'caution',
|
|
718
720
|
note: 'elevation',
|
|
719
721
|
roots: ['SystemRoot'],
|
|
720
|
-
paths: () => ['C:\\Windows
|
|
722
|
+
paths: () => [path.join(env('SystemRoot') ?? 'C:\\Windows', 'SoftwareDistribution', 'Download')],
|
|
721
723
|
},
|
|
722
724
|
{
|
|
723
725
|
id: 'recycle-bin',
|
|
@@ -1057,27 +1059,45 @@ export async function measurePath(abs, cutoff) {
|
|
|
1057
1059
|
* folders hold. Reporting both is what tells the user "245 MB here, 0.2 MB past the limit"
|
|
1058
1060
|
* instead of a small number that reads as "nothing found".
|
|
1059
1061
|
*/
|
|
1062
|
+
const measureTarget = async (target) => {
|
|
1063
|
+
const cutoff = target.minAgeDays > 0 ? Date.now() - target.minAgeDays * DAY : undefined;
|
|
1064
|
+
let bytes = 0;
|
|
1065
|
+
let files = 0;
|
|
1066
|
+
let totalBytes = 0;
|
|
1067
|
+
let totalFiles = 0;
|
|
1068
|
+
let truncated = false;
|
|
1069
|
+
for (const abs of target.paths) {
|
|
1070
|
+
const size = await measurePath(abs, cutoff);
|
|
1071
|
+
bytes += size.bytes;
|
|
1072
|
+
files += size.files;
|
|
1073
|
+
totalBytes += size.totalBytes;
|
|
1074
|
+
totalFiles += size.totalFiles;
|
|
1075
|
+
truncated = truncated || size.truncated === true;
|
|
1076
|
+
}
|
|
1077
|
+
return { ...target, bytes, files, totalBytes, totalFiles, truncated };
|
|
1078
|
+
};
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Measuring all 32 targets at once meant 32 concurrent directory walks, which turns a
|
|
1082
|
+
* scan on a spinning disk (or behind a scanner) into a stall. Four at a time keeps the
|
|
1083
|
+
* wall clock close and the disk queue short; results keep catalog order either way.
|
|
1084
|
+
*/
|
|
1085
|
+
const SCAN_CONCURRENCY = 4;
|
|
1086
|
+
|
|
1060
1087
|
export async function scan(only, drive) {
|
|
1061
1088
|
const targets = resolveTargets(only, drive);
|
|
1062
|
-
const measured =
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
totalBytes += size.totalBytes;
|
|
1075
|
-
totalFiles += size.totalFiles;
|
|
1076
|
-
truncated = truncated || size.truncated === true;
|
|
1077
|
-
}
|
|
1078
|
-
return { ...target, bytes, files, totalBytes, totalFiles, truncated };
|
|
1079
|
-
}),
|
|
1080
|
-
);
|
|
1089
|
+
const measured = new Array(targets.length);
|
|
1090
|
+
let next = 0;
|
|
1091
|
+
const worker = async () => {
|
|
1092
|
+
while (next < targets.length) {
|
|
1093
|
+
const index = next;
|
|
1094
|
+
next += 1;
|
|
1095
|
+
measured[index] = await measureTarget(targets[index]);
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
const workers = [];
|
|
1099
|
+
for (let i = 0; i < Math.min(SCAN_CONCURRENCY, targets.length); i += 1) workers.push(worker());
|
|
1100
|
+
await Promise.all(workers);
|
|
1081
1101
|
return measured;
|
|
1082
1102
|
}
|
|
1083
1103
|
|
package/lib/client.js
CHANGED
|
@@ -102,6 +102,32 @@ window.__ModuleLoader__.load({
|
|
|
102
102
|
cleanConfirmList: '即将清理 {n} 项,合计 {bytes}:\n\n{list}\n\n回收站等不可恢复的项目会单独标出。',
|
|
103
103
|
cleanConfirmIrreversible: '\n\n注意:其中「回收站」清空后无法恢复。',
|
|
104
104
|
truncatedHint: '这个目录太大,只统计/清理了前 {n} 条,实际释放可能少于这个数。',
|
|
105
|
+
dryRunHint: '预演完成:没有删除任何文件。',
|
|
106
|
+
wouldFree: '可释放',
|
|
107
|
+
dryRunBtn: '预演',
|
|
108
|
+
dryRunBusy: '预演中…',
|
|
109
|
+
listSep: '、',
|
|
110
|
+
planEmpty: '现在没有值得动手的地方:缓存都还小,或者才清过。等它们长回来再看。',
|
|
111
|
+
historyTitle: '清理记录',
|
|
112
|
+
historyOpen: '查看清理记录',
|
|
113
|
+
historyLoading: '读取中…',
|
|
114
|
+
historyEmpty: '还没有清理记录。真正执行过一次清理后,这里会留下每一条。',
|
|
115
|
+
historyCopy: '复制为 CSV',
|
|
116
|
+
historyCopied: '已复制',
|
|
117
|
+
historyRun: '{time} · 释放 {bytes} · {files} 个文件',
|
|
118
|
+
historySourceAgent: '由 Agent 执行',
|
|
119
|
+
'err_method-not-allowed': '这个接口只接受本地面板的请求。',
|
|
120
|
+
'err_origin-rejected': '请求来源不被接受:请从面板里操作。',
|
|
121
|
+
'err_body-too-large': '请求内容过大。',
|
|
122
|
+
'err_body-invalid': '请求内容无法解析。',
|
|
123
|
+
'err_bad-ids': '目标列表格式不对。',
|
|
124
|
+
'err_too-many-ids': '一次选的目标太多了。',
|
|
125
|
+
'err_unknown-target': '有不认识的目标名称,请重新扫描后再试。',
|
|
126
|
+
'err_unknown-drive': '磁盘名称不认识。',
|
|
127
|
+
'err_drive-not-mounted': '这个盘当前没有挂载。',
|
|
128
|
+
'err_no-targets': '没有选中任何目标。',
|
|
129
|
+
'err_unknown-action': '不支持的操作。',
|
|
130
|
+
'err_internal': '插件内部出错了,详细信息见日志。',
|
|
105
131
|
'target_temp': '用户临时文件',
|
|
106
132
|
'target_windows-temp': 'Windows 临时文件',
|
|
107
133
|
'target_npm-cache': 'npm 缓存',
|
|
@@ -278,6 +304,32 @@ window.__ModuleLoader__.load({
|
|
|
278
304
|
cleanConfirmList: 'About to clean {n} target(s), {bytes} in total:\n\n{list}\n\nAnything irreversible is called out separately.',
|
|
279
305
|
cleanConfirmIrreversible: '\n\nNote: emptying the Recycle Bin cannot be undone.',
|
|
280
306
|
truncatedHint: 'This folder is too large: only the first {n} entries were measured or removed, so less may be freed.',
|
|
307
|
+
dryRunHint: 'Dry run finished: nothing was deleted.',
|
|
308
|
+
wouldFree: 'Could free',
|
|
309
|
+
dryRunBtn: 'Dry run',
|
|
310
|
+
dryRunBusy: 'Running…',
|
|
311
|
+
listSep: ', ',
|
|
312
|
+
planEmpty: 'Nothing worth doing right now: the caches are still small, or you just cleaned them. Come back when they grow.',
|
|
313
|
+
historyTitle: 'Clean history',
|
|
314
|
+
historyOpen: 'Show clean history',
|
|
315
|
+
historyLoading: 'Reading…',
|
|
316
|
+
historyEmpty: 'No runs recorded yet. Every real clean leaves one entry here.',
|
|
317
|
+
historyCopy: 'Copy as CSV',
|
|
318
|
+
historyCopied: 'Copied',
|
|
319
|
+
historyRun: '{time} · freed {bytes} · {files} file(s)',
|
|
320
|
+
historySourceAgent: 'run by the agent',
|
|
321
|
+
'err_method-not-allowed': 'This route only accepts requests from the local panel.',
|
|
322
|
+
'err_origin-rejected': 'Request origin rejected: use the panel.',
|
|
323
|
+
'err_body-too-large': 'The request was too large.',
|
|
324
|
+
'err_body-invalid': 'The request could not be parsed.',
|
|
325
|
+
'err_bad-ids': 'The target list is malformed.',
|
|
326
|
+
'err_too-many-ids': 'Too many targets in one request.',
|
|
327
|
+
'err_unknown-target': 'Unknown target name; scan again and retry.',
|
|
328
|
+
'err_unknown-drive': 'Unknown drive.',
|
|
329
|
+
'err_drive-not-mounted': 'That drive is not mounted.',
|
|
330
|
+
'err_no-targets': 'No target selected.',
|
|
331
|
+
'err_unknown-action': 'Unsupported action.',
|
|
332
|
+
'err_internal': 'The plugin hit an internal error; see the log.',
|
|
281
333
|
'target_temp': 'User temp files',
|
|
282
334
|
'target_windows-temp': 'Windows temp files',
|
|
283
335
|
'target_npm-cache': 'npm cache',
|
|
@@ -545,6 +597,21 @@ window.__ModuleLoader__.load({
|
|
|
545
597
|
return label === key ? id : label;
|
|
546
598
|
}
|
|
547
599
|
|
|
600
|
+
/**
|
|
601
|
+
* The route answers with a stable `code` beside its English message. The panel shows the
|
|
602
|
+
* translated code and only falls back to the message when the code is unknown, so a
|
|
603
|
+
* Chinese user never reads "origin rejected" or "unknown target: x".
|
|
604
|
+
*/
|
|
605
|
+
function describeError(t, err) {
|
|
606
|
+
const code = err && typeof err.code === 'string' ? err.code : '';
|
|
607
|
+
if (code !== '') {
|
|
608
|
+
const key = `err_${code}`;
|
|
609
|
+
const text = t(key);
|
|
610
|
+
if (text !== key) return text;
|
|
611
|
+
}
|
|
612
|
+
return err instanceof Error ? err.message : String(err);
|
|
613
|
+
}
|
|
614
|
+
|
|
548
615
|
async function call(action, payload) {
|
|
549
616
|
const response = await fetch(ROUTE, {
|
|
550
617
|
method: 'POST',
|
|
@@ -558,7 +625,9 @@ window.__ModuleLoader__.load({
|
|
|
558
625
|
throw new Error(`HTTP ${response.status}`);
|
|
559
626
|
}
|
|
560
627
|
if (!response.ok || !data || data.ok !== true) {
|
|
561
|
-
|
|
628
|
+
const error = new Error((data && data.error) || `HTTP ${response.status}`);
|
|
629
|
+
if (data && typeof data.code === 'string') error.code = data.code;
|
|
630
|
+
throw error;
|
|
562
631
|
}
|
|
563
632
|
return data;
|
|
564
633
|
}
|
|
@@ -779,10 +848,15 @@ window.__ModuleLoader__.load({
|
|
|
779
848
|
};
|
|
780
849
|
}, [drives.length, setDrives]);
|
|
781
850
|
|
|
782
|
-
|
|
851
|
+
/**
|
|
852
|
+
* `keepNotice` is what makes the clean result readable at all: a clean ends by
|
|
853
|
+
* rescanning, and a rescan used to clear the banner it had just set.
|
|
854
|
+
*/
|
|
855
|
+
const scan = useCallback(async (options) => {
|
|
856
|
+
const keepNotice = options !== null && typeof options === 'object' && options.keepNotice === true;
|
|
783
857
|
setBusy('scan');
|
|
784
858
|
setError('');
|
|
785
|
-
setNotice(null);
|
|
859
|
+
if (!keepNotice) setNotice(null);
|
|
786
860
|
try {
|
|
787
861
|
const data = await call('scan', drive === '' ? undefined : { drive });
|
|
788
862
|
const targets = Array.isArray(data.targets) ? data.targets : [];
|
|
@@ -798,7 +872,7 @@ window.__ModuleLoader__.load({
|
|
|
798
872
|
.map((row) => row.id);
|
|
799
873
|
setPicked(new Set(preselect));
|
|
800
874
|
} catch (err) {
|
|
801
|
-
setError(`${t('scanError')}: ${
|
|
875
|
+
setError(`${t('scanError')}: ${describeError(t, err)}`);
|
|
802
876
|
} finally {
|
|
803
877
|
setBusy('');
|
|
804
878
|
}
|
|
@@ -830,10 +904,16 @@ window.__ModuleLoader__.load({
|
|
|
830
904
|
setPicked(new Set(eligible));
|
|
831
905
|
};
|
|
832
906
|
|
|
833
|
-
|
|
907
|
+
/**
|
|
908
|
+
* `dry` runs the exact same request without `confirm`, so the number the panel reports
|
|
909
|
+
* is the number the delete would achieve. It never asks for confirmation: it deletes
|
|
910
|
+
* nothing. A real run keeps asking.
|
|
911
|
+
*/
|
|
912
|
+
const runClean = async (dry) => {
|
|
913
|
+
const isDry = dry === true;
|
|
834
914
|
if (!rows || picked.size === 0) return;
|
|
835
915
|
const chosen = shown.filter((row) => picked.has(row.id));
|
|
836
|
-
if (confirmFirst.get() === 'always') {
|
|
916
|
+
if (!isDry && confirmFirst.get() === 'always') {
|
|
837
917
|
// A confirm box that does not say what it deletes is not a confirmation. Name the
|
|
838
918
|
// targets and the total, and call out anything that cannot be undone.
|
|
839
919
|
const bytes = chosen.reduce((sum, row) => sum + (row.bytes || 0), 0);
|
|
@@ -851,12 +931,20 @@ window.__ModuleLoader__.load({
|
|
|
851
931
|
setError('');
|
|
852
932
|
setNotice(null);
|
|
853
933
|
try {
|
|
854
|
-
const
|
|
934
|
+
const body = isDry ? { ids } : { ids, confirm: true };
|
|
935
|
+
const data = await call('clean', drive === '' ? body : { ...body, drive });
|
|
855
936
|
const results = Array.isArray(data.results) ? data.results : [];
|
|
856
937
|
const bytes = results.reduce((sum, item) => sum + (item.bytes || 0), 0);
|
|
857
938
|
const files = results.reduce((sum, item) => sum + (item.removed || 0), 0);
|
|
858
939
|
const failed = results.reduce((sum, item) => sum + (item.failed || 0), 0);
|
|
859
940
|
const truncated = results.filter((item) => item.truncated === true).length > 0;
|
|
941
|
+
if (isDry) {
|
|
942
|
+
setNotice({
|
|
943
|
+
kind: 'ok',
|
|
944
|
+
text: `${t('dryRunHint')} ${t('wouldFree')} ${formatBytes(bytes)} · ${files} ${t('files')}`,
|
|
945
|
+
});
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
860
948
|
// A run that left files behind is not a success banner: an all-ok green line over
|
|
861
949
|
// "12 skipped" reads as done when it is not.
|
|
862
950
|
const kind = failed > 0 ? 'warn' : 'ok';
|
|
@@ -864,9 +952,9 @@ window.__ModuleLoader__.load({
|
|
|
864
952
|
if (failed > 0) parts.push(`${t('failed')} ${failed} (${t('failedHint')})`);
|
|
865
953
|
if (truncated) parts.push(t('truncatedHint').replace('{n}', String(results.length)));
|
|
866
954
|
setNotice({ kind, text: parts.join(' · ') });
|
|
867
|
-
await scan();
|
|
955
|
+
await scan({ keepNotice: true });
|
|
868
956
|
} catch (err) {
|
|
869
|
-
setError(`${t('cleanError')}: ${
|
|
957
|
+
setError(`${t('cleanError')}: ${describeError(t, err)}`);
|
|
870
958
|
} finally {
|
|
871
959
|
setBusy('');
|
|
872
960
|
}
|
|
@@ -977,13 +1065,26 @@ window.__ModuleLoader__.load({
|
|
|
977
1065
|
picked.size > 0 ? ` · ${t('selected')} ${formatBytes(pickedBytes)}` : '',
|
|
978
1066
|
)
|
|
979
1067
|
: null,
|
|
1068
|
+
rows
|
|
1069
|
+
? h(
|
|
1070
|
+
'button',
|
|
1071
|
+
{
|
|
1072
|
+
type: 'button',
|
|
1073
|
+
className: 'dsc-btn',
|
|
1074
|
+
onClick: () => runClean(true),
|
|
1075
|
+
disabled: busy !== '' || picked.size === 0,
|
|
1076
|
+
title: t('dryRunHint'),
|
|
1077
|
+
},
|
|
1078
|
+
busy === 'clean' ? t('dryRunBusy') : t('dryRunBtn'),
|
|
1079
|
+
)
|
|
1080
|
+
: null,
|
|
980
1081
|
rows
|
|
981
1082
|
? h(
|
|
982
1083
|
'button',
|
|
983
1084
|
{
|
|
984
1085
|
type: 'button',
|
|
985
1086
|
className: 'dsc-btn dsc-btn-danger',
|
|
986
|
-
onClick: runClean,
|
|
1087
|
+
onClick: () => runClean(false),
|
|
987
1088
|
disabled: busy !== '' || picked.size === 0,
|
|
988
1089
|
},
|
|
989
1090
|
busy === 'clean' ? t('cleaning') : t('clean'),
|
|
@@ -1088,10 +1189,110 @@ window.__ModuleLoader__.load({
|
|
|
1088
1189
|
),
|
|
1089
1190
|
),
|
|
1090
1191
|
h(Audit, { t }),
|
|
1192
|
+
h(History, { t }),
|
|
1091
1193
|
],
|
|
1092
1194
|
);
|
|
1093
1195
|
}
|
|
1094
1196
|
|
|
1197
|
+
/**
|
|
1198
|
+
* Every real clean appends one line to a JSONL file under $DSH_HOME. The panel reads it
|
|
1199
|
+
* back, so "what did I actually clean, and when" has an answer that outlives the tab —
|
|
1200
|
+
* and the CSV copy takes that answer out of the app.
|
|
1201
|
+
*/
|
|
1202
|
+
function History({ t }) {
|
|
1203
|
+
const [runs, setRuns] = useState(null);
|
|
1204
|
+
const [busy, setBusy] = useState(false);
|
|
1205
|
+
const [error, setError] = useState('');
|
|
1206
|
+
const [copied, setCopied] = useState(false);
|
|
1207
|
+
|
|
1208
|
+
const load = async () => {
|
|
1209
|
+
setBusy(true);
|
|
1210
|
+
setError('');
|
|
1211
|
+
try {
|
|
1212
|
+
const data = await call('history');
|
|
1213
|
+
setRuns(Array.isArray(data.runs) ? data.runs : []);
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
setError(`${t('historyTitle')}: ${describeError(t, err)}`);
|
|
1216
|
+
} finally {
|
|
1217
|
+
setBusy(false);
|
|
1218
|
+
}
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
/** Quote every cell: a target list contains commas, and Excel must not split it. */
|
|
1222
|
+
const copyCsv = async () => {
|
|
1223
|
+
const rows = [['when', 'bytes', 'files', 'failed', 'truncated', 'targets', 'source']];
|
|
1224
|
+
for (const run of runs || []) {
|
|
1225
|
+
rows.push([
|
|
1226
|
+
run.at || '',
|
|
1227
|
+
String(run.bytes || 0),
|
|
1228
|
+
String(run.files || 0),
|
|
1229
|
+
String(run.failed || 0),
|
|
1230
|
+
run.truncated === true ? 'yes' : 'no',
|
|
1231
|
+
(Array.isArray(run.ids) ? run.ids : []).join(' '),
|
|
1232
|
+
run.source === 'agent' ? 'agent' : 'panel',
|
|
1233
|
+
]);
|
|
1234
|
+
}
|
|
1235
|
+
const csv = rows
|
|
1236
|
+
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
|
1237
|
+
.join('\r\n');
|
|
1238
|
+
try {
|
|
1239
|
+
await navigator.clipboard.writeText(csv);
|
|
1240
|
+
setCopied(true);
|
|
1241
|
+
} catch {
|
|
1242
|
+
setError(t('historyCopy'));
|
|
1243
|
+
}
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
return h(
|
|
1247
|
+
'section',
|
|
1248
|
+
{ className: 'dsc-card' },
|
|
1249
|
+
h(
|
|
1250
|
+
'div',
|
|
1251
|
+
{ className: 'dsc-card-title' },
|
|
1252
|
+
t('historyTitle'),
|
|
1253
|
+
h('span', { className: 'dsc-spacer' }),
|
|
1254
|
+
runs && runs.length > 0
|
|
1255
|
+
? h('button', { type: 'button', className: 'dsc-btn', onClick: copyCsv }, copied ? t('historyCopied') : t('historyCopy'))
|
|
1256
|
+
: null,
|
|
1257
|
+
h(
|
|
1258
|
+
'button',
|
|
1259
|
+
{ type: 'button', className: 'dsc-btn', onClick: load, disabled: busy },
|
|
1260
|
+
busy ? t('historyLoading') : t('historyOpen'),
|
|
1261
|
+
),
|
|
1262
|
+
),
|
|
1263
|
+
error ? h('div', { className: 'dsc-notice dsc-err' }, error) : null,
|
|
1264
|
+
runs === null
|
|
1265
|
+
? null
|
|
1266
|
+
: runs.length === 0
|
|
1267
|
+
? h('div', { className: 'dsc-empty' }, t('historyEmpty'))
|
|
1268
|
+
: runs.map((run, index) =>
|
|
1269
|
+
h(
|
|
1270
|
+
'div',
|
|
1271
|
+
{ className: 'dsc-audit-row', key: `${run.at || ''}/${index}` },
|
|
1272
|
+
h(
|
|
1273
|
+
'span',
|
|
1274
|
+
{ className: 'dsc-audit-name' },
|
|
1275
|
+
h(
|
|
1276
|
+
'span',
|
|
1277
|
+
{ className: 'dsc-audit-label' },
|
|
1278
|
+
t('historyRun')
|
|
1279
|
+
.replace('{time}', run.at ? new Date(run.at).toLocaleString() : '')
|
|
1280
|
+
.replace('{bytes}', formatBytes(run.bytes || 0))
|
|
1281
|
+
.replace('{files}', String(run.files || 0)),
|
|
1282
|
+
),
|
|
1283
|
+
h(
|
|
1284
|
+
'span',
|
|
1285
|
+
{ className: 'dsc-audit-why' },
|
|
1286
|
+
run.source === 'agent'
|
|
1287
|
+
? t('historySourceAgent')
|
|
1288
|
+
: (Array.isArray(run.ids) ? run.ids : []).map((id) => targetName(t, id)).join(t('listSep')),
|
|
1289
|
+
),
|
|
1290
|
+
),
|
|
1291
|
+
),
|
|
1292
|
+
),
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1095
1296
|
/**
|
|
1096
1297
|
* The plan turns the numbers into an order of operations. Steps that this plugin can
|
|
1097
1298
|
* actually perform get a button; the rest show the exact command, because "compact
|
|
@@ -1100,7 +1301,17 @@ window.__ModuleLoader__.load({
|
|
|
1100
1301
|
function Plan({ plan, t, onRan, busy }) {
|
|
1101
1302
|
const [copied, setCopied] = useState('');
|
|
1102
1303
|
|
|
1103
|
-
if (!plan || !Array.isArray(plan.steps)
|
|
1304
|
+
if (!plan || !Array.isArray(plan.steps)) return null;
|
|
1305
|
+
// An empty plan is an answer — "there is nothing worth doing" — not a reason to
|
|
1306
|
+
// render a hole where the plan was.
|
|
1307
|
+
if (plan.steps.length === 0) {
|
|
1308
|
+
return h(
|
|
1309
|
+
'div',
|
|
1310
|
+
{ className: 'dsc-plan' },
|
|
1311
|
+
h('div', { className: 'dsc-audit-group-title' }, t('planTitle')),
|
|
1312
|
+
h('div', { className: 'dsc-plan-detail' }, t('planEmpty')),
|
|
1313
|
+
);
|
|
1314
|
+
}
|
|
1104
1315
|
|
|
1105
1316
|
const copy = (code, command) => {
|
|
1106
1317
|
const done = () => {
|
|
@@ -1125,7 +1336,7 @@ window.__ModuleLoader__.load({
|
|
|
1125
1336
|
const detailKey = `plan_code_${step.code}_detail`;
|
|
1126
1337
|
const detail = t(detailKey)
|
|
1127
1338
|
.replace('{n}', String(step.count || 0))
|
|
1128
|
-
.replace('{ids}', (step.ids || []).
|
|
1339
|
+
.replace('{ids}', (step.ids || []).map((id) => targetName(t, id)).join(t('listSep')))
|
|
1129
1340
|
.replace('{name}', step.name || '')
|
|
1130
1341
|
.replace('{scope}', step.scope === 'local' ? t('planScopeLocal') : t('planScopeRoaming'))
|
|
1131
1342
|
.replace('{folder}', step.folder || '')
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What each real run actually freed, appended as one JSON line per run.
|
|
7
|
+
*
|
|
8
|
+
* The panel is the only writer and it writes after the deletion has already happened, so a
|
|
9
|
+
* failed append must never turn a successful clean into an error: every function here answers
|
|
10
|
+
* with a value instead of throwing. Nothing else reads this file, which is why it is a plain
|
|
11
|
+
* JSONL log rather than a database.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MAX_BYTES = 512 * 1024;
|
|
15
|
+
|
|
16
|
+
export function historyDir(env = process.env) {
|
|
17
|
+
const home =
|
|
18
|
+
typeof env.DSH_HOME === 'string' && env.DSH_HOME.trim() !== ''
|
|
19
|
+
? env.DSH_HOME.trim()
|
|
20
|
+
: path.join(os.homedir(), '.dsh');
|
|
21
|
+
return path.join(home, 'dsh-disk-cleaner');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function historyFile(env = process.env) {
|
|
25
|
+
return path.join(historyDir(env), 'history.jsonl');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const trim = (file) => {
|
|
29
|
+
try {
|
|
30
|
+
const stat = fs.statSync(file);
|
|
31
|
+
if (stat.size <= MAX_BYTES) return;
|
|
32
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter((line) => line.trim() !== '');
|
|
33
|
+
// Keep the newest half, which is what a reader ever asks about.
|
|
34
|
+
const kept = lines.slice(Math.floor(lines.length / 2));
|
|
35
|
+
fs.writeFileSync(file, `${kept.join('\n')}\n`, 'utf8');
|
|
36
|
+
} catch {
|
|
37
|
+
/* a log that cannot be trimmed is still a log */
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function appendRun(record, env = process.env) {
|
|
42
|
+
try {
|
|
43
|
+
const dir = historyDir(env);
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
const file = historyFile(env);
|
|
46
|
+
fs.appendFileSync(file, `${JSON.stringify(record)}\n`, 'utf8');
|
|
47
|
+
trim(file);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function readRuns(limit = 50, env = process.env) {
|
|
55
|
+
try {
|
|
56
|
+
const text = fs.readFileSync(historyFile(env), 'utf8');
|
|
57
|
+
const lines = text.split('\n').filter((line) => line.trim() !== '');
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const line of lines.slice(-limit)) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(line);
|
|
62
|
+
if (parsed !== null && typeof parsed === 'object') out.push(parsed);
|
|
63
|
+
} catch {
|
|
64
|
+
/* a torn last line is not a reason to lose the rest */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out.reverse();
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process';
|
|
|
11
11
|
|
|
12
12
|
import { areaPaths, audit, invalidateAudit } from './audit.js';
|
|
13
13
|
import { clean, drives, hasTarget, resolveTargets, scan } from './catalog.js';
|
|
14
|
+
import { appendRun, readRuns } from './history.js';
|
|
14
15
|
|
|
15
16
|
export const name = 'dsh-disk-cleaner';
|
|
16
17
|
|
|
@@ -20,6 +21,16 @@ const ROUTE_PREFIX = '/api/disk-cleaner/';
|
|
|
20
21
|
const MAX_BODY = 4096;
|
|
21
22
|
const MAX_IDS = 32;
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Every rejection carries a code beside its message. The message is for logs and curl; the
|
|
26
|
+
* code is what the panel translates, so a Chinese user never reads "origin rejected".
|
|
27
|
+
*/
|
|
28
|
+
const coded = (code, message) => {
|
|
29
|
+
const error = new Error(message);
|
|
30
|
+
error.code = code;
|
|
31
|
+
return error;
|
|
32
|
+
};
|
|
33
|
+
|
|
23
34
|
const sendJson = (res, status, value) => {
|
|
24
35
|
const body = JSON.stringify(value);
|
|
25
36
|
res.writeHead(status, {
|
|
@@ -30,11 +41,18 @@ const sendJson = (res, status, value) => {
|
|
|
30
41
|
res.end(body);
|
|
31
42
|
};
|
|
32
43
|
|
|
33
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* A same-origin page is the only caller that can reach a state-changing route.
|
|
46
|
+
*
|
|
47
|
+
* An absent Origin used to pass, which let any local process drive clean/set-key by simply
|
|
48
|
+
* not sending the header. Browsers always send it on a cross-context POST, so requiring it
|
|
49
|
+
* costs the panel nothing and shuts the door; a missing Host is treated the same way.
|
|
50
|
+
*/
|
|
34
51
|
const sameOrigin = (req) => {
|
|
35
52
|
const origin = req.headers.origin;
|
|
36
53
|
const host = req.headers.host;
|
|
37
|
-
|
|
54
|
+
if (typeof origin !== 'string' || typeof host !== 'string') return false;
|
|
55
|
+
return origin === `http://${host}` || origin === `https://${host}`;
|
|
38
56
|
};
|
|
39
57
|
|
|
40
58
|
const readBody = (req) =>
|
|
@@ -74,10 +92,10 @@ const readBody = (req) =>
|
|
|
74
92
|
/** Only catalog ids are accepted; an unknown or malformed id rejects the request. */
|
|
75
93
|
const readIds = (value) => {
|
|
76
94
|
if (value === undefined || value === null) return undefined;
|
|
77
|
-
if (!Array.isArray(value)) throw
|
|
78
|
-
if (value.length > MAX_IDS) throw
|
|
95
|
+
if (!Array.isArray(value)) throw coded('bad-ids', 'ids must be an array');
|
|
96
|
+
if (value.length > MAX_IDS) throw coded('too-many-ids', 'too many ids');
|
|
79
97
|
for (const id of value) {
|
|
80
|
-
if (typeof id !== 'string' || !hasTarget(id)) throw
|
|
98
|
+
if (typeof id !== 'string' || !hasTarget(id)) throw coded('unknown-target', `unknown target: ${String(id)}`);
|
|
81
99
|
}
|
|
82
100
|
return value;
|
|
83
101
|
};
|
|
@@ -91,10 +109,10 @@ const readDrive = (value) => {
|
|
|
91
109
|
if (value === undefined || value === null || value === '') return undefined;
|
|
92
110
|
const raw = typeof value === 'string' ? value.trim() : '';
|
|
93
111
|
const match = /^([A-Za-z])(?::[\\/]?)?$/.exec(raw);
|
|
94
|
-
if (match === null) throw
|
|
112
|
+
if (match === null) throw coded('unknown-drive', `unknown drive: ${String(value)}`);
|
|
95
113
|
const letter = match[1].toUpperCase();
|
|
96
114
|
const mounted = drives().find((drive) => drive.root[0] === letter);
|
|
97
|
-
if (!mounted) throw
|
|
115
|
+
if (!mounted) throw coded('drive-not-mounted', `drive not mounted: ${letter}:`);
|
|
98
116
|
return mounted.root;
|
|
99
117
|
};
|
|
100
118
|
|
|
@@ -173,24 +191,38 @@ async function run(action, payload) {
|
|
|
173
191
|
if (action === 'reveal') return reveal(payload);
|
|
174
192
|
if (action === 'clean') {
|
|
175
193
|
const ids = readIds(payload.ids);
|
|
176
|
-
if (ids === undefined || ids.length === 0) throw
|
|
194
|
+
if (ids === undefined || ids.length === 0) throw coded('no-targets', 'clean needs at least one target id');
|
|
177
195
|
const dryRun = payload.confirm !== true;
|
|
178
196
|
const results = await clean(ids, { dryRun, drive: readDrive(payload.drive) });
|
|
179
197
|
// A real deletion changes the numbers the assessment reported, so the cached plan is
|
|
180
198
|
// no longer the truth. A dry run removed nothing and leaves it valid.
|
|
181
|
-
if (!dryRun)
|
|
199
|
+
if (!dryRun) {
|
|
200
|
+
invalidateAudit();
|
|
201
|
+
// Written after the fact and never allowed to fail the request: the bytes are already
|
|
202
|
+
// gone by the time this runs, so a broken log must not read as a broken clean.
|
|
203
|
+
appendRun({
|
|
204
|
+
at: new Date().toISOString(),
|
|
205
|
+
bytes: results.reduce((sum, item) => sum + (item.bytes || 0), 0),
|
|
206
|
+
files: results.reduce((sum, item) => sum + (item.removed || 0), 0),
|
|
207
|
+
failed: results.reduce((sum, item) => sum + (item.failed || 0), 0),
|
|
208
|
+
truncated: results.some((item) => item.truncated === true),
|
|
209
|
+
ids: results.map((item) => item.id),
|
|
210
|
+
results,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
182
213
|
return { dryRun, results };
|
|
183
214
|
}
|
|
184
|
-
|
|
215
|
+
if (action === 'history') return { runs: readRuns(50) };
|
|
216
|
+
throw coded('unknown-action', `unknown action: ${String(action)}`);
|
|
185
217
|
}
|
|
186
218
|
|
|
187
219
|
const handle = async (req, res) => {
|
|
188
220
|
if (req.method !== 'POST') {
|
|
189
|
-
sendJson(res, 405, { ok: false, error: 'method not allowed' });
|
|
221
|
+
sendJson(res, 405, { ok: false, code: 'method-not-allowed', error: 'method not allowed' });
|
|
190
222
|
return;
|
|
191
223
|
}
|
|
192
224
|
if (!sameOrigin(req)) {
|
|
193
|
-
sendJson(res, 403, { ok: false, error: 'origin rejected' });
|
|
225
|
+
sendJson(res, 403, { ok: false, code: 'origin-rejected', error: 'origin rejected' });
|
|
194
226
|
return;
|
|
195
227
|
}
|
|
196
228
|
try {
|
|
@@ -198,7 +230,11 @@ const handle = async (req, res) => {
|
|
|
198
230
|
const action = typeof payload.action === 'string' ? payload.action : '';
|
|
199
231
|
sendJson(res, 200, { ok: true, ...(await run(action, payload)) });
|
|
200
232
|
} catch (error) {
|
|
201
|
-
sendJson(res, 400, {
|
|
233
|
+
sendJson(res, 400, {
|
|
234
|
+
ok: false,
|
|
235
|
+
code: typeof error?.code === 'string' ? error.code : 'internal',
|
|
236
|
+
error: error instanceof Error ? error.message : String(error),
|
|
237
|
+
});
|
|
202
238
|
}
|
|
203
239
|
};
|
|
204
240
|
|
|
@@ -413,10 +449,22 @@ export function apply(ctx) {
|
|
|
413
449
|
}
|
|
414
450
|
if (action === 'clean') {
|
|
415
451
|
const ids = readIds(args.ids);
|
|
416
|
-
if (ids === undefined || ids.length === 0) throw
|
|
452
|
+
if (ids === undefined || ids.length === 0) throw coded('no-targets', 'clean needs at least one target id');
|
|
417
453
|
const dryRun = args.confirm !== true;
|
|
418
454
|
const results = await clean(ids, { dryRun, drive: readDrive(args.drive) });
|
|
419
|
-
if (!dryRun)
|
|
455
|
+
if (!dryRun) {
|
|
456
|
+
invalidateAudit();
|
|
457
|
+
appendRun({
|
|
458
|
+
at: new Date().toISOString(),
|
|
459
|
+
bytes: results.reduce((sum, item) => sum + (item.bytes || 0), 0),
|
|
460
|
+
files: results.reduce((sum, item) => sum + (item.removed || 0), 0),
|
|
461
|
+
failed: results.reduce((sum, item) => sum + (item.failed || 0), 0),
|
|
462
|
+
truncated: results.some((item) => item.truncated === true),
|
|
463
|
+
ids: results.map((item) => item.id),
|
|
464
|
+
source: 'agent',
|
|
465
|
+
results,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
420
468
|
const freed = results.reduce((sum, item) => sum + item.bytes, 0);
|
|
421
469
|
const files = results.reduce((sum, item) => sum + item.removed, 0);
|
|
422
470
|
const failed = results.reduce((sum, item) => sum + item.failed, 0);
|
|
@@ -426,7 +474,7 @@ export function apply(ctx) {
|
|
|
426
474
|
: `Freed ${formatBytes(freed)} across ${files} file(s).`;
|
|
427
475
|
return `${head}\n${detail}\nLocked or denied entries skipped: ${failed}`;
|
|
428
476
|
}
|
|
429
|
-
throw
|
|
477
|
+
throw coded('unknown-action', `unknown action: ${action}`);
|
|
430
478
|
},
|
|
431
479
|
}),
|
|
432
480
|
'dsh-disk-cleaner: tool',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-disk-cleaner",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Disk Cleaner for DSH: scan known-safe cache locations, see what they occupy, and reclaim the space from a panel or from the agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"lib/catalog.js",
|
|
24
24
|
"lib/audit.js",
|
|
25
25
|
"lib/plan.js",
|
|
26
|
+
"lib/history.js",
|
|
26
27
|
"cordis.patch.yml",
|
|
27
28
|
"locale/*.json",
|
|
28
29
|
"icon.svg",
|
package/smoke.mjs
CHANGED
|
@@ -229,6 +229,7 @@ const index = read('lib/index.js');
|
|
|
229
229
|
const catalog = read('lib/catalog.js');
|
|
230
230
|
const plan = read('lib/plan.js');
|
|
231
231
|
const auditSrc = read('lib/audit.js');
|
|
232
|
+
const historySrc = read('lib/history.js');
|
|
232
233
|
|
|
233
234
|
const { apply } = await import(pathToFileURL(path.join(here, 'lib/index.js')).href);
|
|
234
235
|
check('host exports apply()', typeof apply === 'function');
|
|
@@ -264,7 +265,7 @@ check('tool needs no id for overview', JSON.stringify(definition.parameters.requ
|
|
|
264
265
|
check('tool description warns audit is read-only', /read-only: it never removes anything/.test(definition.description));
|
|
265
266
|
check('every effect is labelled', applied.every((label) => typeof label === 'string' && label.startsWith('dsh-disk-cleaner:')));
|
|
266
267
|
check('tool output schema matches its string reply', definition.output.schema.type === 'string' && typeof (await definition.execute({ action: 'overview' })) === 'string');
|
|
267
|
-
check('the read-only agent clean keeps the audit cache in sync', /if \(!dryRun\) invalidateAudit\(\);[\s\S]
|
|
268
|
+
check('the read-only agent clean keeps the audit cache in sync', /if \(!dryRun\) \{[\s\S]*?invalidateAudit\(\);[\s\S]*?const freed = results\.reduce/.test(index));
|
|
268
269
|
check('orphan target descriptions state the real 30-day rule, not four signals / two years',
|
|
269
270
|
/30 days/.test(catalog) && /30 days/.test(index) && /QQGuild data folder/.test(source) &&
|
|
270
271
|
!/four independent signals|over two years|no Start-menu shortcut|two years/.test(catalog + index + source));
|
|
@@ -412,7 +413,106 @@ check(
|
|
|
412
413
|
source.includes('QQ') ,
|
|
413
414
|
'it only ever measures the QQGuild folder',
|
|
414
415
|
);
|
|
415
|
-
check('the version badge and package agree', pkg.version === '1.6.
|
|
416
|
+
check('the version badge and package agree', pkg.version === '1.6.6');
|
|
417
|
+
|
|
418
|
+
check(
|
|
419
|
+
'a dry run is one click away and never sends confirm',
|
|
420
|
+
/const runClean = async \(dry\) => \{/.test(source) &&
|
|
421
|
+
/const body = isDry \? \{ ids \} : \{ ids, confirm: true \};/.test(source) &&
|
|
422
|
+
/onClick: \(\) => runClean\(true\)/.test(source) &&
|
|
423
|
+
/onClick: \(\) => runClean\(false\)/.test(source),
|
|
424
|
+
'the panel must be able to ask what a delete would free before doing it',
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
check(
|
|
428
|
+
'a dry run never claims it freed anything',
|
|
429
|
+
/if \(isDry\) \{[\s\S]{0,320}t\('dryRunHint'\)[\s\S]{0,320}return;/.test(source) &&
|
|
430
|
+
/dryRunHint: '/.test(source),
|
|
431
|
+
'the dry-run banner must say nothing was deleted',
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
check(
|
|
435
|
+
'the notice a clean sets survives the rescan that follows it',
|
|
436
|
+
/await scan\(\{ keepNotice: true \}\);/.test(source) &&
|
|
437
|
+
/if \(!keepNotice\) setNotice\(null\);/.test(source),
|
|
438
|
+
'rescanning used to erase the result the user just asked for',
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
check(
|
|
442
|
+
'plan steps name targets in words, never catalog ids',
|
|
443
|
+
!source.includes("(step.ids || []).join(', ')") &&
|
|
444
|
+
source.includes('(step.ids || []).map((id) => targetName(t, id))'),
|
|
445
|
+
'the panel must not print npm-cache, chrome-cache, pip-cache at the user',
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
check(
|
|
449
|
+
'an empty plan says so instead of rendering nothing',
|
|
450
|
+
/if \(plan.steps.length === 0\)/.test(source) &&
|
|
451
|
+
/planEmpty: '/.test(source) &&
|
|
452
|
+
/t\('planEmpty'\)/.test(source),
|
|
453
|
+
'nothing worth doing is a conclusion, not a blank',
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
check(
|
|
457
|
+
'route errors carry a stable code the panel can translate',
|
|
458
|
+
/const coded = \(code, message\) => \{/.test(index) &&
|
|
459
|
+
/coded\('unknown-target'/.test(index) &&
|
|
460
|
+
/coded\('no-targets'/.test(index) &&
|
|
461
|
+
/code: typeof error\?\.code === 'string' \? error\.code : 'internal'/.test(index) &&
|
|
462
|
+
/function describeError\(t, err\) \{/.test(source) &&
|
|
463
|
+
/if \(data && typeof data\.code === 'string'\) error\.code = data\.code;/.test(source),
|
|
464
|
+
'a Chinese panel must not print "origin rejected" or "unknown target: x"',
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
check(
|
|
468
|
+
'every route error code has both a Chinese and an English sentence',
|
|
469
|
+
['method-not-allowed', 'origin-rejected', 'bad-ids', 'too-many-ids', 'unknown-target', 'unknown-drive', 'drive-not-mounted', 'no-targets', 'unknown-action', 'internal'].every(
|
|
470
|
+
(code) => source.includes(`'err_${code}': `),
|
|
471
|
+
),
|
|
472
|
+
'an untranslated code falls back to the English message',
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
check(
|
|
476
|
+
'a real clean writes one history line and the panel can read it back',
|
|
477
|
+
/import \{ appendRun, readRuns \} from '\.\/history\.js';/.test(index) &&
|
|
478
|
+
/if \(action === 'history'\) return \{ runs: readRuns\(50\) \};/.test(index) &&
|
|
479
|
+
/appendRun\(\{/.test(index) &&
|
|
480
|
+
/export function appendRun/.test(historySrc) &&
|
|
481
|
+
/export function readRuns/.test(historySrc) &&
|
|
482
|
+
/historyTitle: '/.test(source) &&
|
|
483
|
+
/function History\(\{ t \}\)/.test(source) &&
|
|
484
|
+
/h\(History, \{ t \}\)/.test(source),
|
|
485
|
+
'history must survive the tab, and be exportable',
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
check(
|
|
489
|
+
'history never throws into the clean path',
|
|
490
|
+
/try \{/.test(historySrc) &&
|
|
491
|
+
/catch/.test(historySrc) &&
|
|
492
|
+
!/throw /.test(historySrc),
|
|
493
|
+
'the bytes are already gone; failing to log them must not look like a failed clean',
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
check(
|
|
497
|
+
'scan bounds its own concurrency instead of walking every target at once',
|
|
498
|
+
/const SCAN_CONCURRENCY = \d+;/.test(catalog) &&
|
|
499
|
+
/const worker = async \(\) => \{/.test(catalog) &&
|
|
500
|
+
!/await Promise\.all\(targets\.map/.test(catalog),
|
|
501
|
+
'32 volumes at once stalls a spinning disk and the antivirus',
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
check(
|
|
505
|
+
'a system target under SystemRoot is read from SystemRoot, not from C:',
|
|
506
|
+
(catalog.match(/env\('SystemRoot'\)/g) ?? []).length === 2 &&
|
|
507
|
+
!/C:\\+Windows\\+Temp/.test(catalog),
|
|
508
|
+
'the boundary check already trusted SystemRoot; the paths have to agree',
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
check(
|
|
512
|
+
'a request without an Origin is refused',
|
|
513
|
+
/if \(typeof origin !== 'string' \|\| typeof host !== 'string'\) return false;/.test(index),
|
|
514
|
+
'any local process could otherwise drive a destructive route',
|
|
515
|
+
);
|
|
416
516
|
|
|
417
517
|
check(
|
|
418
518
|
'the panel carries dsh-design-baseline (full mode: css inlined, root tagged, tokens only)',
|