@mison/wecom-cleaner 1.0.0 → 1.1.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/src/config.js CHANGED
@@ -4,6 +4,25 @@ import { ensureDir, expandHome, readJson, writeJson } from './utils.js';
4
4
  import { normalizeRecycleRetention } from './recycle-maintenance.js';
5
5
 
6
6
  const ALLOWED_THEMES = new Set(['auto', 'light', 'dark']);
7
+ const ALLOWED_OUTPUTS = new Set(['json', 'text']);
8
+ const ALLOWED_CONFLICT_STRATEGIES = new Set(['skip', 'overwrite', 'rename']);
9
+ const ALLOWED_EXTERNAL_ROOT_SOURCES = new Set(['preset', 'configured', 'auto', 'all']);
10
+ const ALLOWED_GOVERNANCE_TIERS = new Set(['safe', 'caution', 'protected']);
11
+ const ACTION_FLAG_MAP = new Map([
12
+ ['--cleanup-monthly', 'cleanup_monthly'],
13
+ ['--analysis-only', 'analysis_only'],
14
+ ['--space-governance', 'space_governance'],
15
+ ['--recycle-maintain', 'recycle_maintain'],
16
+ ['--doctor', 'doctor'],
17
+ ]);
18
+ const MODE_TO_ACTION_MAP = new Map([
19
+ ['cleanup_monthly', 'cleanup_monthly'],
20
+ ['analysis_only', 'analysis_only'],
21
+ ['space_governance', 'space_governance'],
22
+ ['recycle_maintain', 'recycle_maintain'],
23
+ ['restore', 'restore'],
24
+ ['doctor', 'doctor'],
25
+ ]);
7
26
 
8
27
  export class CliArgError extends Error {
9
28
  constructor(message) {
@@ -23,6 +42,42 @@ function normalizeTheme(theme) {
23
42
  return normalized;
24
43
  }
25
44
 
45
+ function normalizeOutput(output) {
46
+ if (typeof output !== 'string') {
47
+ return null;
48
+ }
49
+ const normalized = output.trim().toLowerCase();
50
+ if (!ALLOWED_OUTPUTS.has(normalized)) {
51
+ return null;
52
+ }
53
+ return normalized;
54
+ }
55
+
56
+ function parseCsvList(rawValue) {
57
+ return String(rawValue || '')
58
+ .split(/[,\n;]/)
59
+ .map((item) => item.trim())
60
+ .filter(Boolean);
61
+ }
62
+
63
+ function parsePositiveInteger(flag, rawValue) {
64
+ const num = Number.parseInt(String(rawValue || ''), 10);
65
+ if (!Number.isFinite(num) || num < 1) {
66
+ throw new CliArgError(`参数 ${flag} 的值必须是 >= 1 的整数: ${rawValue}`);
67
+ }
68
+ return num;
69
+ }
70
+
71
+ function parseEnumValue(flag, rawValue, allowedSet) {
72
+ const normalized = String(rawValue || '')
73
+ .trim()
74
+ .toLowerCase();
75
+ if (!allowedSet.has(normalized)) {
76
+ throw new CliArgError(`参数 ${flag} 的值无效: ${rawValue}`);
77
+ }
78
+ return normalized;
79
+ }
80
+
26
81
  export function defaultConfig() {
27
82
  const stateRoot = DEFAULT_STATE_ROOT;
28
83
  const recycleRetention = normalizeRecycleRetention({
@@ -91,9 +146,35 @@ export function parseCliArgs(argv) {
91
146
  dryRunDefault: null,
92
147
  mode: null,
93
148
  theme: null,
149
+ output: null,
150
+ dryRun: null,
151
+ yes: false,
152
+ saveConfig: false,
94
153
  jsonOutput: false,
95
154
  force: false,
155
+ interactive: false,
156
+ action: null,
157
+ actionFromMode: false,
158
+ actionFlagCount: 0,
159
+ restoreBatchId: null,
160
+ accounts: null,
161
+ months: null,
162
+ cutoffMonth: null,
163
+ categories: null,
164
+ includeNonMonthDirs: null,
165
+ externalRoots: null,
166
+ externalRootsSource: null,
167
+ targets: null,
168
+ tiers: null,
169
+ suggestedOnly: null,
170
+ allowRecentActive: null,
171
+ conflict: null,
172
+ retentionEnabled: null,
173
+ retentionMaxAgeDays: null,
174
+ retentionMinKeepBatches: null,
175
+ retentionSizeThresholdGB: null,
96
176
  };
177
+ const actionValues = [];
97
178
 
98
179
  const takeValue = (flag, index) => {
99
180
  const value = argv[index + 1];
@@ -116,6 +197,12 @@ export function parseCliArgs(argv) {
116
197
 
117
198
  for (let i = 0; i < argv.length; i += 1) {
118
199
  const token = argv[i];
200
+ if (ACTION_FLAG_MAP.has(token)) {
201
+ parsed.action = ACTION_FLAG_MAP.get(token);
202
+ parsed.actionFlagCount += 1;
203
+ actionValues.push(parsed.action);
204
+ continue;
205
+ }
119
206
  if (token === '--root') {
120
207
  parsed.rootDir = takeValue(token, i);
121
208
  i += 1;
@@ -146,6 +233,14 @@ export function parseCliArgs(argv) {
146
233
  i += 1;
147
234
  continue;
148
235
  }
236
+ if (token === '--restore-batch') {
237
+ parsed.restoreBatchId = takeValue(token, i);
238
+ parsed.action = 'restore';
239
+ parsed.actionFlagCount += 1;
240
+ actionValues.push('restore');
241
+ i += 1;
242
+ continue;
243
+ }
149
244
  if (token === '--theme') {
150
245
  const theme = normalizeTheme(takeValue(token, i));
151
246
  if (!theme) {
@@ -155,6 +250,28 @@ export function parseCliArgs(argv) {
155
250
  i += 1;
156
251
  continue;
157
252
  }
253
+ if (token === '--output') {
254
+ const output = normalizeOutput(takeValue(token, i));
255
+ if (!output) {
256
+ throw new CliArgError(`参数 --output 的值无效: ${argv[i + 1]}`);
257
+ }
258
+ parsed.output = output;
259
+ i += 1;
260
+ continue;
261
+ }
262
+ if (token === '--dry-run') {
263
+ parsed.dryRun = parseBooleanFlag(token, takeValue(token, i));
264
+ i += 1;
265
+ continue;
266
+ }
267
+ if (token === '--yes') {
268
+ parsed.yes = true;
269
+ continue;
270
+ }
271
+ if (token === '--save-config') {
272
+ parsed.saveConfig = true;
273
+ continue;
274
+ }
158
275
  if (token === '--json') {
159
276
  parsed.jsonOutput = true;
160
277
  continue;
@@ -163,11 +280,140 @@ export function parseCliArgs(argv) {
163
280
  parsed.force = true;
164
281
  continue;
165
282
  }
283
+ if (token === '--interactive') {
284
+ parsed.interactive = true;
285
+ continue;
286
+ }
287
+ if (token === '--accounts') {
288
+ parsed.accounts = parseCsvList(takeValue(token, i));
289
+ i += 1;
290
+ continue;
291
+ }
292
+ if (token === '--months') {
293
+ parsed.months = parseCsvList(takeValue(token, i));
294
+ i += 1;
295
+ continue;
296
+ }
297
+ if (token === '--cutoff-month') {
298
+ parsed.cutoffMonth = takeValue(token, i);
299
+ i += 1;
300
+ continue;
301
+ }
302
+ if (token === '--categories') {
303
+ parsed.categories = parseCsvList(takeValue(token, i));
304
+ i += 1;
305
+ continue;
306
+ }
307
+ if (token === '--include-non-month-dirs') {
308
+ parsed.includeNonMonthDirs = parseBooleanFlag(token, takeValue(token, i));
309
+ i += 1;
310
+ continue;
311
+ }
312
+ if (token === '--external-roots') {
313
+ parsed.externalRoots = parseCsvList(takeValue(token, i));
314
+ i += 1;
315
+ continue;
316
+ }
317
+ if (token === '--external-roots-source') {
318
+ const sourceValues = parseCsvList(takeValue(token, i)).map((item) => item.toLowerCase());
319
+ if (sourceValues.length === 0) {
320
+ throw new CliArgError('参数 --external-roots-source 至少需要一个值');
321
+ }
322
+ for (const source of sourceValues) {
323
+ if (!ALLOWED_EXTERNAL_ROOT_SOURCES.has(source)) {
324
+ throw new CliArgError(`参数 --external-roots-source 的值无效: ${source}`);
325
+ }
326
+ }
327
+ parsed.externalRootsSource = sourceValues;
328
+ i += 1;
329
+ continue;
330
+ }
331
+ if (token === '--targets') {
332
+ parsed.targets = parseCsvList(takeValue(token, i));
333
+ i += 1;
334
+ continue;
335
+ }
336
+ if (token === '--tiers') {
337
+ const values = parseCsvList(takeValue(token, i)).map((item) => item.toLowerCase());
338
+ if (values.length === 0) {
339
+ throw new CliArgError('参数 --tiers 至少需要一个值');
340
+ }
341
+ for (const tier of values) {
342
+ if (!ALLOWED_GOVERNANCE_TIERS.has(tier)) {
343
+ throw new CliArgError(`参数 --tiers 的值无效: ${tier}`);
344
+ }
345
+ }
346
+ parsed.tiers = values;
347
+ i += 1;
348
+ continue;
349
+ }
350
+ if (token === '--suggested-only') {
351
+ parsed.suggestedOnly = parseBooleanFlag(token, takeValue(token, i));
352
+ i += 1;
353
+ continue;
354
+ }
355
+ if (token === '--allow-recent-active') {
356
+ parsed.allowRecentActive = parseBooleanFlag(token, takeValue(token, i));
357
+ i += 1;
358
+ continue;
359
+ }
360
+ if (token === '--conflict') {
361
+ parsed.conflict = parseEnumValue(token, takeValue(token, i), ALLOWED_CONFLICT_STRATEGIES);
362
+ i += 1;
363
+ continue;
364
+ }
365
+ if (token === '--retention-enabled') {
366
+ parsed.retentionEnabled = parseBooleanFlag(token, takeValue(token, i));
367
+ i += 1;
368
+ continue;
369
+ }
370
+ if (token === '--retention-max-age-days') {
371
+ parsed.retentionMaxAgeDays = parsePositiveInteger(token, takeValue(token, i));
372
+ i += 1;
373
+ continue;
374
+ }
375
+ if (token === '--retention-min-keep-batches') {
376
+ parsed.retentionMinKeepBatches = parsePositiveInteger(token, takeValue(token, i));
377
+ i += 1;
378
+ continue;
379
+ }
380
+ if (token === '--retention-size-threshold-gb') {
381
+ parsed.retentionSizeThresholdGB = parsePositiveInteger(token, takeValue(token, i));
382
+ i += 1;
383
+ continue;
384
+ }
166
385
  if (token.startsWith('-')) {
167
386
  throw new CliArgError(`不支持的参数: ${token}`);
168
387
  }
169
388
  }
170
389
 
390
+ if (parsed.months && parsed.cutoffMonth) {
391
+ throw new CliArgError('参数 --months 与 --cutoff-month 不能同时使用');
392
+ }
393
+
394
+ if (parsed.actionFlagCount > 1 || new Set(actionValues).size > 1) {
395
+ throw new CliArgError('动作参数冲突:一次只能指定一个动作(如 --cleanup-monthly)');
396
+ }
397
+
398
+ if (parsed.mode && parsed.action) {
399
+ throw new CliArgError('参数 --mode 不能与动作参数同时使用');
400
+ }
401
+
402
+ if (parsed.mode && !parsed.action) {
403
+ const mapped = MODE_TO_ACTION_MAP.get(String(parsed.mode || '').trim());
404
+ if (mapped) {
405
+ parsed.action = mapped;
406
+ parsed.actionFromMode = true;
407
+ }
408
+ }
409
+
410
+ if (parsed.jsonOutput) {
411
+ if (parsed.output && parsed.output !== 'json') {
412
+ throw new CliArgError('参数 --json 与 --output text 不能同时使用');
413
+ }
414
+ parsed.output = 'json';
415
+ }
416
+
171
417
  return parsed;
172
418
  }
173
419
 
package/src/doctor.js CHANGED
@@ -331,7 +331,7 @@ export async function runDoctor({ config, aliases, projectRoot, appVersion }) {
331
331
  '回收区健康',
332
332
  recycleOverThreshold ? STATUS_WARN : STATUS_PASS,
333
333
  `批次 ${recycleStats.totalBatches} 个,容量 ${recycleStats.totalBytes} bytes,阈值 ${thresholdBytes} bytes`,
334
- recycleOverThreshold ? '建议执行回收区治理(--mode recycle_maintain)。' : ''
334
+ recycleOverThreshold ? '建议执行回收区治理(--recycle-maintain)。' : ''
335
335
  )
336
336
  );
337
337
 
package/src/lock.js CHANGED
@@ -63,11 +63,12 @@ export async function breakLock(lockPath) {
63
63
  await fs.rm(lockPath, { force: true });
64
64
  }
65
65
 
66
- export async function acquireLock(stateRoot, mode) {
66
+ export async function acquireLock(stateRoot, mode, options = {}) {
67
+ const allowStaleBreak = options.allowStaleBreak !== false;
67
68
  const lockPath = resolveLockPath(stateRoot);
68
69
  await fs.mkdir(path.dirname(lockPath), { recursive: true });
69
70
 
70
- const payload = {
71
+ const payloadBase = {
71
72
  pid: process.pid,
72
73
  mode: String(mode || 'unknown'),
73
74
  startedAt: Date.now(),
@@ -75,28 +76,55 @@ export async function acquireLock(stateRoot, mode) {
75
76
  version: process.env.npm_package_version || null,
76
77
  };
77
78
 
78
- try {
79
- await writeLockFile(lockPath, payload);
80
- } catch (error) {
81
- if (error && error.code !== 'EEXIST') {
82
- throw error;
83
- }
79
+ let recoveredFromStale = false;
80
+ let staleLockInfo = null;
81
+
82
+ for (let attempt = 0; attempt < 2; attempt += 1) {
83
+ const payload = recoveredFromStale
84
+ ? {
85
+ ...payloadBase,
86
+ recoveredFromStale: true,
87
+ recoveredAt: Date.now(),
88
+ staleLockPid: staleLockInfo?.pid || null,
89
+ }
90
+ : payloadBase;
91
+
92
+ try {
93
+ await writeLockFile(lockPath, payload);
94
+ return {
95
+ lockPath,
96
+ lockInfo: payload,
97
+ async release() {
98
+ await fs.rm(lockPath, { force: true }).catch(() => {});
99
+ },
100
+ };
101
+ } catch (error) {
102
+ if (error && error.code !== 'EEXIST') {
103
+ throw error;
104
+ }
84
105
 
85
- const lockInfo = await readLockInfo(lockPath);
86
- const lockPid = Number.parseInt(String(lockInfo?.pid || ''), 10);
87
- const isStale = !isProcessRunning(lockPid);
88
- throw new LockHeldError('检测到另一个实例正在运行', {
89
- lockPath,
90
- lockInfo,
91
- isStale,
92
- });
106
+ const lockInfo = await readLockInfo(lockPath);
107
+ const lockPid = Number.parseInt(String(lockInfo?.pid || ''), 10);
108
+ const isStale = !isProcessRunning(lockPid);
109
+
110
+ if (isStale && allowStaleBreak && attempt === 0) {
111
+ staleLockInfo = lockInfo;
112
+ recoveredFromStale = true;
113
+ await fs.rm(lockPath, { force: true }).catch(() => {});
114
+ continue;
115
+ }
116
+
117
+ throw new LockHeldError('检测到另一个实例正在运行', {
118
+ lockPath,
119
+ lockInfo,
120
+ isStale,
121
+ });
122
+ }
93
123
  }
94
124
 
95
- return {
125
+ throw new LockHeldError('检测到锁文件冲突,且自动恢复失败', {
96
126
  lockPath,
97
- lockInfo: payload,
98
- async release() {
99
- await fs.rm(lockPath, { force: true }).catch(() => {});
100
- },
101
- };
127
+ lockInfo: staleLockInfo,
128
+ isStale: Boolean(staleLockInfo),
129
+ });
102
130
  }
@@ -5,7 +5,7 @@ import { createHash, randomUUID } from 'node:crypto';
5
5
 
6
6
  const NATIVE_CACHE_DIR = 'native-cache';
7
7
  const MANIFEST_RELATIVE_PATH = path.join('native', 'manifest.json');
8
- const DEFAULT_DOWNLOAD_BASE_URL = 'https://raw.githubusercontent.com/MisonL/wecom-cleaner/v1.0.0/native/bin';
8
+ const DEFAULT_DOWNLOAD_REPO_BASE_URL = 'https://raw.githubusercontent.com/MisonL/wecom-cleaner';
9
9
  const DEFAULT_DOWNLOAD_TIMEOUT_MS = 15_000;
10
10
  const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
11
11
 
@@ -219,6 +219,20 @@ function resolveManifestVersion(manifest) {
219
219
  return null;
220
220
  }
221
221
 
222
+ function resolveReleaseTag(version) {
223
+ const normalized = String(version || '').trim();
224
+ if (!normalized) {
225
+ return null;
226
+ }
227
+ return normalized.startsWith('v') ? normalized : `v${normalized}`;
228
+ }
229
+
230
+ function resolveDefaultDownloadBaseUrl(manifest) {
231
+ const releaseTag = resolveReleaseTag(resolveManifestVersion(manifest));
232
+ const ref = releaseTag || 'main';
233
+ return `${DEFAULT_DOWNLOAD_REPO_BASE_URL}/${ref}/native/bin`;
234
+ }
235
+
222
236
  function resolveDownloadUrl({ target, manifest, manifestTarget }) {
223
237
  const fileName = manifestTarget?.binaryName || target.binaryName;
224
238
  const overrideBaseUrl = getDownloadBaseUrlOverride();
@@ -239,7 +253,7 @@ function resolveDownloadUrl({ target, manifest, manifestTarget }) {
239
253
  return `${manifestBaseUrl}/${target.targetTag}/${fileName}`;
240
254
  }
241
255
 
242
- return `${DEFAULT_DOWNLOAD_BASE_URL}/${target.targetTag}/${fileName}`;
256
+ return `${resolveDefaultDownloadBaseUrl(manifest)}/${target.targetTag}/${fileName}`;
243
257
  }
244
258
 
245
259
  async function verifySha256OrReason(filePath, expectedSha256) {
@@ -201,7 +201,11 @@ export function selectBatchesForMaintenance(batches, policy, now = Date.now()) {
201
201
  export async function maintainRecycleBin({ indexPath, recycleRoot, policy, dryRun, onProgress }) {
202
202
  const normalizedPolicy = normalizeRecycleRetention(policy);
203
203
  const now = Date.now();
204
- const before = await collectRecycleStats({ indexPath, recycleRoot });
204
+ const before = await collectRecycleStats({
205
+ indexPath,
206
+ recycleRoot,
207
+ createIfMissing: !dryRun,
208
+ });
205
209
  const selected = selectBatchesForMaintenance(before.batches, normalizedPolicy, now);
206
210
  const thresholdBytes = selected.thresholdBytes;
207
211
 
package/src/restore.js CHANGED
@@ -262,6 +262,21 @@ async function validateRestoreEntryPath({ originalPath, recyclePath, scope, vali
262
262
  return null;
263
263
  }
264
264
 
265
+ function buildRestoreAuditMeta(entry = {}) {
266
+ return {
267
+ accountId: entry.accountId || null,
268
+ accountShortId: entry.accountShortId || null,
269
+ userName: entry.userName || null,
270
+ corpName: entry.corpName || null,
271
+ categoryKey: entry.categoryKey || null,
272
+ categoryLabel: entry.categoryLabel || null,
273
+ monthKey: entry.monthKey || null,
274
+ targetKey: entry.targetKey || null,
275
+ tier: entry.tier || null,
276
+ sizeBytes: Number(entry.sizeBytes || 0),
277
+ };
278
+ }
279
+
265
280
  export async function listRestorableBatches(indexPath, options = {}) {
266
281
  const recycleRoot = typeof options.recycleRoot === 'string' ? options.recycleRoot : null;
267
282
  const restoredSet = new Set();
@@ -354,6 +369,7 @@ export async function restoreBatch({
354
369
  const recyclePath = entry.recyclePath;
355
370
  const originalPath = entry.sourcePath;
356
371
  const scope = typeof entry.scope === 'string' && entry.scope ? entry.scope : 'cleanup_monthly';
372
+ const auditMeta = buildRestoreAuditMeta(entry);
357
373
  const invalidPathReason = await validateRestoreEntryPath({
358
374
  originalPath,
359
375
  recyclePath,
@@ -370,6 +386,7 @@ export async function restoreBatch({
370
386
  batchId: batch.batchId,
371
387
  recyclePath,
372
388
  sourcePath: originalPath,
389
+ ...auditMeta,
373
390
  status: 'skipped_invalid_path',
374
391
  error_type: ERROR_TYPES.PATH_VALIDATION_FAILED,
375
392
  invalid_reason: invalidPathReason,
@@ -391,6 +408,7 @@ export async function restoreBatch({
391
408
  batchId: batch.batchId,
392
409
  recyclePath,
393
410
  sourcePath: originalPath,
411
+ ...auditMeta,
394
412
  status: 'skipped_missing_recycle',
395
413
  error_type: ERROR_TYPES.PATH_NOT_FOUND,
396
414
  profile_root: profileRoot,
@@ -436,6 +454,7 @@ export async function restoreBatch({
436
454
  batchId: batch.batchId,
437
455
  recyclePath,
438
456
  sourcePath: originalPath,
457
+ ...auditMeta,
439
458
  status: 'skipped_conflict',
440
459
  error_type: ERROR_TYPES.CONFLICT,
441
460
  profile_root: profileRoot,
@@ -464,6 +483,7 @@ export async function restoreBatch({
464
483
  recyclePath,
465
484
  sourcePath: originalPath,
466
485
  restoredPath: targetPath,
486
+ ...auditMeta,
467
487
  status: 'dry_run',
468
488
  dryRun: true,
469
489
  conflict_strategy: conflictStrategy,
@@ -493,6 +513,7 @@ export async function restoreBatch({
493
513
  recyclePath,
494
514
  sourcePath: originalPath,
495
515
  restoredPath: targetPath,
516
+ ...auditMeta,
496
517
  status: 'success',
497
518
  dryRun: false,
498
519
  profile_root: profileRoot,
@@ -516,6 +537,7 @@ export async function restoreBatch({
516
537
  batchId: batch.batchId,
517
538
  recyclePath,
518
539
  sourcePath: originalPath,
540
+ ...auditMeta,
519
541
  status: 'failed',
520
542
  error_type: classifyErrorType(error instanceof Error ? error.message : String(error)),
521
543
  dryRun: false,
package/src/scanner.js CHANGED
@@ -31,6 +31,17 @@ const EXTERNAL_SOURCE_AUTO = 'auto';
31
31
  const EXTERNAL_STORAGE_SCAN_MAX_DEPTH_DEFAULT = 2;
32
32
  const EXTERNAL_STORAGE_SCAN_MAX_VISITS_DEFAULT = 400;
33
33
  const EXTERNAL_STORAGE_CACHE_TTL_MS_DEFAULT = 15_000;
34
+ const EXTERNAL_STORAGE_KNOWN_CATEGORY_DIRS = new Set(
35
+ CACHE_CATEGORIES.map((item) => {
36
+ const relativePath = String(item?.relativePath || '');
37
+ if (!relativePath.startsWith('Caches/')) {
38
+ return null;
39
+ }
40
+ const suffix = relativePath.slice('Caches/'.length);
41
+ const [head] = suffix.split(/[\\/]+/).filter(Boolean);
42
+ return head ? head.toLowerCase() : null;
43
+ }).filter(Boolean)
44
+ );
34
45
  const EXTERNAL_STORAGE_SCAN_SKIP_NAMES = new Set([
35
46
  '.',
36
47
  '..',
@@ -150,6 +161,45 @@ async function isDirectoryPath(targetPath) {
150
161
  return Boolean(stat?.isDirectory());
151
162
  }
152
163
 
164
+ async function detectExternalStorageMarkers(cacheRoot) {
165
+ const entries = await listDirectoryEntries(cacheRoot);
166
+ const categoryDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
167
+ const knownCategoryDirs = categoryDirs.filter((name) =>
168
+ EXTERNAL_STORAGE_KNOWN_CATEGORY_DIRS.has(name.toLowerCase())
169
+ );
170
+
171
+ let monthLikeCategoryCount = 0;
172
+ for (const categoryName of knownCategoryDirs) {
173
+ const childDirs = await listSubDirectories(path.join(cacheRoot, categoryName));
174
+ if (childDirs.some((dirName) => normalizeMonthKey(dirName))) {
175
+ monthLikeCategoryCount += 1;
176
+ }
177
+ }
178
+
179
+ return {
180
+ knownCategoryCount: knownCategoryDirs.length,
181
+ monthLikeCategoryCount,
182
+ };
183
+ }
184
+
185
+ async function isLikelyExternalStorageRoot(rootPath, options = {}) {
186
+ const strictMarkers = options.strictMarkers === true;
187
+ const cacheRoot = path.join(rootPath, EXTERNAL_STORAGE_CACHE_RELATIVE);
188
+ if (!(await isDirectoryPath(cacheRoot))) {
189
+ return false;
190
+ }
191
+
192
+ if (!strictMarkers) {
193
+ return true;
194
+ }
195
+
196
+ const markers = await detectExternalStorageMarkers(cacheRoot);
197
+ if (markers.monthLikeCategoryCount >= 1) {
198
+ return true;
199
+ }
200
+ return markers.knownCategoryCount >= 2;
201
+ }
202
+
153
203
  function normalizeExternalStorageRootCandidate(rawPath) {
154
204
  const input = String(rawPath || '').trim();
155
205
  if (!input) {
@@ -169,13 +219,13 @@ function normalizeExternalStorageRootCandidate(rawPath) {
169
219
  return normalized;
170
220
  }
171
221
 
172
- async function resolveExternalStorageRoot(rawPath) {
222
+ async function resolveExternalStorageRoot(rawPath, options = {}) {
173
223
  const root = normalizeExternalStorageRootCandidate(rawPath);
174
224
  if (!root) {
175
225
  return null;
176
226
  }
177
- const cacheRoot = path.join(root, EXTERNAL_STORAGE_CACHE_RELATIVE);
178
- if (!(await isDirectoryPath(cacheRoot))) {
227
+ const likely = await isLikelyExternalStorageRoot(root, options);
228
+ if (!likely) {
179
229
  return null;
180
230
  }
181
231
  return root;
@@ -227,10 +277,7 @@ function collectBuiltInStorageRootCandidates(options = {}) {
227
277
  async function collectDefaultExternalSearchBaseRoots() {
228
278
  const bases = new Set();
229
279
  const home = os.homedir();
230
- bases.add(home);
231
280
  bases.add(path.join(home, 'Documents'));
232
- bases.add(path.join(home, 'Desktop'));
233
- bases.add(path.join(home, 'Downloads'));
234
281
 
235
282
  const volumeEntries = await fs.readdir('/Volumes', { withFileTypes: true }).catch(() => []);
236
283
  for (const entry of volumeEntries) {
@@ -623,10 +670,11 @@ export async function detectExternalStorageRoots(options = {}) {
623
670
  autoDetectedRootCount: 0,
624
671
  truncatedRoots: [],
625
672
  visitedDirs: 0,
673
+ autoRejectedRootCount: 0,
626
674
  };
627
675
 
628
676
  for (const candidate of builtInCandidates) {
629
- const root = await resolveExternalStorageRoot(candidate);
677
+ const root = await resolveExternalStorageRoot(candidate, { strictMarkers: true });
630
678
  if (!root || seen.has(root)) {
631
679
  continue;
632
680
  }
@@ -636,7 +684,7 @@ export async function detectExternalStorageRoots(options = {}) {
636
684
  }
637
685
 
638
686
  for (const candidate of configuredRoots) {
639
- const root = await resolveExternalStorageRoot(candidate);
687
+ const root = await resolveExternalStorageRoot(candidate, { strictMarkers: false });
640
688
  if (!root || seen.has(root)) {
641
689
  continue;
642
690
  }
@@ -651,16 +699,26 @@ export async function detectExternalStorageRoots(options = {}) {
651
699
  ? options.searchBaseRoots
652
700
  : await collectDefaultExternalSearchBaseRoots();
653
701
  const autoScan = await findExternalStorageRootsByStructure(baseRoots, options);
654
- autoDetectMeta = autoScan.meta;
702
+ autoDetectMeta = {
703
+ ...autoScan.meta,
704
+ autoRejectedRootCount: 0,
705
+ };
655
706
  for (const root of autoScan.roots) {
656
- const normalized = await resolveExternalStorageRoot(root);
707
+ const normalized = await resolveExternalStorageRoot(root, { strictMarkers: true });
657
708
  if (!normalized || seen.has(normalized)) {
709
+ if (!normalized) {
710
+ autoDetectMeta.autoRejectedRootCount += 1;
711
+ }
658
712
  continue;
659
713
  }
660
714
  seen.add(normalized);
661
715
  resolved.push(normalized);
662
716
  sourceByRoot.set(normalized, EXTERNAL_SOURCE_AUTO);
663
717
  }
718
+ autoDetectMeta.autoDetectedRootCount = Math.max(
719
+ 0,
720
+ autoScan.roots.length - autoDetectMeta.autoRejectedRootCount
721
+ );
664
722
  }
665
723
 
666
724
  resolved.sort();
@@ -687,6 +745,7 @@ export async function detectExternalStorageRoots(options = {}) {
687
745
  const meta = {
688
746
  searchedRootsCount: autoDetectMeta.searchedRootsCount,
689
747
  autoDetectedRootCount: autoDetectMeta.autoDetectedRootCount,
748
+ autoRejectedRootCount: autoDetectMeta.autoRejectedRootCount,
690
749
  truncatedRoots: autoDetectMeta.truncatedRoots,
691
750
  visitedDirs: autoDetectMeta.visitedDirs,
692
751
  resolvedRootCount: resolved.length,