@mison/wecom-cleaner 1.0.0
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/LICENSE +21 -0
- package/README.md +323 -0
- package/docs/IMPLEMENTATION_PLAN.md +162 -0
- package/docs/releases/v1.0.0.md +32 -0
- package/native/README.md +9 -0
- package/native/bin/darwin-arm64/wecom-cleaner-core +0 -0
- package/native/bin/darwin-x64/wecom-cleaner-core +0 -0
- package/native/manifest.json +15 -0
- package/native/zig/build.sh +68 -0
- package/native/zig/src/main.zig +96 -0
- package/package.json +62 -0
- package/src/analysis.js +58 -0
- package/src/cleanup.js +217 -0
- package/src/cli.js +2619 -0
- package/src/config.js +270 -0
- package/src/constants.js +309 -0
- package/src/doctor.js +366 -0
- package/src/error-taxonomy.js +73 -0
- package/src/lock.js +102 -0
- package/src/native-bridge.js +403 -0
- package/src/recycle-maintenance.js +335 -0
- package/src/restore.js +533 -0
- package/src/scanner.js +1277 -0
- package/src/utils.js +365 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,2619 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { checkbox, confirm, input, select } from '@inquirer/prompts';
|
|
8
|
+
import {
|
|
9
|
+
CACHE_CATEGORIES,
|
|
10
|
+
DEFAULT_PROFILE_ROOT,
|
|
11
|
+
MODES,
|
|
12
|
+
SPACE_GOVERNANCE_TARGETS,
|
|
13
|
+
SPACE_GOVERNANCE_TIERS,
|
|
14
|
+
SPACE_GOVERNANCE_TIER_LABELS,
|
|
15
|
+
PACKAGE_NAME,
|
|
16
|
+
APP_NAME,
|
|
17
|
+
APP_ASCII_LOGO,
|
|
18
|
+
} from './constants.js';
|
|
19
|
+
import { CliArgError, loadAliases, loadConfig, parseCliArgs, saveAliases, saveConfig } from './config.js';
|
|
20
|
+
import { detectNativeCore } from './native-bridge.js';
|
|
21
|
+
import {
|
|
22
|
+
discoverAccounts,
|
|
23
|
+
collectAvailableMonths,
|
|
24
|
+
collectCleanupTargets,
|
|
25
|
+
analyzeCacheFootprint,
|
|
26
|
+
scanSpaceGovernanceTargets,
|
|
27
|
+
detectExternalStorageRoots,
|
|
28
|
+
} from './scanner.js';
|
|
29
|
+
import { executeCleanup } from './cleanup.js';
|
|
30
|
+
import { listRestorableBatches, restoreBatch } from './restore.js';
|
|
31
|
+
import { printAnalysisSummary } from './analysis.js';
|
|
32
|
+
import { runDoctor } from './doctor.js';
|
|
33
|
+
import { acquireLock, breakLock, LockHeldError } from './lock.js';
|
|
34
|
+
import { classifyErrorType, errorTypeToLabel } from './error-taxonomy.js';
|
|
35
|
+
import { collectRecycleStats, maintainRecycleBin, normalizeRecycleRetention } from './recycle-maintenance.js';
|
|
36
|
+
import {
|
|
37
|
+
compareMonthKey,
|
|
38
|
+
expandHome,
|
|
39
|
+
formatBytes,
|
|
40
|
+
formatLocalDate,
|
|
41
|
+
inferDataRootFromProfilesRoot,
|
|
42
|
+
monthByDaysBefore,
|
|
43
|
+
normalizeMonthKey,
|
|
44
|
+
padToWidth,
|
|
45
|
+
printProgress,
|
|
46
|
+
printSection,
|
|
47
|
+
renderTable,
|
|
48
|
+
sleep,
|
|
49
|
+
trimToWidth,
|
|
50
|
+
} from './utils.js';
|
|
51
|
+
|
|
52
|
+
class PromptAbortError extends Error {
|
|
53
|
+
constructor() {
|
|
54
|
+
super('Prompt aborted by user');
|
|
55
|
+
this.name = 'PromptAbortError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isPromptAbort(error) {
|
|
60
|
+
if (!error) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const text = `${error.name || ''} ${error.message || ''}`;
|
|
64
|
+
return (
|
|
65
|
+
text.includes('ExitPromptError') ||
|
|
66
|
+
text.includes('SIGINT') ||
|
|
67
|
+
text.includes('canceled') ||
|
|
68
|
+
text.includes('force closed')
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const PROMPT_BACK = '__prompt_back__';
|
|
73
|
+
|
|
74
|
+
function isBackCommand(inputValue) {
|
|
75
|
+
const normalized = String(inputValue || '')
|
|
76
|
+
.trim()
|
|
77
|
+
.toLowerCase();
|
|
78
|
+
return normalized === '/b' || normalized === 'b' || normalized === 'back' || normalized === '返回';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function withBackChoice(choices, allowBack) {
|
|
82
|
+
const inputChoices = Array.isArray(choices) ? [...choices] : [];
|
|
83
|
+
if (!allowBack) {
|
|
84
|
+
return inputChoices;
|
|
85
|
+
}
|
|
86
|
+
const already = inputChoices.some((choice) => choice?.value === PROMPT_BACK);
|
|
87
|
+
if (!already) {
|
|
88
|
+
inputChoices.push({
|
|
89
|
+
name: '← 返回上一步',
|
|
90
|
+
value: PROMPT_BACK,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return inputChoices;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function withConfirmDefaultHint(message, defaultValue) {
|
|
97
|
+
const text = String(message || '').trim();
|
|
98
|
+
if (!text) {
|
|
99
|
+
return text;
|
|
100
|
+
}
|
|
101
|
+
if (text.includes('回车默认')) {
|
|
102
|
+
return text;
|
|
103
|
+
}
|
|
104
|
+
const hint = defaultValue ? '(y/n,回车默认: y)' : '(y/n,回车默认: n)';
|
|
105
|
+
return `${text} ${hint}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isPromptBack(value) {
|
|
109
|
+
return value === PROMPT_BACK;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function askSelect(config) {
|
|
113
|
+
try {
|
|
114
|
+
const allowBack = Boolean(config?.allowBack);
|
|
115
|
+
const normalizedConfig = {
|
|
116
|
+
...config,
|
|
117
|
+
choices: withBackChoice(config?.choices, allowBack),
|
|
118
|
+
};
|
|
119
|
+
delete normalizedConfig.allowBack;
|
|
120
|
+
return await select(normalizedConfig);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (isPromptAbort(error)) {
|
|
123
|
+
throw new PromptAbortError();
|
|
124
|
+
}
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function askCheckbox(config) {
|
|
130
|
+
try {
|
|
131
|
+
const allowBack = Boolean(config?.allowBack);
|
|
132
|
+
const originalValidate = config?.validate;
|
|
133
|
+
const normalizedConfig = {
|
|
134
|
+
...config,
|
|
135
|
+
choices: withBackChoice(config?.choices, allowBack),
|
|
136
|
+
validate: (values) => {
|
|
137
|
+
if (allowBack && Array.isArray(values) && values.includes(PROMPT_BACK)) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
if (typeof originalValidate === 'function') {
|
|
141
|
+
return originalValidate(values);
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
delete normalizedConfig.allowBack;
|
|
147
|
+
|
|
148
|
+
const values = await checkbox(normalizedConfig);
|
|
149
|
+
if (allowBack && Array.isArray(values) && values.includes(PROMPT_BACK)) {
|
|
150
|
+
return PROMPT_BACK;
|
|
151
|
+
}
|
|
152
|
+
return values;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (isPromptAbort(error)) {
|
|
155
|
+
throw new PromptAbortError();
|
|
156
|
+
}
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function askInput(config) {
|
|
162
|
+
try {
|
|
163
|
+
const allowBack = Boolean(config?.allowBack);
|
|
164
|
+
const originalValidate = config?.validate;
|
|
165
|
+
const normalizedConfig = {
|
|
166
|
+
...config,
|
|
167
|
+
message: allowBack ? `${String(config?.message || '').trim()}(输入 /b 返回上一步)` : config?.message,
|
|
168
|
+
validate: (value) => {
|
|
169
|
+
if (allowBack && isBackCommand(value)) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
if (typeof originalValidate === 'function') {
|
|
173
|
+
return originalValidate(value);
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
delete normalizedConfig.allowBack;
|
|
179
|
+
|
|
180
|
+
const value = await input(normalizedConfig);
|
|
181
|
+
if (allowBack && isBackCommand(value)) {
|
|
182
|
+
return PROMPT_BACK;
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (isPromptAbort(error)) {
|
|
187
|
+
throw new PromptAbortError();
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function askConfirm(config) {
|
|
194
|
+
try {
|
|
195
|
+
const defaultValue = config?.default === undefined ? false : Boolean(config.default);
|
|
196
|
+
const normalizedConfig = {
|
|
197
|
+
...config,
|
|
198
|
+
message: withConfirmDefaultHint(config?.message, defaultValue),
|
|
199
|
+
default: defaultValue,
|
|
200
|
+
};
|
|
201
|
+
return await confirm(normalizedConfig);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (isPromptAbort(error)) {
|
|
204
|
+
throw new PromptAbortError();
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function askConfirmWithBack(config) {
|
|
211
|
+
const defaultValue = config?.default === undefined ? false : Boolean(config.default);
|
|
212
|
+
const selected = await askSelect({
|
|
213
|
+
message: `${String(config?.message || '').trim()}(回车默认: ${defaultValue ? '是' : '否'})`,
|
|
214
|
+
default: defaultValue ? '__yes__' : '__no__',
|
|
215
|
+
choices: [
|
|
216
|
+
{ name: `是${defaultValue ? '(默认)' : ''}`, value: '__yes__' },
|
|
217
|
+
{ name: `否${!defaultValue ? '(默认)' : ''}`, value: '__no__' },
|
|
218
|
+
],
|
|
219
|
+
allowBack: true,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (isPromptBack(selected)) {
|
|
223
|
+
return PROMPT_BACK;
|
|
224
|
+
}
|
|
225
|
+
return selected === '__yes__';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function categoryChoices(defaultKeys = [], options = {}) {
|
|
229
|
+
const includeAllByDefault = Boolean(options.includeAllByDefault);
|
|
230
|
+
const defaultSet = new Set(defaultKeys);
|
|
231
|
+
return CACHE_CATEGORIES.map((cat) => ({
|
|
232
|
+
name: `${cat.label} (${cat.key}) - ${cat.desc}`,
|
|
233
|
+
value: cat.key,
|
|
234
|
+
checked:
|
|
235
|
+
defaultSet.size === 0
|
|
236
|
+
? includeAllByDefault
|
|
237
|
+
? true
|
|
238
|
+
: cat.defaultSelected !== false
|
|
239
|
+
: defaultSet.has(cat.key),
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function resolveEngineStatus({ nativeCorePath, lastRunEngineUsed }) {
|
|
244
|
+
if (!nativeCorePath) {
|
|
245
|
+
return {
|
|
246
|
+
badge: 'Node模式',
|
|
247
|
+
detail: '未检测到 Zig 核心,当前使用 Node 引擎',
|
|
248
|
+
tone: 'muted',
|
|
249
|
+
fullText: 'Zig加速:未开启(当前使用Node)',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (lastRunEngineUsed === 'zig') {
|
|
253
|
+
return {
|
|
254
|
+
badge: 'Zig加速',
|
|
255
|
+
detail: '本次扫描已启用 Zig 核心',
|
|
256
|
+
tone: 'ok',
|
|
257
|
+
fullText: 'Zig加速:已生效(本次扫描更快)',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (lastRunEngineUsed === 'node') {
|
|
261
|
+
return {
|
|
262
|
+
badge: 'Node回退',
|
|
263
|
+
detail: '检测到 Zig 核心,但本次扫描自动回退到 Node',
|
|
264
|
+
tone: 'warn',
|
|
265
|
+
fullText: 'Zig加速:本次未生效(已自动改用Node)',
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
badge: 'Zig就绪',
|
|
270
|
+
detail: '已检测到 Zig 核心,开始扫描后会自动启用',
|
|
271
|
+
tone: 'info',
|
|
272
|
+
fullText: 'Zig加速:已就绪(开始扫描后自动使用)',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const ANSI_RESET = '\x1b[0m';
|
|
277
|
+
const ANSI_BOLD = '\x1b[1m';
|
|
278
|
+
const LOGO_LEFT_PADDING = ' ';
|
|
279
|
+
const INFO_LEFT_PADDING = ' ';
|
|
280
|
+
const AUTO_ADAPTIVE_LABEL_COLOR = [46, 132, 228];
|
|
281
|
+
const THEME_AUTO = 'auto';
|
|
282
|
+
const THEME_LIGHT = 'light';
|
|
283
|
+
const THEME_DARK = 'dark';
|
|
284
|
+
const THEME_SET = new Set([THEME_AUTO, THEME_LIGHT, THEME_DARK]);
|
|
285
|
+
const LOGO_THEME_PALETTES = {
|
|
286
|
+
light: {
|
|
287
|
+
wecomStops: [
|
|
288
|
+
{ at: 0, color: [0, 170, 220] },
|
|
289
|
+
{ at: 0.55, color: [0, 130, 220] },
|
|
290
|
+
{ at: 1, color: [55, 85, 215] },
|
|
291
|
+
],
|
|
292
|
+
cleanerStops: [
|
|
293
|
+
{ at: 0, color: [0, 190, 215] },
|
|
294
|
+
{ at: 0.6, color: [0, 185, 95] },
|
|
295
|
+
{ at: 1, color: [210, 175, 0] },
|
|
296
|
+
],
|
|
297
|
+
subtitleColor: [20, 90, 185],
|
|
298
|
+
versionColor: [0, 120, 165],
|
|
299
|
+
},
|
|
300
|
+
dark: {
|
|
301
|
+
wecomStops: [
|
|
302
|
+
{ at: 0, color: [70, 205, 255] },
|
|
303
|
+
{ at: 0.55, color: [55, 165, 255] },
|
|
304
|
+
{ at: 1, color: [70, 110, 255] },
|
|
305
|
+
],
|
|
306
|
+
cleanerStops: [
|
|
307
|
+
{ at: 0, color: [90, 225, 255] },
|
|
308
|
+
{ at: 0.6, color: [90, 220, 140] },
|
|
309
|
+
{ at: 1, color: [250, 220, 90] },
|
|
310
|
+
],
|
|
311
|
+
subtitleColor: [120, 180, 255],
|
|
312
|
+
versionColor: [95, 220, 240],
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
const INFO_THEME_PALETTES = {
|
|
316
|
+
light: {
|
|
317
|
+
labelColor: [36, 72, 124],
|
|
318
|
+
valueColor: [32, 44, 66],
|
|
319
|
+
mutedColor: [62, 82, 116],
|
|
320
|
+
dividerColor: [176, 186, 206],
|
|
321
|
+
badges: {
|
|
322
|
+
info: [35, 128, 220],
|
|
323
|
+
ok: [0, 164, 94],
|
|
324
|
+
warn: [190, 145, 0],
|
|
325
|
+
muted: [122, 134, 156],
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
dark: {
|
|
329
|
+
labelColor: [148, 198, 255],
|
|
330
|
+
valueColor: [232, 242, 255],
|
|
331
|
+
mutedColor: [196, 216, 246],
|
|
332
|
+
dividerColor: [92, 114, 152],
|
|
333
|
+
badges: {
|
|
334
|
+
info: [60, 150, 255],
|
|
335
|
+
ok: [24, 185, 108],
|
|
336
|
+
warn: [212, 168, 38],
|
|
337
|
+
muted: [104, 118, 145],
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
function canUseAnsiColor() {
|
|
343
|
+
return Boolean(process.stdout?.isTTY) && !process.env.NO_COLOR && process.env.NODE_DISABLE_COLORS !== '1';
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function ansiColor(color) {
|
|
347
|
+
return `\x1b[38;2;${color[0]};${color[1]};${color[2]}m`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function ansiBgColor(color) {
|
|
351
|
+
return `\x1b[48;2;${color[0]};${color[1]};${color[2]}m`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function normalizeThemeMode(themeMode) {
|
|
355
|
+
if (typeof themeMode !== 'string') {
|
|
356
|
+
return THEME_AUTO;
|
|
357
|
+
}
|
|
358
|
+
const normalized = themeMode.trim().toLowerCase();
|
|
359
|
+
if (!THEME_SET.has(normalized)) {
|
|
360
|
+
return THEME_AUTO;
|
|
361
|
+
}
|
|
362
|
+
return normalized;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function detectThemeByColorFgBg() {
|
|
366
|
+
const raw = process.env.COLORFGBG || '';
|
|
367
|
+
if (!raw) {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
const parts = raw
|
|
371
|
+
.split(/[:;]/)
|
|
372
|
+
.map((x) => Number.parseInt(x, 10))
|
|
373
|
+
.filter((x) => Number.isFinite(x));
|
|
374
|
+
if (parts.length === 0) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
const bg = parts[parts.length - 1];
|
|
378
|
+
if (bg <= 6 || bg === 8) {
|
|
379
|
+
return THEME_DARK;
|
|
380
|
+
}
|
|
381
|
+
return THEME_LIGHT;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function detectThemeByEnvHint() {
|
|
385
|
+
const envHints = [
|
|
386
|
+
process.env.TERM_THEME,
|
|
387
|
+
process.env.COLORSCHEME,
|
|
388
|
+
process.env.THEME,
|
|
389
|
+
process.env.ITERM_PROFILE,
|
|
390
|
+
]
|
|
391
|
+
.filter(Boolean)
|
|
392
|
+
.map((x) => String(x).toLowerCase());
|
|
393
|
+
|
|
394
|
+
for (const hint of envHints) {
|
|
395
|
+
if (hint.includes('dark')) {
|
|
396
|
+
return THEME_DARK;
|
|
397
|
+
}
|
|
398
|
+
if (hint.includes('light')) {
|
|
399
|
+
return THEME_LIGHT;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function resolveThemeMode(themeMode) {
|
|
406
|
+
const normalized = normalizeThemeMode(themeMode);
|
|
407
|
+
if (normalized === THEME_LIGHT || normalized === THEME_DARK) {
|
|
408
|
+
return normalized;
|
|
409
|
+
}
|
|
410
|
+
return detectThemeByColorFgBg() || detectThemeByEnvHint() || THEME_DARK;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function themeLabel(themeMode) {
|
|
414
|
+
const normalized = normalizeThemeMode(themeMode);
|
|
415
|
+
if (normalized === THEME_LIGHT) {
|
|
416
|
+
return '亮色';
|
|
417
|
+
}
|
|
418
|
+
if (normalized === THEME_DARK) {
|
|
419
|
+
return '暗色';
|
|
420
|
+
}
|
|
421
|
+
return '自动';
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function formatThemeStatus(themeMode, resolvedThemeMode) {
|
|
425
|
+
const normalized = normalizeThemeMode(themeMode);
|
|
426
|
+
if (normalized === THEME_AUTO) {
|
|
427
|
+
return '主题:自动';
|
|
428
|
+
}
|
|
429
|
+
return `主题:${themeLabel(normalized)}`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function lerp(a, b, t) {
|
|
433
|
+
return Math.round(a + (b - a) * t);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function blendColor(start, end, t) {
|
|
437
|
+
return [lerp(start[0], end[0], t), lerp(start[1], end[1], t), lerp(start[2], end[2], t)];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function pickGradientColor(stops, t) {
|
|
441
|
+
if (t <= stops[0].at) {
|
|
442
|
+
return stops[0].color;
|
|
443
|
+
}
|
|
444
|
+
if (t >= stops[stops.length - 1].at) {
|
|
445
|
+
return stops[stops.length - 1].color;
|
|
446
|
+
}
|
|
447
|
+
for (let i = 0; i < stops.length - 1; i += 1) {
|
|
448
|
+
const curr = stops[i];
|
|
449
|
+
const next = stops[i + 1];
|
|
450
|
+
if (t >= curr.at && t <= next.at) {
|
|
451
|
+
const denom = next.at - curr.at || 1;
|
|
452
|
+
const local = (t - curr.at) / denom;
|
|
453
|
+
return blendColor(curr.color, next.color, local);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return stops[stops.length - 1].color;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function colorizeGradient(line, stops) {
|
|
460
|
+
if (!canUseAnsiColor()) {
|
|
461
|
+
return line;
|
|
462
|
+
}
|
|
463
|
+
const chars = [...line];
|
|
464
|
+
const denom = Math.max(chars.length - 1, 1);
|
|
465
|
+
let output = '';
|
|
466
|
+
for (let i = 0; i < chars.length; i += 1) {
|
|
467
|
+
const ch = chars[i];
|
|
468
|
+
if (ch === ' ') {
|
|
469
|
+
output += ' ';
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const color = pickGradientColor(stops, i / denom);
|
|
473
|
+
output += `${ansiColor(color)}${ch}`;
|
|
474
|
+
}
|
|
475
|
+
return `${output}${ANSI_RESET}`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function colorizeText(text, color, options = {}) {
|
|
479
|
+
if (!canUseAnsiColor()) {
|
|
480
|
+
return text;
|
|
481
|
+
}
|
|
482
|
+
const boldPrefix = options.bold ? ANSI_BOLD : '';
|
|
483
|
+
return `${boldPrefix}${ansiColor(color)}${text}${ANSI_RESET}`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function styleWithTerminalDefault(text, options = {}) {
|
|
487
|
+
if (!canUseAnsiColor()) {
|
|
488
|
+
return text;
|
|
489
|
+
}
|
|
490
|
+
const boldPrefix = options.bold ? ANSI_BOLD : '';
|
|
491
|
+
return `${boldPrefix}${text}${ANSI_RESET}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function resolveInfoPalette(resolvedThemeMode) {
|
|
495
|
+
return INFO_THEME_PALETTES[resolvedThemeMode] || INFO_THEME_PALETTES.dark;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function pickBadgeForegroundColor(bgColor) {
|
|
499
|
+
const brightness = (bgColor[0] * 299 + bgColor[1] * 587 + bgColor[2] * 114) / 1000;
|
|
500
|
+
return brightness >= 150 ? [18, 26, 40] : [245, 250, 255];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function renderBadge(text, tone, palette) {
|
|
504
|
+
if (!canUseAnsiColor()) {
|
|
505
|
+
return `[${text}]`;
|
|
506
|
+
}
|
|
507
|
+
const bg = palette.badges[tone] || palette.badges.info;
|
|
508
|
+
const fg = pickBadgeForegroundColor(bg);
|
|
509
|
+
return `${ansiBgColor(bg)}${ansiColor(fg)} ${text} ${ANSI_RESET}`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function renderHeaderDivider(resolvedThemeMode, options = {}) {
|
|
513
|
+
const palette = resolveInfoPalette(resolvedThemeMode);
|
|
514
|
+
const width = Math.max(
|
|
515
|
+
42,
|
|
516
|
+
Math.min(96, Number(process.stdout.columns || 120) - INFO_LEFT_PADDING.length * 2)
|
|
517
|
+
);
|
|
518
|
+
if (options.adaptiveText) {
|
|
519
|
+
return `${INFO_LEFT_PADDING}${styleWithTerminalDefault('─'.repeat(width))}`;
|
|
520
|
+
}
|
|
521
|
+
return `${INFO_LEFT_PADDING}${colorizeText('─'.repeat(width), palette.dividerColor)}`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderHeaderInfoLines(label, value, resolvedThemeMode, options = {}) {
|
|
525
|
+
const palette = resolveInfoPalette(resolvedThemeMode);
|
|
526
|
+
const valueIndent = `${INFO_LEFT_PADDING} `;
|
|
527
|
+
const maxValueWidth = Math.max(28, Number(process.stdout.columns || 120) - valueIndent.length - 2);
|
|
528
|
+
const clippedValue = trimToWidth(String(value || '-'), maxValueWidth);
|
|
529
|
+
if (options.adaptiveText) {
|
|
530
|
+
const labelText = colorizeText(`${label}:`, AUTO_ADAPTIVE_LABEL_COLOR, { bold: true });
|
|
531
|
+
const valueText = styleWithTerminalDefault(clippedValue);
|
|
532
|
+
return [`${INFO_LEFT_PADDING}${labelText}`, `${valueIndent}${valueText}`];
|
|
533
|
+
}
|
|
534
|
+
const labelText = colorizeText(`${label}:`, palette.labelColor, { bold: true });
|
|
535
|
+
const isDarkTheme = resolvedThemeMode === THEME_DARK;
|
|
536
|
+
const valueColor = options.muted
|
|
537
|
+
? isDarkTheme
|
|
538
|
+
? palette.valueColor
|
|
539
|
+
: palette.mutedColor
|
|
540
|
+
: palette.valueColor;
|
|
541
|
+
return [`${INFO_LEFT_PADDING}${labelText}`, `${valueIndent}${colorizeText(clippedValue, valueColor)}`];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function guideLabelStyle(text) {
|
|
545
|
+
if (!canUseAnsiColor()) {
|
|
546
|
+
return text;
|
|
547
|
+
}
|
|
548
|
+
return colorizeText(text, AUTO_ADAPTIVE_LABEL_COLOR, { bold: true });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function printGuideBlock(title, rows = []) {
|
|
552
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
553
|
+
const labelWidth = 6;
|
|
554
|
+
const maxValueWidth = Math.max(28, Number(process.stdout.columns || 120) - INFO_LEFT_PADDING.length - 12);
|
|
555
|
+
console.log(`${INFO_LEFT_PADDING}┌ ${styleWithTerminalDefault(title, { bold: true })}`);
|
|
556
|
+
|
|
557
|
+
if (list.length === 0) {
|
|
558
|
+
console.log(`${INFO_LEFT_PADDING}└`);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
list.forEach((row, idx) => {
|
|
563
|
+
const prefix = idx === list.length - 1 ? '└' : '│';
|
|
564
|
+
if (typeof row === 'string') {
|
|
565
|
+
console.log(`${INFO_LEFT_PADDING}${prefix} ${trimToWidth(row, maxValueWidth + labelWidth + 2)}`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
const label = padToWidth(String(row?.label || '-'), labelWidth);
|
|
569
|
+
const value = trimToWidth(String(row?.value || '-'), maxValueWidth);
|
|
570
|
+
const labelText = guideLabelStyle(label);
|
|
571
|
+
console.log(`${INFO_LEFT_PADDING}${prefix} ${labelText}:${value}`);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function summarizeErrorsByType(errors, options = {}) {
|
|
576
|
+
const list = Array.isArray(errors) ? errors : [];
|
|
577
|
+
const pickMessage =
|
|
578
|
+
typeof options.pickMessage === 'function' ? options.pickMessage : (item) => item?.message;
|
|
579
|
+
const counts = new Map();
|
|
580
|
+
for (const item of list) {
|
|
581
|
+
const label = errorTypeToLabel(classifyErrorType(pickMessage(item)));
|
|
582
|
+
counts.set(label, (counts.get(label) || 0) + 1);
|
|
583
|
+
}
|
|
584
|
+
return [...counts.entries()]
|
|
585
|
+
.map(([label, count]) => ({ label, count }))
|
|
586
|
+
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-Hans-CN'));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function printErrorDiagnostics(errors, options = {}) {
|
|
590
|
+
const list = Array.isArray(errors) ? errors : [];
|
|
591
|
+
if (list.length === 0) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const summaryRows = summarizeErrorsByType(list, options).map((item) => ({
|
|
596
|
+
label: item.label,
|
|
597
|
+
value: `${item.count} 项`,
|
|
598
|
+
}));
|
|
599
|
+
printGuideBlock('失败汇总', summaryRows);
|
|
600
|
+
|
|
601
|
+
const defaultLimit = Math.max(1, Number(options.defaultLimit || 5));
|
|
602
|
+
const headers =
|
|
603
|
+
Array.isArray(options.headers) && options.headers.length > 0 ? options.headers : ['路径', '错误'];
|
|
604
|
+
const mapRow =
|
|
605
|
+
typeof options.mapRow === 'function'
|
|
606
|
+
? options.mapRow
|
|
607
|
+
: (item) => [item?.path || '-', item?.message || '-'];
|
|
608
|
+
const showAll = await askConfirm({
|
|
609
|
+
message: `失败明细共 ${list.length} 条,是否展开查看全部?`,
|
|
610
|
+
default: false,
|
|
611
|
+
});
|
|
612
|
+
const limit = showAll ? list.length : Math.min(defaultLimit, list.length);
|
|
613
|
+
const rows = list.slice(0, limit).map((item) => mapRow(item).map((cell) => trimToWidth(cell, 50)));
|
|
614
|
+
const detailTitle = showAll ? `失败明细(全部 ${limit} 条)` : `失败明细(前 ${limit} 条)`;
|
|
615
|
+
console.log(`\n${detailTitle}:`);
|
|
616
|
+
console.log(renderTable(headers, rows));
|
|
617
|
+
if (!showAll && list.length > limit) {
|
|
618
|
+
console.log(`... 已省略其余 ${list.length - limit} 条。`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function doctorStatusText(status) {
|
|
623
|
+
if (status === 'pass') {
|
|
624
|
+
return '通过';
|
|
625
|
+
}
|
|
626
|
+
if (status === 'warn') {
|
|
627
|
+
return '警告';
|
|
628
|
+
}
|
|
629
|
+
return '失败';
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function printDoctorReport(report, asJson) {
|
|
633
|
+
if (asJson) {
|
|
634
|
+
console.log(JSON.stringify(report, null, 2));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
printSection('系统自检结果');
|
|
639
|
+
printGuideBlock('总体状态', [
|
|
640
|
+
{ label: '结果', value: doctorStatusText(report.overall) },
|
|
641
|
+
{ label: '通过', value: `${report.summary.pass} 项` },
|
|
642
|
+
{ label: '警告', value: `${report.summary.warn} 项` },
|
|
643
|
+
{ label: '失败', value: `${report.summary.fail} 项` },
|
|
644
|
+
{ label: '平台', value: `${report.runtime.targetTag}` },
|
|
645
|
+
]);
|
|
646
|
+
|
|
647
|
+
const rows = report.checks.map((item) => [
|
|
648
|
+
item.title,
|
|
649
|
+
doctorStatusText(item.status),
|
|
650
|
+
trimToWidth(item.detail, 58),
|
|
651
|
+
]);
|
|
652
|
+
console.log(renderTable(['检查项', '状态', '详情'], rows));
|
|
653
|
+
|
|
654
|
+
if (Array.isArray(report.recommendations) && report.recommendations.length > 0) {
|
|
655
|
+
printGuideBlock(
|
|
656
|
+
'修复建议',
|
|
657
|
+
report.recommendations.slice(0, 8).map((item) => ({ label: '建议', value: item }))
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function recycleThresholdBytes(config) {
|
|
663
|
+
const policy = normalizeRecycleRetention(config.recycleRetention);
|
|
664
|
+
return Math.max(1, Number(policy.sizeThresholdGB || 20)) * 1024 * 1024 * 1024;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function printRecyclePressureHint(config) {
|
|
668
|
+
const policy = normalizeRecycleRetention(config.recycleRetention);
|
|
669
|
+
if (!policy.enabled) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const stats = await collectRecycleStats({
|
|
674
|
+
indexPath: config.indexPath,
|
|
675
|
+
recycleRoot: config.recycleRoot,
|
|
676
|
+
});
|
|
677
|
+
const threshold = recycleThresholdBytes(config);
|
|
678
|
+
if (stats.totalBytes <= threshold) {
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
printGuideBlock('回收区容量提示', [
|
|
682
|
+
{ label: '当前', value: `${formatBytes(stats.totalBytes)}(${stats.totalBatches} 批)` },
|
|
683
|
+
{ label: '阈值', value: `${formatBytes(threshold)}` },
|
|
684
|
+
{ label: '建议', value: '建议执行“回收区治理(保留策略)”释放空间。' },
|
|
685
|
+
]);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function renderAsciiLogoLines(appMeta, resolvedThemeMode) {
|
|
689
|
+
const palette = LOGO_THEME_PALETTES[resolvedThemeMode] || LOGO_THEME_PALETTES.dark;
|
|
690
|
+
|
|
691
|
+
const logoLines = [];
|
|
692
|
+
for (const line of APP_ASCII_LOGO.wecom) {
|
|
693
|
+
logoLines.push(`${LOGO_LEFT_PADDING}${colorizeGradient(line, palette.wecomStops)}`);
|
|
694
|
+
}
|
|
695
|
+
logoLines.push('');
|
|
696
|
+
for (const line of APP_ASCII_LOGO.cleaner) {
|
|
697
|
+
logoLines.push(`${LOGO_LEFT_PADDING}${colorizeGradient(line, palette.cleanerStops)}`);
|
|
698
|
+
}
|
|
699
|
+
logoLines.push('');
|
|
700
|
+
logoLines.push(`${LOGO_LEFT_PADDING}${colorizeText(APP_ASCII_LOGO.subtitle, palette.subtitleColor)}`);
|
|
701
|
+
logoLines.push(`${LOGO_LEFT_PADDING}${colorizeText(`v${appMeta.version}`, palette.versionColor)}`);
|
|
702
|
+
logoLines.push('');
|
|
703
|
+
return logoLines;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function normalizeRepositoryUrl(rawValue) {
|
|
707
|
+
if (typeof rawValue !== 'string' || rawValue.trim() === '') {
|
|
708
|
+
return '';
|
|
709
|
+
}
|
|
710
|
+
let url = rawValue.trim();
|
|
711
|
+
if (url.startsWith('git+')) {
|
|
712
|
+
url = url.slice(4);
|
|
713
|
+
}
|
|
714
|
+
if (url.startsWith('git@github.com:')) {
|
|
715
|
+
url = `https://github.com/${url.slice('git@github.com:'.length)}`;
|
|
716
|
+
}
|
|
717
|
+
if (url.endsWith('.git')) {
|
|
718
|
+
url = url.slice(0, -4);
|
|
719
|
+
}
|
|
720
|
+
return url;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function loadAppMeta(projectRoot) {
|
|
724
|
+
const fallback = {
|
|
725
|
+
version: process.env.npm_package_version || '0.0.0',
|
|
726
|
+
author: 'MisonL',
|
|
727
|
+
repository: 'https://github.com/MisonL/wecom-cleaner',
|
|
728
|
+
license: 'MIT',
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
const packagePath = path.join(projectRoot, 'package.json');
|
|
733
|
+
const text = await fs.readFile(packagePath, 'utf-8');
|
|
734
|
+
const pkg = JSON.parse(text);
|
|
735
|
+
const author = typeof pkg.author === 'string' ? pkg.author : pkg.author?.name;
|
|
736
|
+
const repositoryRaw = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
|
|
737
|
+
|
|
738
|
+
return {
|
|
739
|
+
version: String(pkg.version || fallback.version),
|
|
740
|
+
author: String(author || fallback.author),
|
|
741
|
+
repository: normalizeRepositoryUrl(repositoryRaw) || fallback.repository,
|
|
742
|
+
license: String(pkg.license || fallback.license),
|
|
743
|
+
};
|
|
744
|
+
} catch {
|
|
745
|
+
return fallback;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async function isDirectoryPath(targetPath) {
|
|
750
|
+
const stat = await fs.stat(targetPath).catch(() => null);
|
|
751
|
+
return Boolean(stat?.isDirectory());
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async function collectProfileRootCandidates(configRootDir) {
|
|
755
|
+
const candidates = new Set([configRootDir, DEFAULT_PROFILE_ROOT]);
|
|
756
|
+
const containersRoot = path.join(os.homedir(), 'Library', 'Containers');
|
|
757
|
+
const entries = await fs.readdir(containersRoot, { withFileTypes: true }).catch(() => []);
|
|
758
|
+
for (const entry of entries) {
|
|
759
|
+
if (!entry.isDirectory()) {
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (!entry.name.toLowerCase().includes('wework')) {
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
candidates.add(path.join(containersRoot, entry.name, 'Data', 'Documents', 'Profiles'));
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const rows = [];
|
|
769
|
+
for (const item of candidates) {
|
|
770
|
+
const rootDir = path.resolve(String(item || ''));
|
|
771
|
+
if (!(await isDirectoryPath(rootDir))) {
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const accountCount = (await discoverAccounts(rootDir, {})).length;
|
|
775
|
+
if (accountCount <= 0) {
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
rows.push({
|
|
779
|
+
rootDir,
|
|
780
|
+
accountCount,
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const dedup = new Map();
|
|
785
|
+
for (const row of rows) {
|
|
786
|
+
dedup.set(row.rootDir, row);
|
|
787
|
+
}
|
|
788
|
+
return [...dedup.values()].sort(
|
|
789
|
+
(a, b) => b.accountCount - a.accountCount || a.rootDir.localeCompare(b.rootDir)
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function evaluateProfileRootHealth(configRootDir, accounts) {
|
|
794
|
+
const rootExists = await isDirectoryPath(configRootDir);
|
|
795
|
+
if (rootExists && accounts.length > 0) {
|
|
796
|
+
return {
|
|
797
|
+
status: 'ok',
|
|
798
|
+
candidates: [],
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const currentRoot = path.resolve(configRootDir);
|
|
803
|
+
const allCandidates = await collectProfileRootCandidates(configRootDir);
|
|
804
|
+
const candidates = allCandidates.filter((item) => item.rootDir !== currentRoot);
|
|
805
|
+
return {
|
|
806
|
+
status: rootExists ? 'empty' : 'missing',
|
|
807
|
+
candidates,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function printHeader({
|
|
812
|
+
config,
|
|
813
|
+
accountCount,
|
|
814
|
+
nativeCorePath,
|
|
815
|
+
lastRunEngineUsed,
|
|
816
|
+
appMeta,
|
|
817
|
+
nativeRepairNote,
|
|
818
|
+
externalStorageRoots = [],
|
|
819
|
+
externalStorageMeta = null,
|
|
820
|
+
profileRootHealth = null,
|
|
821
|
+
}) {
|
|
822
|
+
console.clear();
|
|
823
|
+
const normalizedThemeMode = normalizeThemeMode(config.theme);
|
|
824
|
+
const resolvedThemeMode = resolveThemeMode(config.theme);
|
|
825
|
+
const palette = resolveInfoPalette(resolvedThemeMode);
|
|
826
|
+
const engineStatus = resolveEngineStatus({ nativeCorePath, lastRunEngineUsed });
|
|
827
|
+
const adaptiveHeaderText = normalizedThemeMode === THEME_AUTO;
|
|
828
|
+
const printLine = (label, value, options = {}) => {
|
|
829
|
+
const lines = renderHeaderInfoLines(label, value, resolvedThemeMode, {
|
|
830
|
+
adaptiveText: adaptiveHeaderText,
|
|
831
|
+
...options,
|
|
832
|
+
});
|
|
833
|
+
for (const line of lines) {
|
|
834
|
+
console.log(line);
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
console.log(renderAsciiLogoLines(appMeta, resolvedThemeMode).join('\n'));
|
|
839
|
+
|
|
840
|
+
const stateBadges = [
|
|
841
|
+
renderBadge(`账号 ${accountCount}`, 'info', palette),
|
|
842
|
+
renderBadge(engineStatus.badge, engineStatus.tone, palette),
|
|
843
|
+
renderBadge(formatThemeStatus(config.theme, resolvedThemeMode), 'muted', palette),
|
|
844
|
+
];
|
|
845
|
+
console.log(`${INFO_LEFT_PADDING}${stateBadges.join(' ')}`);
|
|
846
|
+
printLine('引擎说明', engineStatus.detail, { muted: true });
|
|
847
|
+
console.log(renderHeaderDivider(resolvedThemeMode, { adaptiveText: adaptiveHeaderText }));
|
|
848
|
+
|
|
849
|
+
printLine('应用', `${APP_NAME} v${appMeta.version} (${PACKAGE_NAME})`);
|
|
850
|
+
printLine('作者/许可', `${appMeta.author} | ${appMeta.license}`);
|
|
851
|
+
printLine('仓库', appMeta.repository);
|
|
852
|
+
printLine('根目录', config.rootDir);
|
|
853
|
+
printLine('状态目录', config.stateRoot);
|
|
854
|
+
|
|
855
|
+
const sourceCounts = externalStorageMeta?.sourceCounts || null;
|
|
856
|
+
if (externalStorageRoots.length > 0) {
|
|
857
|
+
if (sourceCounts) {
|
|
858
|
+
printLine(
|
|
859
|
+
'文件存储',
|
|
860
|
+
`共${externalStorageRoots.length}个(默认${sourceCounts.builtin || 0} / 手动${sourceCounts.configured || 0} / 自动${sourceCounts.auto || 0})`
|
|
861
|
+
);
|
|
862
|
+
} else {
|
|
863
|
+
printLine(
|
|
864
|
+
'文件存储',
|
|
865
|
+
`已检测 ${externalStorageRoots.length} 个(含默认/自定义,示例: ${externalStorageRoots[0]})`
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
printLine('文件存储', '未检测到(可在设置里手动添加)');
|
|
870
|
+
}
|
|
871
|
+
if ((sourceCounts?.auto || 0) > 0) {
|
|
872
|
+
printLine('探测提示', '自动探测目录默认不预选,纳入处理前请确认。', { muted: true });
|
|
873
|
+
}
|
|
874
|
+
if ((sourceCounts?.auto || 0) > 0 && (sourceCounts?.builtin || 0) + (sourceCounts?.configured || 0) === 0) {
|
|
875
|
+
printLine('操作建议', '建议在“交互配置 -> 手动追加文件存储根目录”先确认常用路径。', { muted: true });
|
|
876
|
+
}
|
|
877
|
+
if (
|
|
878
|
+
externalStorageMeta &&
|
|
879
|
+
Array.isArray(externalStorageMeta.truncatedRoots) &&
|
|
880
|
+
externalStorageMeta.truncatedRoots.length > 0
|
|
881
|
+
) {
|
|
882
|
+
printLine(
|
|
883
|
+
'探测提示',
|
|
884
|
+
`${externalStorageMeta.truncatedRoots.length} 个搜索根达到扫描预算上限,建议手动补充路径`,
|
|
885
|
+
{ muted: true }
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
if (profileRootHealth?.status === 'missing') {
|
|
889
|
+
printLine('目录提示', '当前 Profile 根目录不存在,请在“交互配置”中修正。', { muted: true });
|
|
890
|
+
} else if (profileRootHealth?.status === 'empty') {
|
|
891
|
+
printLine('目录提示', '当前 Profile 根目录未识别到账号目录。', { muted: true });
|
|
892
|
+
}
|
|
893
|
+
if (
|
|
894
|
+
profileRootHealth &&
|
|
895
|
+
Array.isArray(profileRootHealth.candidates) &&
|
|
896
|
+
profileRootHealth.candidates.length > 0
|
|
897
|
+
) {
|
|
898
|
+
const candidateText = profileRootHealth.candidates
|
|
899
|
+
.slice(0, 3)
|
|
900
|
+
.map((item) => `${item.rootDir} (${item.accountCount}账号)`)
|
|
901
|
+
.join(' ; ');
|
|
902
|
+
printLine('候选目录', candidateText, { muted: true });
|
|
903
|
+
printLine('操作建议', '进入“交互配置 -> Profile 根目录”修改。', { muted: true });
|
|
904
|
+
}
|
|
905
|
+
if (nativeRepairNote) {
|
|
906
|
+
printLine('修复状态', nativeRepairNote, { muted: true });
|
|
907
|
+
}
|
|
908
|
+
console.log(renderHeaderDivider(resolvedThemeMode, { adaptiveText: adaptiveHeaderText }));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function accountTableRows(accounts) {
|
|
912
|
+
return accounts.map((account, idx) => [
|
|
913
|
+
String(idx + 1),
|
|
914
|
+
account.userName,
|
|
915
|
+
account.corpName,
|
|
916
|
+
account.shortId,
|
|
917
|
+
account.isCurrent ? '当前登录' : '-',
|
|
918
|
+
]);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function formatAccountChoiceLabel(account) {
|
|
922
|
+
const terminalWidth = Number(process.stdout.columns || 120);
|
|
923
|
+
const widths = {
|
|
924
|
+
user: Math.max(10, Math.floor(terminalWidth * 0.26)),
|
|
925
|
+
corp: Math.max(14, Math.floor(terminalWidth * 0.32)),
|
|
926
|
+
shortId: 8,
|
|
927
|
+
};
|
|
928
|
+
return `${padToWidth(account.userName, widths.user)} | ${padToWidth(account.corpName, widths.corp)} | ${padToWidth(account.shortId, widths.shortId)}`;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function externalStorageSourceLabel(source) {
|
|
932
|
+
if (source === 'builtin') {
|
|
933
|
+
return '默认';
|
|
934
|
+
}
|
|
935
|
+
if (source === 'configured') {
|
|
936
|
+
return '手动';
|
|
937
|
+
}
|
|
938
|
+
return '自动';
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function normalizeExternalStorageDetection(detectedExternalStorage) {
|
|
942
|
+
if (Array.isArray(detectedExternalStorage)) {
|
|
943
|
+
return {
|
|
944
|
+
roots: detectedExternalStorage,
|
|
945
|
+
meta: {
|
|
946
|
+
rootSources: {},
|
|
947
|
+
sourceCounts: {
|
|
948
|
+
builtin: 0,
|
|
949
|
+
configured: 0,
|
|
950
|
+
auto: detectedExternalStorage.length,
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
if (!detectedExternalStorage || typeof detectedExternalStorage !== 'object') {
|
|
956
|
+
return {
|
|
957
|
+
roots: [],
|
|
958
|
+
meta: {
|
|
959
|
+
rootSources: {},
|
|
960
|
+
sourceCounts: {
|
|
961
|
+
builtin: 0,
|
|
962
|
+
configured: 0,
|
|
963
|
+
auto: 0,
|
|
964
|
+
},
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
roots: Array.isArray(detectedExternalStorage.roots) ? detectedExternalStorage.roots : [],
|
|
970
|
+
meta: detectedExternalStorage.meta || {
|
|
971
|
+
rootSources: {},
|
|
972
|
+
sourceCounts: {
|
|
973
|
+
builtin: 0,
|
|
974
|
+
configured: 0,
|
|
975
|
+
auto: 0,
|
|
976
|
+
},
|
|
977
|
+
},
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function formatExternalStorageChoiceLabel(rootPath, source = 'auto') {
|
|
982
|
+
const terminalWidth = Number(process.stdout.columns || 120);
|
|
983
|
+
const pathWidth = Math.max(24, Math.floor(terminalWidth * 0.66));
|
|
984
|
+
const name = path.basename(rootPath) || 'WXWork_Data';
|
|
985
|
+
return `[${externalStorageSourceLabel(source)}] ${padToWidth(name, 14)} | ${padToWidth(rootPath, pathWidth)}`;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
async function chooseExternalStorageRoots(detectedExternalStorage, modeText, options = {}) {
|
|
989
|
+
const allowBack = Boolean(options.allowBack);
|
|
990
|
+
const showGuide = options.showGuide !== false;
|
|
991
|
+
const guideTitle = options.guideTitle || '文件存储目录范围';
|
|
992
|
+
const guideRows = Array.isArray(options.guideRows)
|
|
993
|
+
? options.guideRows
|
|
994
|
+
: [
|
|
995
|
+
{ label: '默认', value: '已预选默认路径与手动配置路径' },
|
|
996
|
+
{ label: '自动', value: '自动探测目录默认不预选,需显式确认' },
|
|
997
|
+
{ label: '回退', value: allowBack ? '可选“← 返回上一步”' : '当前步骤不支持回退' },
|
|
998
|
+
];
|
|
999
|
+
const normalized = normalizeExternalStorageDetection(detectedExternalStorage);
|
|
1000
|
+
const externalStorageRoots = normalized.roots;
|
|
1001
|
+
const rootSources = normalized.meta?.rootSources || {};
|
|
1002
|
+
|
|
1003
|
+
if (!Array.isArray(externalStorageRoots) || externalStorageRoots.length === 0) {
|
|
1004
|
+
return [];
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
printSection(`文件存储目录(默认/自定义,${modeText})`);
|
|
1008
|
+
if (showGuide) {
|
|
1009
|
+
printGuideBlock(guideTitle, guideRows);
|
|
1010
|
+
}
|
|
1011
|
+
const selected = await askCheckbox({
|
|
1012
|
+
message: '检测到文件存储目录,选择要纳入本次扫描的目录',
|
|
1013
|
+
required: false,
|
|
1014
|
+
allowBack,
|
|
1015
|
+
choices: externalStorageRoots.map((rootPath) => ({
|
|
1016
|
+
name: formatExternalStorageChoiceLabel(rootPath, rootSources[rootPath]),
|
|
1017
|
+
value: rootPath,
|
|
1018
|
+
checked: rootSources[rootPath] !== 'auto',
|
|
1019
|
+
})),
|
|
1020
|
+
});
|
|
1021
|
+
if (isPromptBack(selected)) {
|
|
1022
|
+
return PROMPT_BACK;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const autoSelected = selected.filter((item) => rootSources[item] === 'auto');
|
|
1026
|
+
if (autoSelected.length === 0) {
|
|
1027
|
+
return selected;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const allowAuto = allowBack
|
|
1031
|
+
? await askConfirmWithBack({
|
|
1032
|
+
message: `你勾选了自动探测目录 ${autoSelected.length} 项,可能包含非企业微信目录。确认纳入本次扫描吗?`,
|
|
1033
|
+
default: false,
|
|
1034
|
+
})
|
|
1035
|
+
: await askConfirm({
|
|
1036
|
+
message: `你勾选了自动探测目录 ${autoSelected.length} 项,可能包含非企业微信目录。确认纳入本次扫描吗?`,
|
|
1037
|
+
default: false,
|
|
1038
|
+
});
|
|
1039
|
+
if (isPromptBack(allowAuto)) {
|
|
1040
|
+
return PROMPT_BACK;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
if (allowAuto) {
|
|
1044
|
+
return selected;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
console.log('已取消自动探测目录,仅保留默认/手动路径。');
|
|
1048
|
+
return selected.filter((item) => rootSources[item] !== 'auto');
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function chooseAccounts(accounts, modeText, options = {}) {
|
|
1052
|
+
const allowBack = Boolean(options.allowBack);
|
|
1053
|
+
const showGuide = options.showGuide !== false;
|
|
1054
|
+
const guideTitle = options.guideTitle || '账号范围';
|
|
1055
|
+
const guideRows = Array.isArray(options.guideRows)
|
|
1056
|
+
? options.guideRows
|
|
1057
|
+
: [
|
|
1058
|
+
{ label: '默认', value: '优先选中“当前登录”账号;若无则全选' },
|
|
1059
|
+
{ label: '操作', value: '空格勾选,Enter 确认' },
|
|
1060
|
+
{ label: '目的', value: '缩小扫描范围,提高执行效率' },
|
|
1061
|
+
];
|
|
1062
|
+
if (accounts.length === 0) {
|
|
1063
|
+
console.log('\n未发现可用账号目录。');
|
|
1064
|
+
return [];
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
printSection(`账号选择(${modeText})`);
|
|
1068
|
+
if (showGuide) {
|
|
1069
|
+
printGuideBlock(guideTitle, guideRows);
|
|
1070
|
+
}
|
|
1071
|
+
console.log(renderTable(['序号', '用户名', '企业名', '短ID', '状态'], accountTableRows(accounts)));
|
|
1072
|
+
|
|
1073
|
+
const defaults = accounts.filter((x) => x.isCurrent).map((x) => x.id);
|
|
1074
|
+
const defaultValues = defaults.length > 0 ? defaults : accounts.map((x) => x.id);
|
|
1075
|
+
|
|
1076
|
+
const selected = await askCheckbox({
|
|
1077
|
+
message: '请选择要处理的账号(空格勾选,Enter确认)',
|
|
1078
|
+
required: true,
|
|
1079
|
+
allowBack,
|
|
1080
|
+
choices: accounts.map((account) => ({
|
|
1081
|
+
name: formatAccountChoiceLabel(account),
|
|
1082
|
+
value: account.id,
|
|
1083
|
+
checked: defaultValues.includes(account.id),
|
|
1084
|
+
})),
|
|
1085
|
+
validate: (values) => (values.length > 0 ? true : '至少选择一个账号'),
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
return selected;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function configureMonths(availableMonths, options = {}) {
|
|
1092
|
+
const allowBack = Boolean(options.allowBack);
|
|
1093
|
+
if (availableMonths.length === 0) {
|
|
1094
|
+
return [];
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
printSection('年月筛选(进入清理模式后必须设置)');
|
|
1098
|
+
printGuideBlock('步骤 3/6 · 年月策略', [
|
|
1099
|
+
{ label: '检测', value: `已发现 ${availableMonths.length} 个可选年月` },
|
|
1100
|
+
{ label: '推荐', value: '先按截止年月自动筛选,再按需微调' },
|
|
1101
|
+
{ label: '回退', value: allowBack ? '可选“← 返回上一步”' : '当前步骤不支持回退' },
|
|
1102
|
+
]);
|
|
1103
|
+
|
|
1104
|
+
const mode = await askSelect({
|
|
1105
|
+
message: '请选择筛选方式',
|
|
1106
|
+
default: 'cutoff',
|
|
1107
|
+
allowBack,
|
|
1108
|
+
choices: [
|
|
1109
|
+
{ name: '按截止年月自动筛选(推荐)', value: 'cutoff' },
|
|
1110
|
+
{ name: '手动勾选年月', value: 'manual' },
|
|
1111
|
+
],
|
|
1112
|
+
});
|
|
1113
|
+
if (isPromptBack(mode)) {
|
|
1114
|
+
return PROMPT_BACK;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
if (mode === 'cutoff') {
|
|
1118
|
+
const defaultCutoff = monthByDaysBefore(730);
|
|
1119
|
+
const cutoff = await askInput({
|
|
1120
|
+
message: '请输入截止年月(含此年月,例如 2024-02)',
|
|
1121
|
+
default: defaultCutoff,
|
|
1122
|
+
allowBack,
|
|
1123
|
+
validate: (value) => (normalizeMonthKey(value) ? true : '格式必须是 YYYY-MM,且月份在 01-12'),
|
|
1124
|
+
});
|
|
1125
|
+
if (isPromptBack(cutoff)) {
|
|
1126
|
+
return PROMPT_BACK;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const cutoffKey = normalizeMonthKey(cutoff);
|
|
1130
|
+
let selected = availableMonths.filter((month) => compareMonthKey(month, cutoffKey) <= 0);
|
|
1131
|
+
|
|
1132
|
+
console.log(`自动命中 ${selected.length} 个年月。`);
|
|
1133
|
+
|
|
1134
|
+
const tweak = allowBack
|
|
1135
|
+
? await askConfirmWithBack({
|
|
1136
|
+
message: '是否手动微调月份列表?',
|
|
1137
|
+
default: false,
|
|
1138
|
+
})
|
|
1139
|
+
: await askConfirm({
|
|
1140
|
+
message: '是否手动微调月份列表?',
|
|
1141
|
+
default: false,
|
|
1142
|
+
});
|
|
1143
|
+
if (isPromptBack(tweak)) {
|
|
1144
|
+
return PROMPT_BACK;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if (tweak) {
|
|
1148
|
+
selected = await askCheckbox({
|
|
1149
|
+
message: '微调月份(空格勾选,Enter确认)',
|
|
1150
|
+
required: true,
|
|
1151
|
+
allowBack,
|
|
1152
|
+
choices: availableMonths.map((month) => ({
|
|
1153
|
+
name: month,
|
|
1154
|
+
value: month,
|
|
1155
|
+
checked: selected.includes(month),
|
|
1156
|
+
})),
|
|
1157
|
+
validate: (values) => (values.length > 0 ? true : '至少选择一个年月'),
|
|
1158
|
+
});
|
|
1159
|
+
if (isPromptBack(selected)) {
|
|
1160
|
+
return PROMPT_BACK;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return selected;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const selected = await askCheckbox({
|
|
1168
|
+
message: '手动选择要清理的年月',
|
|
1169
|
+
required: true,
|
|
1170
|
+
allowBack,
|
|
1171
|
+
choices: availableMonths.map((month) => ({
|
|
1172
|
+
name: month,
|
|
1173
|
+
value: month,
|
|
1174
|
+
checked: false,
|
|
1175
|
+
})),
|
|
1176
|
+
validate: (values) => (values.length > 0 ? true : '至少选择一个年月'),
|
|
1177
|
+
});
|
|
1178
|
+
return selected;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function summarizeTargets(targets) {
|
|
1182
|
+
const totalBytes = targets.reduce((acc, item) => acc + Number(item.sizeBytes || 0), 0);
|
|
1183
|
+
const byCategory = new Map();
|
|
1184
|
+
const byAccount = new Map();
|
|
1185
|
+
|
|
1186
|
+
for (const item of targets) {
|
|
1187
|
+
if (!byCategory.has(item.categoryKey)) {
|
|
1188
|
+
byCategory.set(item.categoryKey, { label: item.categoryLabel, sizeBytes: 0, count: 0 });
|
|
1189
|
+
}
|
|
1190
|
+
const cat = byCategory.get(item.categoryKey);
|
|
1191
|
+
cat.sizeBytes += item.sizeBytes;
|
|
1192
|
+
cat.count += 1;
|
|
1193
|
+
|
|
1194
|
+
if (!byAccount.has(item.accountId)) {
|
|
1195
|
+
byAccount.set(item.accountId, {
|
|
1196
|
+
userName: item.userName,
|
|
1197
|
+
corpName: item.corpName,
|
|
1198
|
+
shortId: item.accountShortId,
|
|
1199
|
+
sizeBytes: 0,
|
|
1200
|
+
count: 0,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
const acc = byAccount.get(item.accountId);
|
|
1204
|
+
acc.sizeBytes += item.sizeBytes;
|
|
1205
|
+
acc.count += 1;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
return {
|
|
1209
|
+
totalBytes,
|
|
1210
|
+
byCategory: [...byCategory.values()].sort((a, b) => b.sizeBytes - a.sizeBytes),
|
|
1211
|
+
byAccount: [...byAccount.values()].sort((a, b) => b.sizeBytes - a.sizeBytes),
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
function relativeTargetPath(target) {
|
|
1216
|
+
const parts = [target.accountShortId, target.categoryKey, target.directoryName];
|
|
1217
|
+
return parts.join('/');
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function printTargetPreview(targets) {
|
|
1221
|
+
const rows = targets
|
|
1222
|
+
.slice(0, 40)
|
|
1223
|
+
.map((item, idx) => [
|
|
1224
|
+
String(idx + 1),
|
|
1225
|
+
formatBytes(item.sizeBytes),
|
|
1226
|
+
item.categoryLabel,
|
|
1227
|
+
item.monthKey || '非月份目录',
|
|
1228
|
+
item.accountShortId,
|
|
1229
|
+
relativeTargetPath(item),
|
|
1230
|
+
]);
|
|
1231
|
+
|
|
1232
|
+
console.log(renderTable(['#', '大小', '类型', '月份/目录', '账号', '路径'], rows));
|
|
1233
|
+
|
|
1234
|
+
if (targets.length > 40) {
|
|
1235
|
+
console.log(`... 仅展示前 40 项,实际 ${targets.length} 项。`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function governanceTierLabel(tier) {
|
|
1240
|
+
return SPACE_GOVERNANCE_TIER_LABELS.get(tier) || tier;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function governanceTierRank(tier) {
|
|
1244
|
+
if (tier === SPACE_GOVERNANCE_TIERS.SAFE) {
|
|
1245
|
+
return 1;
|
|
1246
|
+
}
|
|
1247
|
+
if (tier === SPACE_GOVERNANCE_TIERS.CAUTION) {
|
|
1248
|
+
return 2;
|
|
1249
|
+
}
|
|
1250
|
+
return 3;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function formatIdleDaysText(idleDays) {
|
|
1254
|
+
if (!Number.isFinite(idleDays)) {
|
|
1255
|
+
return '-';
|
|
1256
|
+
}
|
|
1257
|
+
if (idleDays < 1) {
|
|
1258
|
+
return '<1天';
|
|
1259
|
+
}
|
|
1260
|
+
return `${Math.floor(idleDays)}天`;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function formatGovernancePath(target, dataRoot) {
|
|
1264
|
+
if (target.accountId) {
|
|
1265
|
+
if (target.accountPath && target.path.startsWith(target.accountPath)) {
|
|
1266
|
+
const rel = path.relative(target.accountPath, target.path);
|
|
1267
|
+
if (rel) {
|
|
1268
|
+
return `${target.accountShortId}/${rel}`;
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return `${target.accountShortId}/${target.targetKey}`;
|
|
1272
|
+
}
|
|
1273
|
+
if (dataRoot && target.path.startsWith(dataRoot)) {
|
|
1274
|
+
const rel = path.relative(dataRoot, target.path);
|
|
1275
|
+
return rel || path.basename(target.path);
|
|
1276
|
+
}
|
|
1277
|
+
return target.path;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function summarizeGovernanceTargets(targets) {
|
|
1281
|
+
const byTier = new Map();
|
|
1282
|
+
let totalBytes = 0;
|
|
1283
|
+
for (const item of targets) {
|
|
1284
|
+
totalBytes += Number(item.sizeBytes || 0);
|
|
1285
|
+
if (!byTier.has(item.tier)) {
|
|
1286
|
+
byTier.set(item.tier, {
|
|
1287
|
+
tier: item.tier,
|
|
1288
|
+
count: 0,
|
|
1289
|
+
suggestedCount: 0,
|
|
1290
|
+
sizeBytes: 0,
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
const row = byTier.get(item.tier);
|
|
1294
|
+
row.count += 1;
|
|
1295
|
+
row.sizeBytes += item.sizeBytes;
|
|
1296
|
+
row.suggestedCount += item.suggested ? 1 : 0;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
return {
|
|
1300
|
+
totalBytes,
|
|
1301
|
+
byTier: [...byTier.values()].sort((a, b) => governanceTierRank(a.tier) - governanceTierRank(b.tier)),
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function printGovernancePreview({ targets, dataRoot }) {
|
|
1306
|
+
const rows = targets
|
|
1307
|
+
.slice(0, 50)
|
|
1308
|
+
.map((item, idx) => [
|
|
1309
|
+
String(idx + 1),
|
|
1310
|
+
governanceTierLabel(item.tier),
|
|
1311
|
+
item.suggested ? '建议' : '-',
|
|
1312
|
+
formatBytes(item.sizeBytes),
|
|
1313
|
+
formatIdleDaysText(item.idleDays),
|
|
1314
|
+
item.accountShortId || '-',
|
|
1315
|
+
trimToWidth(item.targetLabel, 20),
|
|
1316
|
+
trimToWidth(formatGovernancePath(item, dataRoot), 40),
|
|
1317
|
+
]);
|
|
1318
|
+
|
|
1319
|
+
console.log(renderTable(['#', '层级', '建议', '大小', '静置', '账号', '目标', '路径'], rows));
|
|
1320
|
+
if (targets.length > 50) {
|
|
1321
|
+
console.log(`... 仅展示前 50 项,实际 ${targets.length} 项。`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async function runCleanupMode(context) {
|
|
1326
|
+
const { config, aliases, nativeCorePath } = context;
|
|
1327
|
+
|
|
1328
|
+
const accounts = await discoverAccounts(config.rootDir, aliases);
|
|
1329
|
+
const detectedExternalStorage = await detectExternalStorageRoots({
|
|
1330
|
+
configuredRoots: config.externalStorageRoots,
|
|
1331
|
+
profilesRoot: config.rootDir,
|
|
1332
|
+
autoDetect: config.externalStorageAutoDetect !== false,
|
|
1333
|
+
returnMeta: true,
|
|
1334
|
+
});
|
|
1335
|
+
const allCategoryKeys = CACHE_CATEGORIES.map((x) => x.key);
|
|
1336
|
+
|
|
1337
|
+
let selectedAccountIds = [];
|
|
1338
|
+
let selectedExternalStorageRoots = [];
|
|
1339
|
+
let selectedMonths = [];
|
|
1340
|
+
let selectedCategories = [];
|
|
1341
|
+
let includeNonMonthDirs = false;
|
|
1342
|
+
let dryRun = Boolean(config.dryRunDefault);
|
|
1343
|
+
|
|
1344
|
+
let step = 0;
|
|
1345
|
+
while (step < 6) {
|
|
1346
|
+
if (step === 0) {
|
|
1347
|
+
const selected = await chooseAccounts(accounts, '年月清理', {
|
|
1348
|
+
allowBack: false,
|
|
1349
|
+
guideTitle: '步骤 1/6 · 账号范围',
|
|
1350
|
+
});
|
|
1351
|
+
if (!Array.isArray(selected) || selected.length === 0) {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
selectedAccountIds = selected;
|
|
1355
|
+
step = 1;
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (step === 1) {
|
|
1360
|
+
const selected = await chooseExternalStorageRoots(detectedExternalStorage, '年月清理', {
|
|
1361
|
+
allowBack: true,
|
|
1362
|
+
guideTitle: '步骤 2/6 · 文件存储目录范围',
|
|
1363
|
+
});
|
|
1364
|
+
if (isPromptBack(selected)) {
|
|
1365
|
+
step = Math.max(0, step - 1);
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
selectedExternalStorageRoots = Array.isArray(selected) ? selected : [];
|
|
1369
|
+
step = 2;
|
|
1370
|
+
continue;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (step === 2) {
|
|
1374
|
+
const availableMonths = await collectAvailableMonths(
|
|
1375
|
+
accounts,
|
|
1376
|
+
selectedAccountIds,
|
|
1377
|
+
allCategoryKeys,
|
|
1378
|
+
selectedExternalStorageRoots
|
|
1379
|
+
);
|
|
1380
|
+
|
|
1381
|
+
if (availableMonths.length === 0) {
|
|
1382
|
+
console.log('\n未发现按年月分组的缓存目录。你仍可清理非月份目录。');
|
|
1383
|
+
selectedMonths = [];
|
|
1384
|
+
step = 3;
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const selected = await configureMonths(availableMonths, { allowBack: true });
|
|
1389
|
+
if (isPromptBack(selected)) {
|
|
1390
|
+
step = Math.max(0, step - 1);
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
selectedMonths = Array.isArray(selected) ? selected : [];
|
|
1394
|
+
step = 3;
|
|
1395
|
+
continue;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (step === 3) {
|
|
1399
|
+
printSection('缓存类型筛选');
|
|
1400
|
+
printGuideBlock('步骤 4/6 · 缓存类型', [
|
|
1401
|
+
{ label: '范围', value: '按类型限制清理目标,降低误删风险' },
|
|
1402
|
+
{ label: '建议', value: '默认推荐项已预选,可按需调整' },
|
|
1403
|
+
{ label: '回退', value: '可选“← 返回上一步”' },
|
|
1404
|
+
]);
|
|
1405
|
+
const selected = await askCheckbox({
|
|
1406
|
+
message: '选择要清理的缓存类型',
|
|
1407
|
+
required: true,
|
|
1408
|
+
allowBack: true,
|
|
1409
|
+
choices: categoryChoices(config.defaultCategories),
|
|
1410
|
+
validate: (values) => (values.length > 0 ? true : '至少选择一个类型'),
|
|
1411
|
+
});
|
|
1412
|
+
if (isPromptBack(selected)) {
|
|
1413
|
+
step = Math.max(0, step - 1);
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
selectedCategories = selected;
|
|
1417
|
+
step = 4;
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
if (step === 4) {
|
|
1422
|
+
printSection('目录粒度策略');
|
|
1423
|
+
printGuideBlock('步骤 5/6 · 非月份目录策略', [
|
|
1424
|
+
{ label: '含义', value: '非月份目录常见于临时目录、数字目录等' },
|
|
1425
|
+
{ label: '风险', value: '勾选后命中范围会更大,请先预览' },
|
|
1426
|
+
{ label: '回退', value: '可选“← 返回上一步”' },
|
|
1427
|
+
]);
|
|
1428
|
+
const selected = await askConfirmWithBack({
|
|
1429
|
+
message: '是否包含非月份目录(如数字目录、临时目录)?',
|
|
1430
|
+
default: false,
|
|
1431
|
+
});
|
|
1432
|
+
if (isPromptBack(selected)) {
|
|
1433
|
+
step = Math.max(0, step - 1);
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
includeNonMonthDirs = selected;
|
|
1437
|
+
step = 5;
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
printSection('执行方式');
|
|
1442
|
+
printGuideBlock('步骤 6/6 · 预览与执行', [
|
|
1443
|
+
{ label: 'dry-run', value: '只预览命中结果,不执行删除' },
|
|
1444
|
+
{ label: '真实删', value: '移动到程序回收区,可按批次恢复' },
|
|
1445
|
+
{ label: '回退', value: '可选“← 返回上一步”' },
|
|
1446
|
+
]);
|
|
1447
|
+
const selected = await askConfirmWithBack({
|
|
1448
|
+
message: '先 dry-run 预览(不执行删除)?',
|
|
1449
|
+
default: Boolean(config.dryRunDefault),
|
|
1450
|
+
});
|
|
1451
|
+
if (isPromptBack(selected)) {
|
|
1452
|
+
step = Math.max(0, step - 1);
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
dryRun = selected;
|
|
1456
|
+
step = 6;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
printSection('向导配置确认');
|
|
1460
|
+
const categoryLabelByKey = new Map(CACHE_CATEGORIES.map((cat) => [cat.key, cat.label]));
|
|
1461
|
+
const selectedCategoryText = selectedCategories.map((key) => categoryLabelByKey.get(key) || key).join('、');
|
|
1462
|
+
printGuideBlock('即将执行的配置', [
|
|
1463
|
+
{ label: '账号', value: `${selectedAccountIds.length} 个` },
|
|
1464
|
+
{
|
|
1465
|
+
label: '年月',
|
|
1466
|
+
value: selectedMonths.length > 0 ? `${selectedMonths.length} 个` : '不过滤(仅非月份或按类型命中)',
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
label: '类型',
|
|
1470
|
+
value: selectedCategoryText || '-',
|
|
1471
|
+
},
|
|
1472
|
+
{
|
|
1473
|
+
label: '文件存储',
|
|
1474
|
+
value:
|
|
1475
|
+
selectedExternalStorageRoots.length > 0
|
|
1476
|
+
? `${selectedExternalStorageRoots.length} 个目录`
|
|
1477
|
+
: '未纳入额外文件存储目录',
|
|
1478
|
+
},
|
|
1479
|
+
{ label: '非月份', value: includeNonMonthDirs ? '包含' : '不包含' },
|
|
1480
|
+
{ label: '模式', value: dryRun ? 'dry-run(预览)' : '真实删除(回收区)' },
|
|
1481
|
+
]);
|
|
1482
|
+
|
|
1483
|
+
printSection('扫描目录并计算大小');
|
|
1484
|
+
const scan = await collectCleanupTargets({
|
|
1485
|
+
accounts,
|
|
1486
|
+
selectedAccountIds,
|
|
1487
|
+
categoryKeys: selectedCategories,
|
|
1488
|
+
monthFilters: selectedMonths,
|
|
1489
|
+
includeNonMonthDirs,
|
|
1490
|
+
externalStorageRoots: selectedExternalStorageRoots,
|
|
1491
|
+
nativeCorePath,
|
|
1492
|
+
onProgress: (current, total) => printProgress('计算目录大小', current, total),
|
|
1493
|
+
});
|
|
1494
|
+
const targets = scan.targets;
|
|
1495
|
+
context.lastRunEngineUsed = scan.engineUsed;
|
|
1496
|
+
|
|
1497
|
+
if (targets.length === 0) {
|
|
1498
|
+
console.log('没有匹配到可清理目录。');
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const summary = summarizeTargets(targets);
|
|
1503
|
+
|
|
1504
|
+
printSection('清理预览');
|
|
1505
|
+
console.log(`匹配目录: ${targets.length}`);
|
|
1506
|
+
console.log(`预计释放: ${formatBytes(summary.totalBytes)}`);
|
|
1507
|
+
console.log(`扫描引擎: ${scan.engineUsed === 'zig' ? 'Zig核心' : 'Node引擎'}`);
|
|
1508
|
+
if (scan.nativeFallbackReason) {
|
|
1509
|
+
console.log(`引擎提示: ${scan.nativeFallbackReason}`);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
if (summary.byAccount.length > 0) {
|
|
1513
|
+
console.log('\n按账号汇总:');
|
|
1514
|
+
const rows = summary.byAccount.map((row) => [
|
|
1515
|
+
row.userName,
|
|
1516
|
+
row.corpName,
|
|
1517
|
+
row.shortId,
|
|
1518
|
+
String(row.count),
|
|
1519
|
+
formatBytes(row.sizeBytes),
|
|
1520
|
+
]);
|
|
1521
|
+
console.log(renderTable(['用户名', '企业名', '短ID', '目录数', '大小'], rows));
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
if (summary.byCategory.length > 0) {
|
|
1525
|
+
console.log('\n按类型汇总:');
|
|
1526
|
+
const rows = summary.byCategory.map((row) => [row.label, String(row.count), formatBytes(row.sizeBytes)]);
|
|
1527
|
+
console.log(renderTable(['类型', '目录数', '大小'], rows));
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
console.log('\n命中目录明细:');
|
|
1531
|
+
printTargetPreview(targets);
|
|
1532
|
+
|
|
1533
|
+
let executeDryRun = dryRun;
|
|
1534
|
+
if (dryRun) {
|
|
1535
|
+
const continueDelete = await askConfirm({
|
|
1536
|
+
message: '当前为 dry-run 预览。是否继续执行真实删除(移动到程序回收区)?',
|
|
1537
|
+
default: false,
|
|
1538
|
+
});
|
|
1539
|
+
if (!continueDelete) {
|
|
1540
|
+
console.log('已结束:仅预览,无删除。');
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
executeDryRun = false;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const confirmText = await askInput({
|
|
1547
|
+
message: `将删除 ${targets.length} 项并移动到回收区,请输入 DELETE 确认`,
|
|
1548
|
+
validate: (value) => (value === 'DELETE' ? true : '请输入大写 DELETE'),
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
if (confirmText !== 'DELETE') {
|
|
1552
|
+
console.log('未确认,已取消。');
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
printSection('开始删除(移动到回收区)');
|
|
1557
|
+
const result = await executeCleanup({
|
|
1558
|
+
targets,
|
|
1559
|
+
recycleRoot: config.recycleRoot,
|
|
1560
|
+
indexPath: config.indexPath,
|
|
1561
|
+
dryRun: executeDryRun,
|
|
1562
|
+
onProgress: (current, total) => printProgress('移动目录', current, total),
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
printSection('删除结果');
|
|
1566
|
+
printGuideBlock('结果摘要', [
|
|
1567
|
+
{ label: '批次', value: result.batchId },
|
|
1568
|
+
{ label: '模式', value: executeDryRun ? 'dry-run(预览)' : '真实删除(回收区)' },
|
|
1569
|
+
{ label: '成功', value: `${result.successCount} 项` },
|
|
1570
|
+
{ label: '跳过', value: `${result.skippedCount} 项` },
|
|
1571
|
+
{ label: '失败', value: `${result.failedCount} 项` },
|
|
1572
|
+
{ label: '释放', value: formatBytes(result.reclaimedBytes) },
|
|
1573
|
+
]);
|
|
1574
|
+
if (result.failedCount > 0) {
|
|
1575
|
+
printGuideBlock('结果建议', [{ label: '建议', value: '请查看失败明细,修复路径或权限后重试。' }]);
|
|
1576
|
+
}
|
|
1577
|
+
console.log(`批次ID : ${result.batchId}`);
|
|
1578
|
+
console.log(`成功数量 : ${result.successCount}`);
|
|
1579
|
+
console.log(`跳过数量 : ${result.skippedCount}`);
|
|
1580
|
+
console.log(`失败数量 : ${result.failedCount}`);
|
|
1581
|
+
console.log(`释放体积 : ${formatBytes(result.reclaimedBytes)}`);
|
|
1582
|
+
|
|
1583
|
+
await printErrorDiagnostics(result.errors, {
|
|
1584
|
+
defaultLimit: 5,
|
|
1585
|
+
headers: ['路径', '错误'],
|
|
1586
|
+
mapRow: (item) => [item.path || '-', item.message || '-'],
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
async function runAnalysisMode(context) {
|
|
1591
|
+
const { config, aliases, nativeCorePath } = context;
|
|
1592
|
+
const accounts = await discoverAccounts(config.rootDir, aliases);
|
|
1593
|
+
const detectedExternalStorage = await detectExternalStorageRoots({
|
|
1594
|
+
configuredRoots: config.externalStorageRoots,
|
|
1595
|
+
profilesRoot: config.rootDir,
|
|
1596
|
+
autoDetect: config.externalStorageAutoDetect !== false,
|
|
1597
|
+
returnMeta: true,
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
const selectedAccountIds = await chooseAccounts(accounts, '会话分析(只读)', {
|
|
1601
|
+
guideTitle: '步骤 1/3 · 账号范围',
|
|
1602
|
+
guideRows: [
|
|
1603
|
+
{ label: '模式', value: '只读分析,不执行删除' },
|
|
1604
|
+
{ label: '范围', value: '建议先聚焦常用账号,避免分析结果过载' },
|
|
1605
|
+
{ label: '操作', value: '空格勾选,Enter 确认' },
|
|
1606
|
+
],
|
|
1607
|
+
});
|
|
1608
|
+
if (selectedAccountIds.length === 0) {
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
const selectedExternalStorageRoots = await chooseExternalStorageRoots(
|
|
1612
|
+
detectedExternalStorage,
|
|
1613
|
+
'会话分析(只读)',
|
|
1614
|
+
{
|
|
1615
|
+
guideTitle: '步骤 2/3 · 文件存储目录范围',
|
|
1616
|
+
guideRows: [
|
|
1617
|
+
{ label: '默认', value: '默认/手动目录已预选' },
|
|
1618
|
+
{ label: '自动', value: '自动探测目录建议按需纳入' },
|
|
1619
|
+
{ label: '提示', value: '分析仅统计体积,不做移动或删除' },
|
|
1620
|
+
],
|
|
1621
|
+
}
|
|
1622
|
+
);
|
|
1623
|
+
|
|
1624
|
+
printSection('分析范围设置');
|
|
1625
|
+
printGuideBlock('步骤 3/3 · 缓存类型范围', [
|
|
1626
|
+
{ label: '建议', value: '可先全选做全景盘点,再聚焦热点类型' },
|
|
1627
|
+
{ label: '安全', value: '当前流程只读,不写入数据目录' },
|
|
1628
|
+
{ label: '确认', value: '空格勾选,Enter 确认' },
|
|
1629
|
+
]);
|
|
1630
|
+
const selectedCategories = await askCheckbox({
|
|
1631
|
+
message: '选择分析范围(缓存类型)',
|
|
1632
|
+
required: true,
|
|
1633
|
+
choices: categoryChoices(config.defaultCategories, { includeAllByDefault: true }),
|
|
1634
|
+
validate: (values) => (values.length > 0 ? true : '至少选择一个类型'),
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
printSection('分析中');
|
|
1638
|
+
const result = await analyzeCacheFootprint({
|
|
1639
|
+
accounts,
|
|
1640
|
+
selectedAccountIds,
|
|
1641
|
+
categoryKeys: selectedCategories,
|
|
1642
|
+
externalStorageRoots: selectedExternalStorageRoots,
|
|
1643
|
+
nativeCorePath,
|
|
1644
|
+
onProgress: (current, total) => printProgress('分析目录', current, total),
|
|
1645
|
+
});
|
|
1646
|
+
context.lastRunEngineUsed = result.engineUsed;
|
|
1647
|
+
|
|
1648
|
+
printSection('分析结果(只读)');
|
|
1649
|
+
printAnalysisSummary(result);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
async function runSpaceGovernanceMode(context) {
|
|
1653
|
+
const { config, aliases, nativeCorePath } = context;
|
|
1654
|
+
const accounts = await discoverAccounts(config.rootDir, aliases);
|
|
1655
|
+
const detectedExternalStorage = await detectExternalStorageRoots({
|
|
1656
|
+
configuredRoots: config.externalStorageRoots,
|
|
1657
|
+
profilesRoot: config.rootDir,
|
|
1658
|
+
autoDetect: config.externalStorageAutoDetect !== false,
|
|
1659
|
+
returnMeta: true,
|
|
1660
|
+
});
|
|
1661
|
+
let selectedAccountIds = [];
|
|
1662
|
+
|
|
1663
|
+
if (accounts.length > 0) {
|
|
1664
|
+
selectedAccountIds = await chooseAccounts(accounts, '全量空间治理(账号相关目录)', {
|
|
1665
|
+
guideTitle: '治理步骤 1/5 · 账号范围',
|
|
1666
|
+
guideRows: [
|
|
1667
|
+
{ label: '目标', value: '限定账号相关目录,避免无关扫描' },
|
|
1668
|
+
{ label: '建议', value: '优先处理当前登录账号与高占用账号' },
|
|
1669
|
+
{ label: '操作', value: '空格勾选,Enter 确认' },
|
|
1670
|
+
],
|
|
1671
|
+
});
|
|
1672
|
+
} else {
|
|
1673
|
+
printSection('账号范围(全量治理)');
|
|
1674
|
+
printGuideBlock('治理步骤 1/5 · 账号范围', [
|
|
1675
|
+
{ label: '状态', value: '未识别账号目录,将仅处理容器级治理目标' },
|
|
1676
|
+
{ label: '影响', value: '不会阻塞治理,可继续执行' },
|
|
1677
|
+
]);
|
|
1678
|
+
}
|
|
1679
|
+
const selectedExternalStorageRoots = await chooseExternalStorageRoots(
|
|
1680
|
+
detectedExternalStorage,
|
|
1681
|
+
'全量空间治理',
|
|
1682
|
+
{
|
|
1683
|
+
guideTitle: '治理步骤 2/5 · 文件存储目录范围',
|
|
1684
|
+
guideRows: [
|
|
1685
|
+
{ label: '策略', value: '默认/手动目录预选,自动探测目录需确认' },
|
|
1686
|
+
{ label: '风险', value: '目录越多,命中范围越大,请结合预览确认' },
|
|
1687
|
+
{ label: '目标', value: '仅纳入确认为企业微信缓存的路径' },
|
|
1688
|
+
],
|
|
1689
|
+
}
|
|
1690
|
+
);
|
|
1691
|
+
|
|
1692
|
+
printSection('扫描治理目录并计算大小');
|
|
1693
|
+
printGuideBlock('治理步骤 3/5 · 扫描与建议', [
|
|
1694
|
+
{ label: '分级', value: '按安全层/谨慎层/受保护层分类' },
|
|
1695
|
+
{ label: '建议', value: '默认建议阈值:体积 + 静置天数' },
|
|
1696
|
+
{ label: '保障', value: '仅生成候选列表,不会立即删除' },
|
|
1697
|
+
]);
|
|
1698
|
+
const scan = await scanSpaceGovernanceTargets({
|
|
1699
|
+
accounts,
|
|
1700
|
+
selectedAccountIds,
|
|
1701
|
+
rootDir: config.rootDir,
|
|
1702
|
+
externalStorageRoots: selectedExternalStorageRoots,
|
|
1703
|
+
nativeCorePath,
|
|
1704
|
+
autoSuggest: config.spaceGovernance?.autoSuggest,
|
|
1705
|
+
onProgress: (current, total) => printProgress('扫描治理目录', current, total),
|
|
1706
|
+
});
|
|
1707
|
+
context.lastRunEngineUsed = scan.engineUsed;
|
|
1708
|
+
|
|
1709
|
+
if (scan.targets.length === 0) {
|
|
1710
|
+
console.log('未发现可治理目录。');
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const summary = summarizeGovernanceTargets(scan.targets);
|
|
1715
|
+
const governanceRuleCount = SPACE_GOVERNANCE_TARGETS.length + selectedExternalStorageRoots.length;
|
|
1716
|
+
printSection('全量空间治理预览');
|
|
1717
|
+
console.log(`治理规则数: ${governanceRuleCount}`);
|
|
1718
|
+
console.log(`匹配目录数: ${scan.targets.length}`);
|
|
1719
|
+
console.log(`预计涉及: ${formatBytes(summary.totalBytes)}`);
|
|
1720
|
+
console.log(`建议阈值: >= ${scan.suggestSizeThresholdMB}MB 且静置 >= ${scan.suggestIdleDays}天`);
|
|
1721
|
+
console.log(`扫描引擎: ${scan.engineUsed === 'zig' ? 'Zig核心' : 'Node引擎'}`);
|
|
1722
|
+
if (scan.nativeFallbackReason) {
|
|
1723
|
+
console.log(`引擎提示: ${scan.nativeFallbackReason}`);
|
|
1724
|
+
}
|
|
1725
|
+
if (scan.dataRoot) {
|
|
1726
|
+
console.log(`数据根目录: ${scan.dataRoot}`);
|
|
1727
|
+
} else {
|
|
1728
|
+
console.log('数据根目录: 未识别(仅扫描账号目录相关目标)');
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
const byTierRows = summary.byTier.map((row) => [
|
|
1732
|
+
governanceTierLabel(row.tier),
|
|
1733
|
+
String(row.count),
|
|
1734
|
+
String(row.suggestedCount),
|
|
1735
|
+
formatBytes(row.sizeBytes),
|
|
1736
|
+
]);
|
|
1737
|
+
console.log('\n按层级汇总:');
|
|
1738
|
+
console.log(renderTable(['层级', '目录数', '建议数', '大小'], byTierRows));
|
|
1739
|
+
|
|
1740
|
+
console.log('\n命中目录明细:');
|
|
1741
|
+
printGovernancePreview({ targets: scan.targets, dataRoot: scan.dataRoot });
|
|
1742
|
+
|
|
1743
|
+
const selectableTargets = scan.targets.filter((item) => item.deletable);
|
|
1744
|
+
if (selectableTargets.length === 0) {
|
|
1745
|
+
console.log('\n当前仅发现受保护目录,已结束(只读分析)。');
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
printSection('治理目标选择');
|
|
1750
|
+
printGuideBlock('治理步骤 4/5 · 选择目录', [
|
|
1751
|
+
{ label: '预选', value: '建议项默认预选,可手动调整' },
|
|
1752
|
+
{ label: '层级', value: '谨慎层会触发二次确认' },
|
|
1753
|
+
{ label: '确认', value: '空格勾选,Enter 确认' },
|
|
1754
|
+
]);
|
|
1755
|
+
const lastSelected = new Set(config.spaceGovernance?.lastSelectedTargets || []);
|
|
1756
|
+
const selectedIds = await askCheckbox({
|
|
1757
|
+
message: '选择要治理的目录(建议项会预选)',
|
|
1758
|
+
required: true,
|
|
1759
|
+
choices: selectableTargets.map((item) => ({
|
|
1760
|
+
name: `${item.suggested ? '[建议] ' : ''}[${governanceTierLabel(item.tier)}] ${item.targetLabel} | ${item.accountShortId} | ${formatBytes(item.sizeBytes)} | 静置${formatIdleDaysText(item.idleDays)} | ${trimToWidth(formatGovernancePath(item, scan.dataRoot), 36)}`,
|
|
1761
|
+
value: item.id,
|
|
1762
|
+
checked:
|
|
1763
|
+
lastSelected.size > 0
|
|
1764
|
+
? lastSelected.has(item.id)
|
|
1765
|
+
: item.suggested && item.tier === SPACE_GOVERNANCE_TIERS.SAFE,
|
|
1766
|
+
})),
|
|
1767
|
+
validate: (values) => (values.length > 0 ? true : '至少选择一个目录'),
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
const selectedTargets = selectableTargets.filter((item) => selectedIds.includes(item.id));
|
|
1771
|
+
if (selectedTargets.length === 0) {
|
|
1772
|
+
console.log('未选择任何目标。');
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const cautionTargets = selectedTargets.filter((item) => item.tier === SPACE_GOVERNANCE_TIERS.CAUTION);
|
|
1777
|
+
if (cautionTargets.length > 0) {
|
|
1778
|
+
const continueCaution = await askConfirm({
|
|
1779
|
+
message: `已选择谨慎层目录 ${cautionTargets.length} 项,可能导致部分缓存重新建立。继续吗?`,
|
|
1780
|
+
default: false,
|
|
1781
|
+
});
|
|
1782
|
+
if (!continueCaution) {
|
|
1783
|
+
console.log('已取消执行。');
|
|
1784
|
+
return;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const recentTargets = selectedTargets.filter((item) => item.recentlyActive);
|
|
1789
|
+
let allowRecentActive = false;
|
|
1790
|
+
if (recentTargets.length > 0) {
|
|
1791
|
+
allowRecentActive = await askConfirm({
|
|
1792
|
+
message: `有 ${recentTargets.length} 项在最近 ${scan.suggestIdleDays} 天内仍有写入,默认会跳过。是否允许继续处理这些活跃目录?`,
|
|
1793
|
+
default: false,
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
config.spaceGovernance = config.spaceGovernance || {};
|
|
1798
|
+
config.spaceGovernance.lastSelectedTargets = selectedIds;
|
|
1799
|
+
await saveConfig(config);
|
|
1800
|
+
|
|
1801
|
+
printSection('执行确认');
|
|
1802
|
+
printGuideBlock('治理步骤 5/5 · 执行策略', [
|
|
1803
|
+
{ label: '目标', value: `已选 ${selectedTargets.length} 项(谨慎层 ${cautionTargets.length} 项)` },
|
|
1804
|
+
{ label: '模式', value: '可先 dry-run,再决定是否真实治理' },
|
|
1805
|
+
{ label: '保护', value: '真实治理包含冷静期 + CLEAN 确认词' },
|
|
1806
|
+
]);
|
|
1807
|
+
const dryRun = await askConfirm({
|
|
1808
|
+
message: '先 dry-run 预览(不执行删除)?',
|
|
1809
|
+
default: Boolean(config.dryRunDefault),
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
if (!dryRun) {
|
|
1813
|
+
const executeConfirm = await askConfirm({
|
|
1814
|
+
message: `即将处理 ${selectedTargets.length} 项,是否继续?`,
|
|
1815
|
+
default: false,
|
|
1816
|
+
});
|
|
1817
|
+
if (!executeConfirm) {
|
|
1818
|
+
console.log('已取消执行。');
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
const cooldownSeconds = Math.max(1, Number(config.spaceGovernance?.cooldownSeconds || 5));
|
|
1823
|
+
for (let left = cooldownSeconds; left >= 1; left -= 1) {
|
|
1824
|
+
process.stdout.write(`\r安全冷静期: ${left}s...`);
|
|
1825
|
+
await sleep(1000);
|
|
1826
|
+
}
|
|
1827
|
+
process.stdout.write('\n');
|
|
1828
|
+
|
|
1829
|
+
const confirmText = await askInput({
|
|
1830
|
+
message: '请输入 CLEAN 确认执行治理删除',
|
|
1831
|
+
validate: (value) => (value === 'CLEAN' ? true : '请输入大写 CLEAN'),
|
|
1832
|
+
});
|
|
1833
|
+
if (confirmText !== 'CLEAN') {
|
|
1834
|
+
console.log('未确认,已取消。');
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
printSection('开始全量空间治理');
|
|
1840
|
+
const result = await executeCleanup({
|
|
1841
|
+
targets: selectedTargets,
|
|
1842
|
+
recycleRoot: config.recycleRoot,
|
|
1843
|
+
indexPath: config.indexPath,
|
|
1844
|
+
dryRun,
|
|
1845
|
+
scope: MODES.SPACE_GOVERNANCE,
|
|
1846
|
+
shouldSkip: (target) => {
|
|
1847
|
+
if (!target.deletable) {
|
|
1848
|
+
return 'skipped_policy_protected';
|
|
1849
|
+
}
|
|
1850
|
+
if (target.recentlyActive && !allowRecentActive) {
|
|
1851
|
+
return 'skipped_recently_active';
|
|
1852
|
+
}
|
|
1853
|
+
return null;
|
|
1854
|
+
},
|
|
1855
|
+
onProgress: (current, total) => printProgress('治理目录', current, total),
|
|
1856
|
+
});
|
|
1857
|
+
|
|
1858
|
+
printSection('治理结果');
|
|
1859
|
+
printGuideBlock('结果摘要', [
|
|
1860
|
+
{ label: '批次', value: result.batchId },
|
|
1861
|
+
{ label: '模式', value: dryRun ? 'dry-run(预览)' : '真实治理(回收区)' },
|
|
1862
|
+
{ label: '成功', value: `${result.successCount} 项` },
|
|
1863
|
+
{ label: '跳过', value: `${result.skippedCount} 项` },
|
|
1864
|
+
{ label: '失败', value: `${result.failedCount} 项` },
|
|
1865
|
+
{ label: '释放', value: formatBytes(result.reclaimedBytes) },
|
|
1866
|
+
]);
|
|
1867
|
+
if (result.failedCount > 0 || result.skippedCount > 0) {
|
|
1868
|
+
printGuideBlock('结果建议', [
|
|
1869
|
+
{
|
|
1870
|
+
label: '建议',
|
|
1871
|
+
value: '建议复核“跳过/失败”明细,确认是否需要放宽活跃目录策略后重试。',
|
|
1872
|
+
},
|
|
1873
|
+
]);
|
|
1874
|
+
}
|
|
1875
|
+
console.log(`批次ID : ${result.batchId}`);
|
|
1876
|
+
console.log(`成功数量 : ${result.successCount}`);
|
|
1877
|
+
console.log(`跳过数量 : ${result.skippedCount}`);
|
|
1878
|
+
console.log(`失败数量 : ${result.failedCount}`);
|
|
1879
|
+
console.log(`释放体积 : ${formatBytes(result.reclaimedBytes)}`);
|
|
1880
|
+
|
|
1881
|
+
await printErrorDiagnostics(result.errors, {
|
|
1882
|
+
defaultLimit: 5,
|
|
1883
|
+
headers: ['路径', '错误'],
|
|
1884
|
+
mapRow: (item) => [item.path || '-', item.message || '-'],
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function batchTableRows(batches) {
|
|
1889
|
+
return batches.map((batch, idx) => [
|
|
1890
|
+
String(idx + 1),
|
|
1891
|
+
batch.batchId,
|
|
1892
|
+
formatLocalDate(batch.firstTime),
|
|
1893
|
+
String(batch.entries.length),
|
|
1894
|
+
formatBytes(batch.totalBytes),
|
|
1895
|
+
]);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
async function askConflictResolution(conflict) {
|
|
1899
|
+
const scope = String(conflict?.entry?.scope || MODES.CLEANUP_MONTHLY);
|
|
1900
|
+
const scopeText = scope === MODES.SPACE_GOVERNANCE ? '全量治理批次' : '年月清理批次';
|
|
1901
|
+
const targetPathText = trimToWidth(String(conflict?.originalPath || '-'), 66);
|
|
1902
|
+
const recyclePathText = trimToWidth(String(conflict?.recyclePath || '-'), 66);
|
|
1903
|
+
const sizeText = formatBytes(Number(conflict?.entry?.sizeBytes || 0));
|
|
1904
|
+
|
|
1905
|
+
printSection('恢复冲突处理');
|
|
1906
|
+
printGuideBlock('冲突说明', [
|
|
1907
|
+
{ label: '目标', value: targetPathText },
|
|
1908
|
+
{ label: '来源', value: recyclePathText },
|
|
1909
|
+
{ label: '范围', value: scopeText },
|
|
1910
|
+
{ label: '大小', value: sizeText },
|
|
1911
|
+
{ label: '建议', value: '默认建议先“跳过该项”,确认后再决定覆盖或重命名' },
|
|
1912
|
+
]);
|
|
1913
|
+
|
|
1914
|
+
const action = await askSelect({
|
|
1915
|
+
message: '请选择冲突处理策略',
|
|
1916
|
+
choices: [
|
|
1917
|
+
{ name: '跳过该项', value: 'skip' },
|
|
1918
|
+
{ name: '覆盖现有目标', value: 'overwrite' },
|
|
1919
|
+
{ name: '重命名恢复目标', value: 'rename' },
|
|
1920
|
+
],
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
const applyToAll = await askConfirm({
|
|
1924
|
+
message: '后续冲突是否沿用同一策略?',
|
|
1925
|
+
default: false,
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
return { action, applyToAll };
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
async function runRestoreMode(context) {
|
|
1932
|
+
const { config } = context;
|
|
1933
|
+
const governanceRoot = inferDataRootFromProfilesRoot(config.rootDir);
|
|
1934
|
+
const externalStorageRoots = await detectExternalStorageRoots({
|
|
1935
|
+
configuredRoots: config.externalStorageRoots,
|
|
1936
|
+
profilesRoot: config.rootDir,
|
|
1937
|
+
autoDetect: config.externalStorageAutoDetect !== false,
|
|
1938
|
+
});
|
|
1939
|
+
const governanceAllowRoots = governanceRoot
|
|
1940
|
+
? [...externalStorageRoots]
|
|
1941
|
+
: [config.rootDir, ...externalStorageRoots];
|
|
1942
|
+
|
|
1943
|
+
const batches = await listRestorableBatches(config.indexPath, { recycleRoot: config.recycleRoot });
|
|
1944
|
+
if (batches.length === 0) {
|
|
1945
|
+
console.log('\n暂无可恢复批次。');
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
printSection('可恢复批次');
|
|
1950
|
+
printGuideBlock('恢复步骤 1/3 · 批次选择', [
|
|
1951
|
+
{ label: '来源', value: '仅展示回收区中可恢复且索引有效的批次' },
|
|
1952
|
+
{ label: '建议', value: '优先恢复最近批次,便于问题回滚' },
|
|
1953
|
+
{ label: '确认', value: 'Enter 选中后进入恢复确认' },
|
|
1954
|
+
]);
|
|
1955
|
+
console.log(renderTable(['序号', '批次ID', '时间', '目录数', '大小'], batchTableRows(batches)));
|
|
1956
|
+
|
|
1957
|
+
const batchId = await askSelect({
|
|
1958
|
+
message: '请选择要恢复的批次',
|
|
1959
|
+
choices: batches.map((batch) => ({
|
|
1960
|
+
name: `${batch.batchId} | ${formatLocalDate(batch.firstTime)} | ${batch.entries.length}项 | ${formatBytes(batch.totalBytes)}`,
|
|
1961
|
+
value: batch.batchId,
|
|
1962
|
+
})),
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
const batch = batches.find((x) => x.batchId === batchId);
|
|
1966
|
+
if (!batch) {
|
|
1967
|
+
console.log('批次不存在。');
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
const sure = await askConfirm({
|
|
1972
|
+
message: `确认恢复批次 ${batch.batchId} 吗?`,
|
|
1973
|
+
default: false,
|
|
1974
|
+
});
|
|
1975
|
+
|
|
1976
|
+
if (!sure) {
|
|
1977
|
+
console.log('已取消恢复。');
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
printSection('恢复配置确认');
|
|
1982
|
+
printGuideBlock('恢复步骤 2/3 · 白名单与冲突策略', [
|
|
1983
|
+
{ label: '批次', value: `${batch.batchId}(${batch.entries.length} 项)` },
|
|
1984
|
+
{
|
|
1985
|
+
label: '范围',
|
|
1986
|
+
value: governanceRoot
|
|
1987
|
+
? `Data 根目录 + 文件存储目录(${externalStorageRoots.length}项)`
|
|
1988
|
+
: `Profile 根目录 + 文件存储目录(${externalStorageRoots.length}项)`,
|
|
1989
|
+
},
|
|
1990
|
+
{ label: '冲突', value: '若目标已存在,将询问 跳过/覆盖/重命名' },
|
|
1991
|
+
{ label: '安全', value: '路径越界会自动拦截并记审计状态' },
|
|
1992
|
+
]);
|
|
1993
|
+
printSection('恢复执行策略');
|
|
1994
|
+
const previewFirst = await askConfirm({
|
|
1995
|
+
message: '先 dry-run 预演恢复?',
|
|
1996
|
+
default: true,
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
const runRestoreOnce = async (dryRun) => {
|
|
2000
|
+
printSection(dryRun ? '恢复预演中(dry-run)' : '恢复中');
|
|
2001
|
+
printGuideBlock('恢复步骤 3/3 · 执行中', [
|
|
2002
|
+
{ label: '动作', value: '按批次回放恢复,逐项校验路径边界' },
|
|
2003
|
+
{ label: '审计', value: '成功/跳过/失败都会写入索引' },
|
|
2004
|
+
{ label: '模式', value: dryRun ? 'dry-run 预演(不落盘)' : '真实恢复(落盘)' },
|
|
2005
|
+
]);
|
|
2006
|
+
if (governanceRoot) {
|
|
2007
|
+
console.log(`治理恢复白名单: Data 根目录 + 文件存储目录(${externalStorageRoots.length}项)`);
|
|
2008
|
+
} else {
|
|
2009
|
+
console.log(
|
|
2010
|
+
`治理恢复白名单: 未识别Data根,已回退到 Profile 根目录 + 文件存储目录(${externalStorageRoots.length}项)`
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
const result = await restoreBatch({
|
|
2015
|
+
batch,
|
|
2016
|
+
indexPath: config.indexPath,
|
|
2017
|
+
onProgress: (current, total) => printProgress('恢复目录', current, total),
|
|
2018
|
+
onConflict: askConflictResolution,
|
|
2019
|
+
dryRun,
|
|
2020
|
+
profileRoot: config.rootDir,
|
|
2021
|
+
extraProfileRoots: externalStorageRoots,
|
|
2022
|
+
recycleRoot: config.recycleRoot,
|
|
2023
|
+
governanceRoot,
|
|
2024
|
+
extraGovernanceRoots: governanceAllowRoots,
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
printSection(dryRun ? '恢复预演结果(dry-run)' : '恢复结果');
|
|
2028
|
+
printGuideBlock('结果摘要', [
|
|
2029
|
+
{ label: '批次', value: result.batchId },
|
|
2030
|
+
{ label: '模式', value: dryRun ? 'dry-run 预演' : '真实恢复' },
|
|
2031
|
+
{ label: '成功', value: `${result.successCount} 项` },
|
|
2032
|
+
{ label: '跳过', value: `${result.skipCount} 项` },
|
|
2033
|
+
{ label: '失败', value: `${result.failCount} 项` },
|
|
2034
|
+
{ label: '恢复', value: formatBytes(result.restoredBytes) },
|
|
2035
|
+
]);
|
|
2036
|
+
if (result.failCount > 0 || result.skipCount > 0) {
|
|
2037
|
+
printGuideBlock('结果建议', [
|
|
2038
|
+
{
|
|
2039
|
+
label: '建议',
|
|
2040
|
+
value: '若有冲突或越界拦截,请按提示修正后重新恢复剩余项。',
|
|
2041
|
+
},
|
|
2042
|
+
]);
|
|
2043
|
+
}
|
|
2044
|
+
console.log(`批次ID : ${result.batchId}`);
|
|
2045
|
+
console.log(`成功数量 : ${result.successCount}`);
|
|
2046
|
+
console.log(`跳过数量 : ${result.skipCount}`);
|
|
2047
|
+
console.log(`失败数量 : ${result.failCount}`);
|
|
2048
|
+
console.log(`恢复体积 : ${formatBytes(result.restoredBytes)}`);
|
|
2049
|
+
|
|
2050
|
+
await printErrorDiagnostics(result.errors, {
|
|
2051
|
+
defaultLimit: 5,
|
|
2052
|
+
headers: ['路径', '错误'],
|
|
2053
|
+
mapRow: (item) => [item.sourcePath || '-', item.message || '-'],
|
|
2054
|
+
});
|
|
2055
|
+
return result;
|
|
2056
|
+
};
|
|
2057
|
+
|
|
2058
|
+
if (previewFirst) {
|
|
2059
|
+
await runRestoreOnce(true);
|
|
2060
|
+
const continueReal = await askConfirm({
|
|
2061
|
+
message: 'dry-run 预演已完成,是否继续执行真实恢复?',
|
|
2062
|
+
default: false,
|
|
2063
|
+
});
|
|
2064
|
+
if (!continueReal) {
|
|
2065
|
+
console.log('已结束:dry-run 预演已完成,未执行真实恢复。');
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
await runRestoreOnce(false);
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
async function runDoctorMode(context, options = {}) {
|
|
2074
|
+
const { config, aliases, appMeta } = context;
|
|
2075
|
+
const report = await runDoctor({
|
|
2076
|
+
config,
|
|
2077
|
+
aliases,
|
|
2078
|
+
projectRoot: context.projectRoot,
|
|
2079
|
+
appVersion: appMeta?.version || null,
|
|
2080
|
+
});
|
|
2081
|
+
await printDoctorReport(report, Boolean(options.jsonOutput));
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
async function runRecycleMaintainMode(context, options = {}) {
|
|
2085
|
+
const { config } = context;
|
|
2086
|
+
const policy = normalizeRecycleRetention(config.recycleRetention);
|
|
2087
|
+
const stats = await collectRecycleStats({
|
|
2088
|
+
indexPath: config.indexPath,
|
|
2089
|
+
recycleRoot: config.recycleRoot,
|
|
2090
|
+
});
|
|
2091
|
+
const thresholdBytes = recycleThresholdBytes(config);
|
|
2092
|
+
const overThreshold = stats.totalBytes > thresholdBytes;
|
|
2093
|
+
|
|
2094
|
+
printSection('回收区治理预览');
|
|
2095
|
+
printGuideBlock('治理策略', [
|
|
2096
|
+
{ label: '启用', value: policy.enabled ? '是' : '否' },
|
|
2097
|
+
{ label: '保留', value: `最近 ${policy.minKeepBatches} 批 + ${policy.maxAgeDays} 天内` },
|
|
2098
|
+
{ label: '容量', value: `${formatBytes(thresholdBytes)}(当前 ${formatBytes(stats.totalBytes)})` },
|
|
2099
|
+
{ label: '批次', value: `${stats.totalBatches} 批` },
|
|
2100
|
+
{ label: '状态', value: overThreshold ? '已超过阈值' : '未超过阈值' },
|
|
2101
|
+
]);
|
|
2102
|
+
|
|
2103
|
+
if (!policy.enabled) {
|
|
2104
|
+
console.log('回收区治理策略已关闭,请在“交互配置”中开启。');
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
const force = Boolean(options.force);
|
|
2109
|
+
let dryRun = true;
|
|
2110
|
+
if (force) {
|
|
2111
|
+
dryRun = false;
|
|
2112
|
+
} else {
|
|
2113
|
+
dryRun = await askConfirm({
|
|
2114
|
+
message: '先 dry-run 预览回收区治理计划?',
|
|
2115
|
+
default: true,
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
if (!dryRun && !force) {
|
|
2120
|
+
const sure = await askConfirm({
|
|
2121
|
+
message: '将按策略清理回收区历史批次,是否继续?',
|
|
2122
|
+
default: false,
|
|
2123
|
+
});
|
|
2124
|
+
if (!sure) {
|
|
2125
|
+
console.log('已取消回收区治理。');
|
|
2126
|
+
return;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
printSection('回收区治理中');
|
|
2131
|
+
const result = await maintainRecycleBin({
|
|
2132
|
+
indexPath: config.indexPath,
|
|
2133
|
+
recycleRoot: config.recycleRoot,
|
|
2134
|
+
policy,
|
|
2135
|
+
dryRun,
|
|
2136
|
+
onProgress: (current, total) => printProgress('清理批次', current, total),
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
if (!dryRun) {
|
|
2140
|
+
config.recycleRetention = {
|
|
2141
|
+
...policy,
|
|
2142
|
+
lastRunAt: Date.now(),
|
|
2143
|
+
};
|
|
2144
|
+
await saveConfig(config);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
printSection('回收区治理结果');
|
|
2148
|
+
printGuideBlock('结果摘要', [
|
|
2149
|
+
{ label: '状态', value: result.status },
|
|
2150
|
+
{ label: '模式', value: dryRun ? 'dry-run 预览' : '真实清理' },
|
|
2151
|
+
{ label: '候选', value: `${result.candidateCount} 批` },
|
|
2152
|
+
{ label: '已清理', value: `${result.deletedBatches} 批` },
|
|
2153
|
+
{ label: '释放', value: formatBytes(result.deletedBytes) },
|
|
2154
|
+
{ label: '剩余', value: `${result.after.totalBatches} 批 / ${formatBytes(result.after.totalBytes)}` },
|
|
2155
|
+
]);
|
|
2156
|
+
if (result.failBatches > 0) {
|
|
2157
|
+
printGuideBlock('结果建议', [{ label: '建议', value: '部分批次清理失败,请检查目录权限后重试。' }]);
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
async function runAliasManager(context) {
|
|
2162
|
+
const { config } = context;
|
|
2163
|
+
const aliases = context.aliases || {};
|
|
2164
|
+
context.aliases = aliases;
|
|
2165
|
+
const accounts = await discoverAccounts(config.rootDir, aliases);
|
|
2166
|
+
|
|
2167
|
+
if (accounts.length === 0) {
|
|
2168
|
+
console.log('\n无可配置账号。');
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
printSection('账号别名管理(用户名 | 企业名 | 短ID)');
|
|
2173
|
+
console.log(renderTable(['序号', '用户名', '企业名', '短ID', '状态'], accountTableRows(accounts)));
|
|
2174
|
+
|
|
2175
|
+
const accountId = await askSelect({
|
|
2176
|
+
message: '选择要修改别名的账号',
|
|
2177
|
+
choices: accounts.map((account) => ({
|
|
2178
|
+
name: `${account.userName} | ${account.corpName} | ${account.shortId}`,
|
|
2179
|
+
value: account.id,
|
|
2180
|
+
})),
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
const account = accounts.find((x) => x.id === accountId);
|
|
2184
|
+
if (!account) {
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
const oldAlias = aliases[account.id] || {};
|
|
2189
|
+
|
|
2190
|
+
const userName = await askInput({
|
|
2191
|
+
message: '用户名别名(留空表示清除该字段别名)',
|
|
2192
|
+
default: oldAlias.userName || account.userName,
|
|
2193
|
+
});
|
|
2194
|
+
|
|
2195
|
+
const corpName = await askInput({
|
|
2196
|
+
message: '企业名别名(留空表示清除该字段别名)',
|
|
2197
|
+
default: oldAlias.corpName || account.corpName,
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
const cleaned = {
|
|
2201
|
+
userName: (userName || '').trim(),
|
|
2202
|
+
corpName: (corpName || '').trim(),
|
|
2203
|
+
};
|
|
2204
|
+
|
|
2205
|
+
if (!cleaned.userName && !cleaned.corpName) {
|
|
2206
|
+
delete aliases[account.id];
|
|
2207
|
+
} else {
|
|
2208
|
+
aliases[account.id] = cleaned;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
await saveAliases(config.aliasPath, aliases);
|
|
2212
|
+
console.log('已保存账号别名。');
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
async function runSettingsMode(context) {
|
|
2216
|
+
const { config } = context;
|
|
2217
|
+
|
|
2218
|
+
while (true) {
|
|
2219
|
+
printSection('交互配置');
|
|
2220
|
+
const choice = await askSelect({
|
|
2221
|
+
message: '选择要调整的配置项',
|
|
2222
|
+
choices: [
|
|
2223
|
+
{ name: `Profile 根目录: ${config.rootDir}`, value: 'root' },
|
|
2224
|
+
{
|
|
2225
|
+
name: `手动追加文件存储根目录: ${Array.isArray(config.externalStorageRoots) && config.externalStorageRoots.length > 0 ? config.externalStorageRoots.length : 0} 项(默认路径自动识别)`,
|
|
2226
|
+
value: 'externalRoots',
|
|
2227
|
+
},
|
|
2228
|
+
{
|
|
2229
|
+
name: `外部存储自动探测: ${config.externalStorageAutoDetect !== false ? '开' : '关'}`,
|
|
2230
|
+
value: 'externalAutoDetect',
|
|
2231
|
+
},
|
|
2232
|
+
{ name: `回收区目录: ${config.recycleRoot}`, value: 'recycle' },
|
|
2233
|
+
{ name: `默认 dry-run: ${config.dryRunDefault ? '开' : '关'}`, value: 'dryrun' },
|
|
2234
|
+
{
|
|
2235
|
+
name: `全量治理建议阈值: >=${config.spaceGovernance.autoSuggest.sizeThresholdMB}MB 且静置${config.spaceGovernance.autoSuggest.idleDays}天`,
|
|
2236
|
+
value: 'spaceSuggest',
|
|
2237
|
+
},
|
|
2238
|
+
{ name: `全量治理冷静期: ${config.spaceGovernance.cooldownSeconds}s`, value: 'spaceCooldown' },
|
|
2239
|
+
{ name: `Logo 主题: ${themeLabel(config.theme)}`, value: 'theme' },
|
|
2240
|
+
{ name: '账号别名管理(用户名 | 企业名 | 短ID)', value: 'alias' },
|
|
2241
|
+
{
|
|
2242
|
+
name: `回收区保留策略: ${config.recycleRetention.enabled ? '开' : '关'} | ${config.recycleRetention.maxAgeDays}天 | 最近${config.recycleRetention.minKeepBatches}批 | ${config.recycleRetention.sizeThresholdGB}GB`,
|
|
2243
|
+
value: 'recycleRetention',
|
|
2244
|
+
},
|
|
2245
|
+
{ name: '返回主菜单', value: 'back' },
|
|
2246
|
+
],
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
if (choice === 'back') {
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
if (choice === 'root') {
|
|
2254
|
+
const value = await askInput({
|
|
2255
|
+
message: '输入新的 Profile 根目录',
|
|
2256
|
+
default: config.rootDir,
|
|
2257
|
+
});
|
|
2258
|
+
if (value.trim()) {
|
|
2259
|
+
config.rootDir = expandHome(value.trim());
|
|
2260
|
+
}
|
|
2261
|
+
await saveConfig(config);
|
|
2262
|
+
console.log('已保存根目录配置。');
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
if (choice === 'recycle') {
|
|
2267
|
+
const value = await askInput({
|
|
2268
|
+
message: '输入新的回收区目录',
|
|
2269
|
+
default: config.recycleRoot,
|
|
2270
|
+
});
|
|
2271
|
+
if (value.trim()) {
|
|
2272
|
+
config.recycleRoot = expandHome(value.trim());
|
|
2273
|
+
}
|
|
2274
|
+
await saveConfig(config);
|
|
2275
|
+
console.log('已保存回收区配置。');
|
|
2276
|
+
continue;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
if (choice === 'externalRoots') {
|
|
2280
|
+
const current = Array.isArray(config.externalStorageRoots) ? config.externalStorageRoots : [];
|
|
2281
|
+
const value = await askInput({
|
|
2282
|
+
message: '输入手动追加的文件存储根目录(多个路径可用逗号分隔,留空表示清空手动配置)',
|
|
2283
|
+
default: current.join(', '),
|
|
2284
|
+
});
|
|
2285
|
+
config.externalStorageRoots = String(value || '')
|
|
2286
|
+
.split(/[,\n;]/)
|
|
2287
|
+
.map((item) => expandHome(item.trim()))
|
|
2288
|
+
.filter((item) => item);
|
|
2289
|
+
await saveConfig(config);
|
|
2290
|
+
console.log('已保存手动文件存储根目录配置。');
|
|
2291
|
+
continue;
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
if (choice === 'externalAutoDetect') {
|
|
2295
|
+
const value = await askConfirm({
|
|
2296
|
+
message: '是否启用外部存储自动探测?(关闭后仅使用默认目录与手动配置路径)',
|
|
2297
|
+
default: config.externalStorageAutoDetect !== false,
|
|
2298
|
+
});
|
|
2299
|
+
config.externalStorageAutoDetect = value;
|
|
2300
|
+
await saveConfig(config);
|
|
2301
|
+
console.log(`已保存外部存储自动探测:${value ? '开启' : '关闭'}。`);
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
if (choice === 'dryrun') {
|
|
2306
|
+
const value = await askConfirm({
|
|
2307
|
+
message: '默认是否启用 dry-run?',
|
|
2308
|
+
default: config.dryRunDefault,
|
|
2309
|
+
});
|
|
2310
|
+
config.dryRunDefault = value;
|
|
2311
|
+
await saveConfig(config);
|
|
2312
|
+
console.log('已保存 dry-run 默认值。');
|
|
2313
|
+
continue;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
if (choice === 'spaceSuggest') {
|
|
2317
|
+
const sizeThresholdMB = await askInput({
|
|
2318
|
+
message: '输入建议体积阈值(MB,整数)',
|
|
2319
|
+
default: String(config.spaceGovernance.autoSuggest.sizeThresholdMB),
|
|
2320
|
+
validate: (value) => {
|
|
2321
|
+
const n = Number.parseInt(value, 10);
|
|
2322
|
+
return Number.isFinite(n) && n >= 1 ? true : '请输入 >= 1 的整数';
|
|
2323
|
+
},
|
|
2324
|
+
});
|
|
2325
|
+
const idleDays = await askInput({
|
|
2326
|
+
message: '输入建议静置天数(天,整数)',
|
|
2327
|
+
default: String(config.spaceGovernance.autoSuggest.idleDays),
|
|
2328
|
+
validate: (value) => {
|
|
2329
|
+
const n = Number.parseInt(value, 10);
|
|
2330
|
+
return Number.isFinite(n) && n >= 1 ? true : '请输入 >= 1 的整数';
|
|
2331
|
+
},
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
config.spaceGovernance.autoSuggest.sizeThresholdMB = Number.parseInt(sizeThresholdMB, 10);
|
|
2335
|
+
config.spaceGovernance.autoSuggest.idleDays = Number.parseInt(idleDays, 10);
|
|
2336
|
+
await saveConfig(config);
|
|
2337
|
+
console.log('已保存全量治理建议阈值。');
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
if (choice === 'spaceCooldown') {
|
|
2342
|
+
const cooldownSeconds = await askInput({
|
|
2343
|
+
message: '输入冷静期秒数(整数)',
|
|
2344
|
+
default: String(config.spaceGovernance.cooldownSeconds),
|
|
2345
|
+
validate: (value) => {
|
|
2346
|
+
const n = Number.parseInt(value, 10);
|
|
2347
|
+
return Number.isFinite(n) && n >= 1 ? true : '请输入 >= 1 的整数';
|
|
2348
|
+
},
|
|
2349
|
+
});
|
|
2350
|
+
config.spaceGovernance.cooldownSeconds = Number.parseInt(cooldownSeconds, 10);
|
|
2351
|
+
await saveConfig(config);
|
|
2352
|
+
console.log('已保存全量治理冷静期。');
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
if (choice === 'theme') {
|
|
2357
|
+
const value = await askSelect({
|
|
2358
|
+
message: '选择 Logo 主题',
|
|
2359
|
+
default: normalizeThemeMode(config.theme),
|
|
2360
|
+
choices: [
|
|
2361
|
+
{ name: '自动(按终端环境判断)', value: THEME_AUTO },
|
|
2362
|
+
{ name: '亮色(适配浅色背景)', value: THEME_LIGHT },
|
|
2363
|
+
{ name: '暗色(适配深色背景)', value: THEME_DARK },
|
|
2364
|
+
],
|
|
2365
|
+
});
|
|
2366
|
+
config.theme = normalizeThemeMode(value);
|
|
2367
|
+
await saveConfig(config);
|
|
2368
|
+
console.log(`已保存 Logo 主题:${themeLabel(config.theme)}。`);
|
|
2369
|
+
continue;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
if (choice === 'alias') {
|
|
2373
|
+
await runAliasManager(context);
|
|
2374
|
+
continue;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
if (choice === 'recycleRetention') {
|
|
2378
|
+
const enabled = await askConfirm({
|
|
2379
|
+
message: '是否启用回收区保留策略?',
|
|
2380
|
+
default: config.recycleRetention.enabled !== false,
|
|
2381
|
+
});
|
|
2382
|
+
|
|
2383
|
+
const maxAgeDays = await askInput({
|
|
2384
|
+
message: '输入保留天数阈值(超过该天数可清理)',
|
|
2385
|
+
default: String(config.recycleRetention.maxAgeDays),
|
|
2386
|
+
validate: (value) => {
|
|
2387
|
+
const n = Number.parseInt(value, 10);
|
|
2388
|
+
return Number.isFinite(n) && n >= 1 ? true : '请输入 >= 1 的整数';
|
|
2389
|
+
},
|
|
2390
|
+
});
|
|
2391
|
+
const minKeepBatches = await askInput({
|
|
2392
|
+
message: '输入至少保留最近批次数',
|
|
2393
|
+
default: String(config.recycleRetention.minKeepBatches),
|
|
2394
|
+
validate: (value) => {
|
|
2395
|
+
const n = Number.parseInt(value, 10);
|
|
2396
|
+
return Number.isFinite(n) && n >= 1 ? true : '请输入 >= 1 的整数';
|
|
2397
|
+
},
|
|
2398
|
+
});
|
|
2399
|
+
const sizeThresholdGB = await askInput({
|
|
2400
|
+
message: '输入容量阈值(GB,超过后会提示治理)',
|
|
2401
|
+
default: String(config.recycleRetention.sizeThresholdGB),
|
|
2402
|
+
validate: (value) => {
|
|
2403
|
+
const n = Number.parseInt(value, 10);
|
|
2404
|
+
return Number.isFinite(n) && n >= 1 ? true : '请输入 >= 1 的整数';
|
|
2405
|
+
},
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
config.recycleRetention = normalizeRecycleRetention({
|
|
2409
|
+
...config.recycleRetention,
|
|
2410
|
+
enabled,
|
|
2411
|
+
maxAgeDays: Number.parseInt(maxAgeDays, 10),
|
|
2412
|
+
minKeepBatches: Number.parseInt(minKeepBatches, 10),
|
|
2413
|
+
sizeThresholdGB: Number.parseInt(sizeThresholdGB, 10),
|
|
2414
|
+
});
|
|
2415
|
+
await saveConfig(config);
|
|
2416
|
+
console.log('已保存回收区保留策略。');
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
async function runMode(mode, context, options = {}) {
|
|
2422
|
+
if (mode === MODES.CLEANUP_MONTHLY) {
|
|
2423
|
+
await runCleanupMode(context);
|
|
2424
|
+
await printRecyclePressureHint(context.config);
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
if (mode === MODES.ANALYSIS_ONLY) {
|
|
2428
|
+
await runAnalysisMode(context);
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
if (mode === MODES.SPACE_GOVERNANCE) {
|
|
2432
|
+
await runSpaceGovernanceMode(context);
|
|
2433
|
+
await printRecyclePressureHint(context.config);
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
if (mode === MODES.RESTORE) {
|
|
2437
|
+
await runRestoreMode(context);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
if (mode === MODES.DOCTOR) {
|
|
2441
|
+
await runDoctorMode(context, options);
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
if (mode === MODES.RECYCLE_MAINTAIN) {
|
|
2445
|
+
await runRecycleMaintainMode(context, options);
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
if (mode === MODES.SETTINGS) {
|
|
2449
|
+
await runSettingsMode(context);
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
throw new Error(`不支持的运行模式: ${mode}`);
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
function formatLockOwner(lockInfo) {
|
|
2456
|
+
if (!lockInfo || typeof lockInfo !== 'object') {
|
|
2457
|
+
return '未知实例';
|
|
2458
|
+
}
|
|
2459
|
+
const pid = Number(lockInfo.pid || 0);
|
|
2460
|
+
const mode = String(lockInfo.mode || 'unknown');
|
|
2461
|
+
const host = String(lockInfo.hostname || 'unknown');
|
|
2462
|
+
const startedAt = Number(lockInfo.startedAt || 0);
|
|
2463
|
+
const startedText = Number.isFinite(startedAt) && startedAt > 0 ? formatLocalDate(startedAt) : 'unknown';
|
|
2464
|
+
return `PID ${pid || '-'} | 模式 ${mode} | 主机 ${host} | 启动 ${startedText}`;
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
async function acquireExecutionLock(stateRoot, mode, options = {}) {
|
|
2468
|
+
try {
|
|
2469
|
+
return await acquireLock(stateRoot, mode);
|
|
2470
|
+
} catch (error) {
|
|
2471
|
+
if (!(error instanceof LockHeldError)) {
|
|
2472
|
+
throw error;
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
const ownerText = formatLockOwner(error.lockInfo);
|
|
2476
|
+
if (!error.isStale) {
|
|
2477
|
+
throw new Error(`已有实例正在运行:${ownerText}`);
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
if (options.force) {
|
|
2481
|
+
await breakLock(error.lockPath);
|
|
2482
|
+
return acquireLock(stateRoot, mode);
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
if (!process.stdout.isTTY) {
|
|
2486
|
+
throw new Error(`检测到疑似陈旧锁:${ownerText}。可追加 --force 自动清理后重试。`);
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
const clearStaleLock = await askConfirm({
|
|
2490
|
+
message: `检测到陈旧锁(${ownerText}),是否清理后继续?`,
|
|
2491
|
+
default: false,
|
|
2492
|
+
});
|
|
2493
|
+
if (!clearStaleLock) {
|
|
2494
|
+
throw new Error('已取消:未清理陈旧锁。');
|
|
2495
|
+
}
|
|
2496
|
+
await breakLock(error.lockPath);
|
|
2497
|
+
return acquireLock(stateRoot, mode);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
async function main() {
|
|
2502
|
+
const cliArgs = parseCliArgs(process.argv.slice(2));
|
|
2503
|
+
const runModeValue = cliArgs.mode || MODES.START;
|
|
2504
|
+
const config = await loadConfig(cliArgs, {
|
|
2505
|
+
readOnly: runModeValue === MODES.DOCTOR,
|
|
2506
|
+
});
|
|
2507
|
+
const aliases = await loadAliases(config.aliasPath);
|
|
2508
|
+
|
|
2509
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
2510
|
+
const __dirname = path.dirname(__filename);
|
|
2511
|
+
const projectRoot = path.resolve(__dirname, '..');
|
|
2512
|
+
const nativeProbe =
|
|
2513
|
+
runModeValue === MODES.DOCTOR
|
|
2514
|
+
? { nativeCorePath: null, repairNote: null }
|
|
2515
|
+
: await detectNativeCore(projectRoot, {
|
|
2516
|
+
stateRoot: config.stateRoot,
|
|
2517
|
+
allowAutoRepair: true,
|
|
2518
|
+
});
|
|
2519
|
+
const appMeta = await loadAppMeta(projectRoot);
|
|
2520
|
+
|
|
2521
|
+
const context = {
|
|
2522
|
+
config,
|
|
2523
|
+
aliases,
|
|
2524
|
+
nativeCorePath: nativeProbe.nativeCorePath || null,
|
|
2525
|
+
nativeRepairNote: nativeProbe.repairNote || null,
|
|
2526
|
+
appMeta,
|
|
2527
|
+
projectRoot,
|
|
2528
|
+
};
|
|
2529
|
+
|
|
2530
|
+
let lockHandle = null;
|
|
2531
|
+
if (runModeValue !== MODES.DOCTOR) {
|
|
2532
|
+
lockHandle = await acquireExecutionLock(config.stateRoot, runModeValue, { force: cliArgs.force });
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
try {
|
|
2536
|
+
if (cliArgs.mode) {
|
|
2537
|
+
await runMode(cliArgs.mode, context, {
|
|
2538
|
+
jsonOutput: cliArgs.jsonOutput,
|
|
2539
|
+
force: cliArgs.force,
|
|
2540
|
+
});
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
while (true) {
|
|
2545
|
+
const accounts = await discoverAccounts(config.rootDir, context.aliases);
|
|
2546
|
+
const detectedExternalStorage = await detectExternalStorageRoots({
|
|
2547
|
+
configuredRoots: config.externalStorageRoots,
|
|
2548
|
+
profilesRoot: config.rootDir,
|
|
2549
|
+
autoDetect: config.externalStorageAutoDetect !== false,
|
|
2550
|
+
returnMeta: true,
|
|
2551
|
+
});
|
|
2552
|
+
const detectedExternalStorageRoots = detectedExternalStorage.roots;
|
|
2553
|
+
const profileRootHealth = await evaluateProfileRootHealth(config.rootDir, accounts);
|
|
2554
|
+
printHeader({
|
|
2555
|
+
config,
|
|
2556
|
+
accountCount: accounts.length,
|
|
2557
|
+
nativeCorePath: context.nativeCorePath,
|
|
2558
|
+
lastRunEngineUsed: context.lastRunEngineUsed || null,
|
|
2559
|
+
appMeta: context.appMeta,
|
|
2560
|
+
nativeRepairNote: context.nativeRepairNote,
|
|
2561
|
+
externalStorageRoots: detectedExternalStorageRoots,
|
|
2562
|
+
externalStorageMeta: detectedExternalStorage.meta,
|
|
2563
|
+
profileRootHealth,
|
|
2564
|
+
});
|
|
2565
|
+
|
|
2566
|
+
const mode = await askSelect({
|
|
2567
|
+
message: '开始菜单:请选择功能',
|
|
2568
|
+
default: MODES.CLEANUP_MONTHLY,
|
|
2569
|
+
choices: [
|
|
2570
|
+
{ name: '年月清理(默认,可执行删除)', value: MODES.CLEANUP_MONTHLY },
|
|
2571
|
+
{ name: '会话分析(只读,不处理)', value: MODES.ANALYSIS_ONLY },
|
|
2572
|
+
{ name: '全量空间治理(分级,安全优先)', value: MODES.SPACE_GOVERNANCE },
|
|
2573
|
+
{ name: '恢复已删除批次', value: MODES.RESTORE },
|
|
2574
|
+
{ name: '回收区治理(保留策略)', value: MODES.RECYCLE_MAINTAIN },
|
|
2575
|
+
{ name: '系统自检(doctor)', value: MODES.DOCTOR },
|
|
2576
|
+
{ name: '交互配置', value: MODES.SETTINGS },
|
|
2577
|
+
{ name: '退出', value: 'exit' },
|
|
2578
|
+
],
|
|
2579
|
+
});
|
|
2580
|
+
|
|
2581
|
+
if (mode === 'exit') {
|
|
2582
|
+
break;
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
await runMode(mode, context, {
|
|
2586
|
+
jsonOutput: false,
|
|
2587
|
+
force: false,
|
|
2588
|
+
});
|
|
2589
|
+
|
|
2590
|
+
const back = await askConfirm({
|
|
2591
|
+
message: '返回主菜单?',
|
|
2592
|
+
default: true,
|
|
2593
|
+
});
|
|
2594
|
+
|
|
2595
|
+
if (!back) {
|
|
2596
|
+
break;
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
console.log('已退出。');
|
|
2601
|
+
} finally {
|
|
2602
|
+
if (lockHandle && typeof lockHandle.release === 'function') {
|
|
2603
|
+
await lockHandle.release();
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
main().catch((error) => {
|
|
2609
|
+
if (error instanceof PromptAbortError) {
|
|
2610
|
+
console.log('\n已取消。');
|
|
2611
|
+
process.exit(0);
|
|
2612
|
+
}
|
|
2613
|
+
if (error instanceof CliArgError) {
|
|
2614
|
+
console.error(`参数错误: ${error.message}`);
|
|
2615
|
+
process.exit(2);
|
|
2616
|
+
}
|
|
2617
|
+
console.error('运行失败:', error instanceof Error ? error.message : error);
|
|
2618
|
+
process.exit(1);
|
|
2619
|
+
});
|