@mison/wecom-cleaner 1.2.1 → 1.3.2

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/cli.js CHANGED
@@ -33,6 +33,17 @@ import { runDoctor } from './doctor.js';
33
33
  import { acquireLock, breakLock, LockHeldError } from './lock.js';
34
34
  import { classifyErrorType, errorTypeToLabel } from './error-taxonomy.js';
35
35
  import { collectRecycleStats, maintainRecycleBin, normalizeRecycleRetention } from './recycle-maintenance.js';
36
+ import {
37
+ applyUpdateCheckResult,
38
+ channelLabel,
39
+ checkLatestVersion,
40
+ normalizeSelfUpdateConfig,
41
+ normalizeUpgradeChannel,
42
+ runUpgrade,
43
+ shouldCheckForUpdate,
44
+ shouldSkipVersion,
45
+ updateWarningMessage,
46
+ } from './updater.js';
36
47
  import {
37
48
  compareMonthKey,
38
49
  expandHome,
@@ -91,6 +102,8 @@ const NON_INTERACTIVE_ACTIONS = new Set([
91
102
  MODES.RESTORE,
92
103
  MODES.RECYCLE_MAINTAIN,
93
104
  MODES.DOCTOR,
105
+ MODES.CHECK_UPDATE,
106
+ MODES.UPGRADE,
94
107
  ]);
95
108
  const INTERACTIVE_MODE_ALIASES = new Map([
96
109
  ['start', MODES.START],
@@ -104,10 +117,36 @@ const INTERACTIVE_MODE_ALIASES = new Map([
104
117
  ['recycle_maintain', MODES.RECYCLE_MAINTAIN],
105
118
  ['recycle-maintain', MODES.RECYCLE_MAINTAIN],
106
119
  ['doctor', MODES.DOCTOR],
120
+ ['check_update', MODES.CHECK_UPDATE],
121
+ ['check-update', MODES.CHECK_UPDATE],
107
122
  ['settings', MODES.SETTINGS],
108
123
  ]);
109
124
  const OUTPUT_JSON = 'json';
110
125
  const OUTPUT_TEXT = 'text';
126
+ const RUN_TASK_PREVIEW = 'preview';
127
+ const RUN_TASK_EXECUTE = 'execute';
128
+ const RUN_TASK_PREVIEW_EXECUTE_VERIFY = 'preview-execute-verify';
129
+ const RUN_TASK_MODES = new Set([RUN_TASK_PREVIEW, RUN_TASK_EXECUTE, RUN_TASK_PREVIEW_EXECUTE_VERIFY]);
130
+ const SCAN_DEBUG_OFF = 'off';
131
+ const SCAN_DEBUG_SUMMARY = 'summary';
132
+ const SCAN_DEBUG_FULL = 'full';
133
+ const SCAN_DEBUG_LEVELS = new Set([SCAN_DEBUG_OFF, SCAN_DEBUG_SUMMARY, SCAN_DEBUG_FULL]);
134
+ const DESTRUCTIVE_ACTIONS = new Set([
135
+ MODES.CLEANUP_MONTHLY,
136
+ MODES.SPACE_GOVERNANCE,
137
+ MODES.RESTORE,
138
+ MODES.RECYCLE_MAINTAIN,
139
+ ]);
140
+ const UPDATE_REPO_OWNER = 'MisonL';
141
+ const UPDATE_REPO_NAME = 'wecom-cleaner';
142
+ const UPDATE_TIMEOUT_MS = 2500;
143
+
144
+ function allowAutoUpdateByEnv() {
145
+ const raw = String(process.env.WECOM_CLEANER_AUTO_UPDATE || 'true')
146
+ .trim()
147
+ .toLowerCase();
148
+ return !['0', 'false', 'no', 'off'].includes(raw);
149
+ }
111
150
 
112
151
  function isBackCommand(inputValue) {
113
152
  const normalized = String(inputValue || '')
@@ -1772,6 +1811,58 @@ function uniqueStrings(values = []) {
1772
1811
  return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))];
1773
1812
  }
1774
1813
 
1814
+ function normalizeRunTaskMode(rawMode) {
1815
+ const value = String(rawMode || '')
1816
+ .trim()
1817
+ .toLowerCase();
1818
+ if (!value) {
1819
+ return null;
1820
+ }
1821
+ if (!RUN_TASK_MODES.has(value)) {
1822
+ throw new UsageError(`参数 --run-task 的值无效: ${rawMode}`);
1823
+ }
1824
+ return value;
1825
+ }
1826
+
1827
+ function normalizeScanDebugLevel(rawLevel) {
1828
+ const value = String(rawLevel || SCAN_DEBUG_OFF)
1829
+ .trim()
1830
+ .toLowerCase();
1831
+ if (!value) {
1832
+ return SCAN_DEBUG_OFF;
1833
+ }
1834
+ if (!SCAN_DEBUG_LEVELS.has(value)) {
1835
+ throw new UsageError(`参数 --scan-debug 的值无效: ${rawLevel}`);
1836
+ }
1837
+ return value;
1838
+ }
1839
+
1840
+ function isDestructiveAction(action) {
1841
+ return DESTRUCTIVE_ACTIONS.has(action);
1842
+ }
1843
+
1844
+ function shouldAttachScanDebug(cliArgs) {
1845
+ return normalizeScanDebugLevel(cliArgs?.scanDebug) !== SCAN_DEBUG_OFF;
1846
+ }
1847
+
1848
+ function attachScanDebugData(baseData, cliArgs, summaryPayload, fullPayload = {}) {
1849
+ const level = normalizeScanDebugLevel(cliArgs?.scanDebug);
1850
+ if (level === SCAN_DEBUG_OFF) {
1851
+ return baseData || {};
1852
+ }
1853
+ const summary = summaryPayload && typeof summaryPayload === 'object' ? summaryPayload : {};
1854
+ const full = fullPayload && typeof fullPayload === 'object' ? fullPayload : {};
1855
+ return {
1856
+ ...(baseData || {}),
1857
+ scanDebug: {
1858
+ level,
1859
+ summary,
1860
+ ...(level === SCAN_DEBUG_FULL ? { full } : {}),
1861
+ generatedAt: Date.now(),
1862
+ },
1863
+ };
1864
+ }
1865
+
1775
1866
  function normalizeActionOutputMode(cliArgs) {
1776
1867
  if (cliArgs.output === OUTPUT_TEXT || cliArgs.output === OUTPUT_JSON) {
1777
1868
  return cliArgs.output;
@@ -1798,6 +1889,12 @@ function actionFlagName(action) {
1798
1889
  if (action === MODES.DOCTOR) {
1799
1890
  return '--doctor';
1800
1891
  }
1892
+ if (action === MODES.CHECK_UPDATE) {
1893
+ return '--check-update';
1894
+ }
1895
+ if (action === MODES.UPGRADE) {
1896
+ return '--upgrade <npm|github-script>';
1897
+ }
1801
1898
  return `--${String(action || '').replace(/_/g, '-')}`;
1802
1899
  }
1803
1900
 
@@ -1809,7 +1906,7 @@ function resolveActionFromCli(cliArgs, hasAnyArgs) {
1809
1906
  throw new UsageError(
1810
1907
  [
1811
1908
  '无交互模式必须指定一个动作参数:',
1812
- '--cleanup-monthly | --analysis-only | --space-governance | --restore-batch <batchId> | --recycle-maintain | --doctor',
1909
+ '--cleanup-monthly | --analysis-only | --space-governance | --restore-batch <batchId> | --recycle-maintain | --doctor | --check-update | --upgrade <npm|github-script>',
1813
1910
  ].join('\n')
1814
1911
  );
1815
1912
  }
@@ -1832,15 +1929,22 @@ function printCliUsage(appMeta) {
1832
1929
  ' --restore-batch <batchId>',
1833
1930
  ' --recycle-maintain',
1834
1931
  ' --doctor',
1932
+ ' --check-update',
1933
+ ' --upgrade <npm|github-script>',
1835
1934
  '',
1836
1935
  '常用选项:',
1837
1936
  ' --output json|text',
1838
1937
  ' --dry-run true|false',
1839
1938
  ' --yes',
1939
+ ' --run-task preview|execute|preview-execute-verify',
1940
+ ' --scan-debug off|summary|full',
1840
1941
  ' --accounts all|current|id1,id2',
1841
1942
  ' --months YYYY-MM,YYYY-MM',
1842
1943
  ' --cutoff-month YYYY-MM',
1843
1944
  ' --categories key1,key2',
1945
+ ' --upgrade-version x.y.z',
1946
+ ' --upgrade-channel stable|pre',
1947
+ ' --upgrade-yes',
1844
1948
  ' --root <path>',
1845
1949
  ' --state-root <path>',
1846
1950
  '',
@@ -1851,6 +1955,7 @@ function printCliUsage(appMeta) {
1851
1955
  '示例:',
1852
1956
  ' wecom-cleaner --doctor',
1853
1957
  ' wecom-cleaner --cleanup-monthly --accounts all --cutoff-month 2024-04',
1958
+ ' wecom-cleaner --cleanup-monthly --cutoff-month 2024-04 --accounts all --run-task preview-execute-verify --yes',
1854
1959
  ' wecom-cleaner --cleanup-monthly --accounts all --cutoff-month 2024-04 --dry-run false --yes',
1855
1960
  ];
1856
1961
  console.log(lines.join('\n'));
@@ -1869,6 +1974,9 @@ function resolveInteractiveStartMode(cliArgs) {
1869
1974
  }
1870
1975
 
1871
1976
  if (NON_INTERACTIVE_ACTIONS.has(cliArgs.action)) {
1977
+ if (cliArgs.action === MODES.UPGRADE) {
1978
+ throw new UsageError('参数 --upgrade 仅支持无交互模式,请移除 --interactive 后重试。');
1979
+ }
1872
1980
  return cliArgs.action;
1873
1981
  }
1874
1982
  return MODES.START;
@@ -2120,6 +2228,180 @@ function toStructuredError(item = {}, fallbackCode = 'E_ACTION_FAILED') {
2120
2228
  };
2121
2229
  }
2122
2230
 
2231
+ function summarizeDimensionRows(rows, { labelKey = 'label', countKey = 'targetCount' } = {}, limit = 20) {
2232
+ const list = Array.isArray(rows) ? rows : [];
2233
+ return list.slice(0, limit).map((row) => ({
2234
+ label:
2235
+ row?.[labelKey] ||
2236
+ row?.categoryLabel ||
2237
+ row?.targetLabel ||
2238
+ row?.monthKey ||
2239
+ row?.rootPath ||
2240
+ row?.accountShortId ||
2241
+ '-',
2242
+ count: Number(row?.[countKey] || row?.count || 0),
2243
+ sizeBytes: Number(row?.sizeBytes || 0),
2244
+ }));
2245
+ }
2246
+
2247
+ function buildUserFacingSummary(action, result) {
2248
+ const summary = result?.summary || {};
2249
+ const report = result?.data?.report || {};
2250
+ const matched = report?.matched || {};
2251
+
2252
+ if (action === MODES.CLEANUP_MONTHLY) {
2253
+ return {
2254
+ scope: {
2255
+ accountCount: Number(summary.accountCount || 0),
2256
+ monthCount: Number(summary.monthCount || 0),
2257
+ categoryCount: Number(summary.categoryCount || 0),
2258
+ rootPathCount: Number(summary.rootPathCount || 0),
2259
+ cutoffMonth: summary.cutoffMonth || null,
2260
+ monthRange: {
2261
+ from: summary.matchedMonthStart || matched?.monthRange?.from || null,
2262
+ to: summary.matchedMonthEnd || matched?.monthRange?.to || null,
2263
+ },
2264
+ },
2265
+ result: {
2266
+ noTarget: Boolean(summary.noTarget),
2267
+ matchedTargets: Number(summary.matchedTargets || 0),
2268
+ matchedBytes: Number(summary.matchedBytes || 0),
2269
+ reclaimedBytes: Number(summary.reclaimedBytes || 0),
2270
+ successCount: Number(summary.successCount || 0),
2271
+ skippedCount: Number(summary.skippedCount || 0),
2272
+ failedCount: Number(summary.failedCount || 0),
2273
+ batchId: summary.batchId || null,
2274
+ },
2275
+ byMonth: summarizeDimensionRows(matched.monthStats, { labelKey: 'monthKey' }),
2276
+ byCategory: summarizeDimensionRows(matched.categoryStats, { labelKey: 'categoryLabel' }),
2277
+ byRoot: summarizeDimensionRows(matched.rootStats, { labelKey: 'rootPath' }),
2278
+ };
2279
+ }
2280
+
2281
+ if (action === MODES.ANALYSIS_ONLY) {
2282
+ return {
2283
+ scope: {
2284
+ accountCount: Number(summary.accountCount || 0),
2285
+ matchedAccountCount: Number(summary.matchedAccountCount || 0),
2286
+ categoryCount: Number(summary.categoryCount || 0),
2287
+ monthBucketCount: Number(summary.monthBucketCount || 0),
2288
+ },
2289
+ result: {
2290
+ targetCount: Number(summary.targetCount || 0),
2291
+ totalBytes: Number(summary.totalBytes || 0),
2292
+ },
2293
+ byMonth: summarizeDimensionRows(matched.monthStats, { labelKey: 'monthKey' }),
2294
+ byCategory: summarizeDimensionRows(matched.categoryStats, { labelKey: 'categoryLabel' }),
2295
+ byRoot: summarizeDimensionRows(matched.rootStats, { labelKey: 'rootPath' }),
2296
+ };
2297
+ }
2298
+
2299
+ if (action === MODES.SPACE_GOVERNANCE) {
2300
+ return {
2301
+ scope: {
2302
+ accountCount: Number(summary.accountCount || 0),
2303
+ tierCount: Number(summary.tierCount || 0),
2304
+ targetTypeCount: Number(summary.targetTypeCount || 0),
2305
+ rootPathCount: Number(summary.rootPathCount || 0),
2306
+ },
2307
+ result: {
2308
+ noTarget: Boolean(summary.noTarget),
2309
+ matchedTargets: Number(summary.matchedTargets || 0),
2310
+ matchedBytes: Number(summary.matchedBytes || 0),
2311
+ reclaimedBytes: Number(summary.reclaimedBytes || 0),
2312
+ successCount: Number(summary.successCount || 0),
2313
+ skippedCount: Number(summary.skippedCount || 0),
2314
+ failedCount: Number(summary.failedCount || 0),
2315
+ batchId: summary.batchId || null,
2316
+ },
2317
+ byTier: summarizeDimensionRows(matched.byTier, { labelKey: 'tierLabel' }),
2318
+ byCategory: summarizeDimensionRows(matched.byTargetType, { labelKey: 'targetLabel' }),
2319
+ byRoot: summarizeDimensionRows(matched.byRoot, { labelKey: 'rootPath' }),
2320
+ };
2321
+ }
2322
+
2323
+ if (action === MODES.RESTORE) {
2324
+ return {
2325
+ scope: {
2326
+ entryCount: Number(summary.entryCount || 0),
2327
+ conflictStrategy: summary.conflictStrategy || null,
2328
+ rootPathCount: Number(summary.rootPathCount || 0),
2329
+ },
2330
+ result: {
2331
+ batchId: summary.batchId || null,
2332
+ matchedBytes: Number(summary.matchedBytes || 0),
2333
+ restoredBytes: Number(summary.restoredBytes || 0),
2334
+ successCount: Number(summary.successCount || 0),
2335
+ skippedCount: Number(summary.skippedCount || 0),
2336
+ failedCount: Number(summary.failedCount || 0),
2337
+ },
2338
+ byMonth: summarizeDimensionRows(matched.byMonth, { labelKey: 'monthKey' }),
2339
+ byCategory: summarizeDimensionRows(matched.byCategory, { labelKey: 'categoryLabel' }),
2340
+ byRoot: summarizeDimensionRows(matched.byRoot, { labelKey: 'rootPath' }),
2341
+ };
2342
+ }
2343
+
2344
+ if (action === MODES.RECYCLE_MAINTAIN) {
2345
+ return {
2346
+ scope: {
2347
+ candidateCount: Number(summary.candidateCount || 0),
2348
+ selectedByAge: Number(summary.selectedByAge || 0),
2349
+ selectedBySize: Number(summary.selectedBySize || 0),
2350
+ },
2351
+ result: {
2352
+ status: summary.status || null,
2353
+ deletedBatches: Number(summary.deletedBatches || 0),
2354
+ deletedBytes: Number(summary.deletedBytes || 0),
2355
+ failedBatches: Number(summary.failedBatches || 0),
2356
+ remainingBatches: Number(summary.remainingBatches || 0),
2357
+ remainingBytes: Number(summary.remainingBytes || 0),
2358
+ },
2359
+ };
2360
+ }
2361
+
2362
+ if (action === MODES.DOCTOR) {
2363
+ return {
2364
+ scope: {
2365
+ platform: result?.data?.runtime?.targetTag || null,
2366
+ },
2367
+ result: {
2368
+ overall: summary.overall || null,
2369
+ pass: Number(summary.pass || 0),
2370
+ warn: Number(summary.warn || 0),
2371
+ fail: Number(summary.fail || 0),
2372
+ },
2373
+ };
2374
+ }
2375
+
2376
+ if (action === MODES.CHECK_UPDATE) {
2377
+ return {
2378
+ result: {
2379
+ checked: Boolean(summary.checked),
2380
+ hasUpdate: Boolean(summary.hasUpdate),
2381
+ currentVersion: summary.currentVersion || null,
2382
+ latestVersion: summary.latestVersion || null,
2383
+ source: summary.source || null,
2384
+ channel: summary.channel || null,
2385
+ },
2386
+ };
2387
+ }
2388
+
2389
+ if (action === MODES.UPGRADE) {
2390
+ return {
2391
+ result: {
2392
+ executed: Boolean(summary.executed),
2393
+ method: summary.method || null,
2394
+ targetVersion: summary.targetVersion || null,
2395
+ status: hasDisplayValue(summary.status) ? Number(summary.status) : null,
2396
+ },
2397
+ };
2398
+ }
2399
+
2400
+ return {
2401
+ result: summary,
2402
+ };
2403
+ }
2404
+
2123
2405
  const ACTION_DISPLAY_NAMES = new Map([
2124
2406
  [MODES.CLEANUP_MONTHLY, '年月清理'],
2125
2407
  [MODES.ANALYSIS_ONLY, '会话分析(只读)'],
@@ -2127,6 +2409,8 @@ const ACTION_DISPLAY_NAMES = new Map([
2127
2409
  [MODES.RESTORE, '恢复已删除批次'],
2128
2410
  [MODES.RECYCLE_MAINTAIN, '回收区治理'],
2129
2411
  [MODES.DOCTOR, '系统自检'],
2412
+ [MODES.CHECK_UPDATE, '检查更新'],
2413
+ [MODES.UPGRADE, '程序升级'],
2130
2414
  ]);
2131
2415
 
2132
2416
  const CONFLICT_STRATEGY_DISPLAY = new Map([
@@ -2744,6 +3028,61 @@ function printDoctorTextResult(payload) {
2744
3028
  printRuntimeAndRisk(payload);
2745
3029
  }
2746
3030
 
3031
+ function printCheckUpdateTextResult(payload) {
3032
+ const summary = payload.summary || {};
3033
+ const update = payload.data?.update || {};
3034
+ const conclusion = summary.hasUpdate
3035
+ ? `检测到新版本 v${summary.latestVersion},可选择 npm 或 GitHub 脚本升级。`
3036
+ : summary.checked
3037
+ ? '当前已是最新版本。'
3038
+ : '本次更新检查失败,请稍后重试。';
3039
+
3040
+ printTextRows('任务结论', [
3041
+ { label: '动作', value: actionDisplayName(payload.action) },
3042
+ { label: '结论', value: conclusion },
3043
+ { label: '状态', value: summary.checked ? '检查完成' : '检查失败' },
3044
+ ]);
3045
+
3046
+ printTextRows('检查结果', [
3047
+ { label: '当前版本', value: summary.currentVersion || '-' },
3048
+ { label: '最新版本', value: summary.latestVersion || '-' },
3049
+ { label: '来源', value: summary.source || '-' },
3050
+ { label: '通道', value: channelLabel(summary.channel) },
3051
+ { label: '跳过提醒', value: formatYesNo(Boolean(summary.skippedByUser)) },
3052
+ { label: '检查时间', value: formatLocalDate(update.checkedAt || Date.now()) },
3053
+ ]);
3054
+
3055
+ printRuntimeAndRisk(payload);
3056
+ }
3057
+
3058
+ function printUpgradeTextResult(payload) {
3059
+ const summary = payload.summary || {};
3060
+ const upgrade = payload.data?.upgrade || {};
3061
+ const executed = Boolean(summary.executed);
3062
+ const conclusion = !executed
3063
+ ? summary.reason === 'already_latest'
3064
+ ? '当前已是最新版本,无需执行升级。'
3065
+ : '升级前置检查失败,未执行升级。'
3066
+ : payload.ok
3067
+ ? '升级已执行成功,建议重启程序后继续使用。'
3068
+ : '升级执行失败,请按错误提示排查。';
3069
+
3070
+ printTextRows('任务结论', [
3071
+ { label: '动作', value: actionDisplayName(payload.action) },
3072
+ { label: '结论', value: conclusion },
3073
+ { label: '升级方式', value: summary.method || '-' },
3074
+ { label: '目标版本', value: summary.targetVersion || '-' },
3075
+ ]);
3076
+
3077
+ printTextRows('执行明细', [
3078
+ { label: '已执行升级', value: formatYesNo(executed) },
3079
+ { label: '退出码', value: hasDisplayValue(summary.status) ? summary.status : '-' },
3080
+ { label: '命令', value: summary.command || upgrade.command || '-' },
3081
+ ]);
3082
+
3083
+ printRuntimeAndRisk(payload);
3084
+ }
3085
+
2747
3086
  function printGenericTextResult(payload) {
2748
3087
  printTextRows('任务结论', [
2749
3088
  { label: '动作', value: actionDisplayName(payload.action) },
@@ -2757,7 +3096,37 @@ function printGenericTextResult(payload) {
2757
3096
  printRuntimeAndRisk(payload);
2758
3097
  }
2759
3098
 
3099
+ function printTaskPhasesText(payload) {
3100
+ const phases = Array.isArray(payload?.data?.taskPhases) ? payload.data.taskPhases : [];
3101
+ if (phases.length === 0) {
3102
+ return;
3103
+ }
3104
+ printTextRows(
3105
+ '任务流程',
3106
+ phases.map((phase) => {
3107
+ const summaryText =
3108
+ phase.status === 'completed'
3109
+ ? `命中 ${formatCount(phase?.stats?.matchedTargets)} 项,成功 ${formatCount(phase?.stats?.successCount)} 项,失败 ${formatCount(phase?.stats?.failedCount)} 项,释放 ${formatBytesSafe(phase?.stats?.reclaimedBytes)}`
3110
+ : `已跳过(${phase.reason || '无'})`;
3111
+ return {
3112
+ label: phase.name,
3113
+ value: `${phase.status}${phase.ok === false ? '(失败)' : ''}`,
3114
+ note: summaryText,
3115
+ };
3116
+ })
3117
+ );
3118
+ const taskCard = payload?.data?.taskCard;
3119
+ if (taskCard && typeof taskCard === 'object') {
3120
+ printTextRows('流程结论', [
3121
+ { label: '模式', value: taskCard.mode || '-' },
3122
+ { label: '决策', value: taskCard.decision || '-' },
3123
+ { label: '结论', value: taskCard.conclusion || '-' },
3124
+ ]);
3125
+ }
3126
+ }
3127
+
2760
3128
  function printNonInteractiveTextResult(payload) {
3129
+ printTaskPhasesText(payload);
2761
3130
  if (payload.action === MODES.CLEANUP_MONTHLY) {
2762
3131
  printCleanupTextResult(payload);
2763
3132
  return;
@@ -2782,6 +3151,14 @@ function printNonInteractiveTextResult(payload) {
2782
3151
  printDoctorTextResult(payload);
2783
3152
  return;
2784
3153
  }
3154
+ if (payload.action === MODES.CHECK_UPDATE) {
3155
+ printCheckUpdateTextResult(payload);
3156
+ return;
3157
+ }
3158
+ if (payload.action === MODES.UPGRADE) {
3159
+ printUpgradeTextResult(payload);
3160
+ return;
3161
+ }
2785
3162
  printGenericTextResult(payload);
2786
3163
  }
2787
3164
 
@@ -2846,6 +3223,32 @@ async function runCleanupModeNonInteractive(context, cliArgs, warnings = []) {
2846
3223
  const targets = scan.targets || [];
2847
3224
  const matchedBytes = targets.reduce((total, item) => total + Number(item?.sizeBytes || 0), 0);
2848
3225
  const matchedReport = buildCleanupTargetReport(targets, { topPathLimit: 20 });
3226
+ const scanDebugSummary = {
3227
+ action: MODES.CLEANUP_MONTHLY,
3228
+ engineReady: context.nativeCorePath ? 'zig' : 'node',
3229
+ engineUsed: scan.engineUsed || 'node',
3230
+ nativeFallbackReason: scan.nativeFallbackReason || null,
3231
+ selectedAccountCount: accountResolved.selectedAccountIds.length,
3232
+ availableMonthCount: availableMonths.length,
3233
+ selectedMonthCount: monthFilters.length,
3234
+ selectedCategoryCount: categoryKeys.length,
3235
+ selectedExternalRootCount: externalResolved.roots.length,
3236
+ includeNonMonthDirs,
3237
+ matchedTargets: targets.length,
3238
+ matchedBytes,
3239
+ };
3240
+ const scanDebugFull = {
3241
+ selectedAccounts: accountResolved.selectedAccountIds,
3242
+ selectedMonths: monthFilters,
3243
+ selectedCategories: categoryKeys,
3244
+ availableMonths,
3245
+ selectedExternalRoots: externalResolved.roots,
3246
+ externalDetectionMeta: detectedExternalStorage.meta || null,
3247
+ matchedTopPaths: matchedReport.topPaths || [],
3248
+ matchedCategoryStats: matchedReport.categoryStats || [],
3249
+ matchedMonthStats: matchedReport.monthStats || [],
3250
+ matchedRootStats: matchedReport.rootStats || [],
3251
+ };
2849
3252
  if (targets.length === 0) {
2850
3253
  return {
2851
3254
  ok: true,
@@ -2879,6 +3282,7 @@ async function runCleanupModeNonInteractive(context, cliArgs, warnings = []) {
2879
3282
  matched: matchedReport,
2880
3283
  executed: null,
2881
3284
  },
3285
+ ...attachScanDebugData({}, cliArgs, scanDebugSummary, scanDebugFull),
2882
3286
  },
2883
3287
  };
2884
3288
  }
@@ -2918,6 +3322,7 @@ async function runCleanupModeNonInteractive(context, cliArgs, warnings = []) {
2918
3322
  matched: matchedReport,
2919
3323
  executed: result.breakdown || null,
2920
3324
  },
3325
+ ...attachScanDebugData({}, cliArgs, scanDebugSummary, scanDebugFull),
2921
3326
  },
2922
3327
  };
2923
3328
  }
@@ -2952,6 +3357,29 @@ async function runAnalysisModeNonInteractive(context, cliArgs, warnings = []) {
2952
3357
  warnings.push(result.nativeFallbackReason);
2953
3358
  }
2954
3359
  const analysisReport = buildCleanupTargetReport(result.targets, { topPathLimit: 20 });
3360
+ const scanDebugSummary = {
3361
+ action: MODES.ANALYSIS_ONLY,
3362
+ engineReady: context.nativeCorePath ? 'zig' : 'node',
3363
+ engineUsed: result.engineUsed || 'node',
3364
+ nativeFallbackReason: result.nativeFallbackReason || null,
3365
+ selectedAccountCount: accountResolved.selectedAccountIds.length,
3366
+ selectedCategoryCount: categoryKeys.length,
3367
+ selectedExternalRootCount: externalResolved.roots.length,
3368
+ matchedTargets: result.targets.length,
3369
+ matchedBytes: result.totalBytes,
3370
+ matchedAccountCount: result.accountsSummary.length,
3371
+ monthBucketCount: result.monthsSummary.length,
3372
+ };
3373
+ const scanDebugFull = {
3374
+ selectedAccounts: accountResolved.selectedAccountIds,
3375
+ selectedCategories: categoryKeys,
3376
+ selectedExternalRoots: externalResolved.roots,
3377
+ externalDetectionMeta: detectedExternalStorage.meta || null,
3378
+ accountsSummary: result.accountsSummary,
3379
+ categoriesSummary: result.categoriesSummary,
3380
+ monthsSummary: result.monthsSummary,
3381
+ matchedTopPaths: analysisReport.topPaths || [],
3382
+ };
2955
3383
 
2956
3384
  return {
2957
3385
  ok: true,
@@ -2978,6 +3406,7 @@ async function runAnalysisModeNonInteractive(context, cliArgs, warnings = []) {
2978
3406
  report: {
2979
3407
  matched: analysisReport,
2980
3408
  },
3409
+ ...attachScanDebugData({}, cliArgs, scanDebugSummary, scanDebugFull),
2981
3410
  },
2982
3411
  };
2983
3412
  }
@@ -3036,6 +3465,33 @@ async function runSpaceGovernanceModeNonInteractive(context, cliArgs, warnings =
3036
3465
  const allowRecentActive = cliArgs.allowRecentActive === true;
3037
3466
  const dryRun = resolveDestructiveDryRun(cliArgs);
3038
3467
  const matchedReport = buildGovernanceTargetReport(selectedTargets, { topPathLimit: 20 });
3468
+ const scanDebugSummary = {
3469
+ action: MODES.SPACE_GOVERNANCE,
3470
+ engineReady: context.nativeCorePath ? 'zig' : 'node',
3471
+ engineUsed: scan.engineUsed || 'node',
3472
+ nativeFallbackReason: scan.nativeFallbackReason || null,
3473
+ selectedAccountCount: accountResolved.selectedAccountIds.length,
3474
+ selectedExternalRootCount: externalResolved.roots.length,
3475
+ scannedTargets: scan.targets.length,
3476
+ selectableTargets: selectableTargets.length,
3477
+ selectedTargets: selectedTargets.length,
3478
+ selectedByCliTargets: selectedById.length,
3479
+ allowRecentActive,
3480
+ matchedBytes: matchedReport.totalBytes,
3481
+ };
3482
+ const scanDebugFull = {
3483
+ selectedAccounts: accountResolved.selectedAccountIds,
3484
+ selectedExternalRoots: externalResolved.roots,
3485
+ externalDetectionMeta: detectedExternalStorage.meta || null,
3486
+ tierFilters: tierFilterSet ? [...tierFilterSet] : [],
3487
+ cliSelectedTargets: selectedById,
3488
+ selectedTargetIds: selectedTargets.map((item) => item.id),
3489
+ matchedByTier: matchedReport.byTier || [],
3490
+ matchedByTargetType: matchedReport.byTargetType || [],
3491
+ matchedByRoot: matchedReport.byRoot || [],
3492
+ matchedTopPaths: matchedReport.topPaths || [],
3493
+ scanByTier: scan.byTier || [],
3494
+ };
3039
3495
  const governanceRoot = inferDataRootFromProfilesRoot(config.rootDir);
3040
3496
  const governanceAllowedRoots = governanceRoot
3041
3497
  ? [governanceRoot, ...externalResolved.roots]
@@ -3068,6 +3524,7 @@ async function runSpaceGovernanceModeNonInteractive(context, cliArgs, warnings =
3068
3524
  matched: matchedReport,
3069
3525
  executed: null,
3070
3526
  },
3527
+ ...attachScanDebugData({}, cliArgs, scanDebugSummary, scanDebugFull),
3071
3528
  },
3072
3529
  };
3073
3530
  }
@@ -3113,6 +3570,7 @@ async function runSpaceGovernanceModeNonInteractive(context, cliArgs, warnings =
3113
3570
  matched: matchedReport,
3114
3571
  executed: result.breakdown || null,
3115
3572
  },
3573
+ ...attachScanDebugData({}, cliArgs, scanDebugSummary, scanDebugFull),
3116
3574
  },
3117
3575
  };
3118
3576
  }
@@ -3158,6 +3616,25 @@ async function runRestoreModeNonInteractive(context, cliArgs, warnings = []) {
3158
3616
  onConflict: async () => ({ action: conflictStrategy, applyToAll: true }),
3159
3617
  });
3160
3618
  const matchedReport = buildRestoreBatchTargetReport(batch.entries, { topPathLimit: 20 });
3619
+ const scanDebugSummary = {
3620
+ action: MODES.RESTORE,
3621
+ selectedExternalRootCount: externalResolved.roots.length,
3622
+ governanceRoot: governanceRoot || null,
3623
+ batchEntryCount: batch.entries.length,
3624
+ matchedBytes: matchedReport.totalBytes,
3625
+ dryRun,
3626
+ };
3627
+ const scanDebugFull = {
3628
+ batchId: batch.batchId,
3629
+ conflictStrategy,
3630
+ selectedExternalRoots: externalResolved.roots,
3631
+ governanceAllowRoots: governanceAllowRoots,
3632
+ matchedByScope: matchedReport.byScope || [],
3633
+ matchedByCategory: matchedReport.byCategory || [],
3634
+ matchedByMonth: matchedReport.byMonth || [],
3635
+ matchedByRoot: matchedReport.byRoot || [],
3636
+ topEntries: matchedReport.topEntries || [],
3637
+ };
3161
3638
 
3162
3639
  return {
3163
3640
  ok: result.failCount === 0,
@@ -3185,6 +3662,7 @@ async function runRestoreModeNonInteractive(context, cliArgs, warnings = []) {
3185
3662
  matched: matchedReport,
3186
3663
  executed: result.breakdown || null,
3187
3664
  },
3665
+ ...attachScanDebugData({}, cliArgs, scanDebugSummary, scanDebugFull),
3188
3666
  },
3189
3667
  };
3190
3668
  }
@@ -3226,6 +3704,25 @@ async function runRecycleMaintainModeNonInteractive(context, cliArgs, warnings =
3226
3704
  };
3227
3705
  await saveConfig(config);
3228
3706
  }
3707
+ const scanDebugSummary = {
3708
+ action: MODES.RECYCLE_MAINTAIN,
3709
+ dryRun,
3710
+ candidateCount: result.candidateCount,
3711
+ selectedByAge: result.selectedByAge,
3712
+ selectedBySize: result.selectedBySize,
3713
+ deletedBatches: result.deletedBatches,
3714
+ deletedBytes: result.deletedBytes,
3715
+ failedBatches: result.failBatches,
3716
+ };
3717
+ const scanDebugFull = {
3718
+ policy,
3719
+ before: result.before || null,
3720
+ after: result.after || null,
3721
+ thresholdBytes: result.thresholdBytes,
3722
+ overThreshold: result.overThreshold,
3723
+ selectedCandidates: result.selectedCandidates || [],
3724
+ operations: result.operations || [],
3725
+ };
3229
3726
 
3230
3727
  return {
3231
3728
  ok: result.failBatches === 0,
@@ -3259,6 +3756,7 @@ async function runRecycleMaintainModeNonInteractive(context, cliArgs, warnings =
3259
3756
  selectedCandidates: result.selectedCandidates || [],
3260
3757
  operations: result.operations || [],
3261
3758
  },
3759
+ ...attachScanDebugData({}, cliArgs, scanDebugSummary, scanDebugFull),
3262
3760
  },
3263
3761
  };
3264
3762
  }
@@ -3287,6 +3785,337 @@ async function runDoctorModeNonInteractive(context, _cliArgs, warnings = []) {
3287
3785
  };
3288
3786
  }
3289
3787
 
3788
+ function resolveUpdateChannel(cliArgs, config) {
3789
+ const fallback = normalizeSelfUpdateConfig(config?.selfUpdate).channel;
3790
+ return normalizeUpgradeChannel(cliArgs.upgradeChannel, fallback);
3791
+ }
3792
+
3793
+ function buildUpdateData(result, skipVersion = '') {
3794
+ const data = result && typeof result === 'object' ? result : {};
3795
+ return {
3796
+ checked: Boolean(data.checked),
3797
+ currentVersion: data.currentVersion || '',
3798
+ latestVersion: data.latestVersion || null,
3799
+ hasUpdate: Boolean(data.hasUpdate),
3800
+ sourceUsed: data.sourceUsed || 'none',
3801
+ channel: data.channel || 'stable',
3802
+ checkReason: data.checkReason || 'manual',
3803
+ checkedAt: Number(data.checkedAt || Date.now()),
3804
+ skippedByUser: shouldSkipVersion(data, skipVersion),
3805
+ errors: Array.isArray(data.errors) ? data.errors : [],
3806
+ upgradeMethods: Array.isArray(data.upgradeMethods) ? data.upgradeMethods : ['npm', 'github-script'],
3807
+ };
3808
+ }
3809
+
3810
+ async function persistSelfUpdateState(context) {
3811
+ if (context.readOnlyConfig) {
3812
+ return;
3813
+ }
3814
+ await saveConfig(context.config).catch(() => {});
3815
+ }
3816
+
3817
+ async function runCheckUpdateModeNonInteractive(context, cliArgs, warnings = []) {
3818
+ const channel = resolveUpdateChannel(cliArgs, context.config);
3819
+ const checkResult = await checkLatestVersion({
3820
+ currentVersion: context.appMeta?.version || '0.0.0',
3821
+ packageName: PACKAGE_NAME,
3822
+ githubOwner: UPDATE_REPO_OWNER,
3823
+ githubRepo: UPDATE_REPO_NAME,
3824
+ channel,
3825
+ timeoutMs: UPDATE_TIMEOUT_MS,
3826
+ reason: 'manual',
3827
+ });
3828
+ const normalizedSelfUpdate = normalizeSelfUpdateConfig({
3829
+ ...context.config.selfUpdate,
3830
+ channel,
3831
+ });
3832
+ context.config.selfUpdate = applyUpdateCheckResult(normalizedSelfUpdate, checkResult, '');
3833
+ await persistSelfUpdateState(context);
3834
+ const updateData = buildUpdateData(checkResult, context.config.selfUpdate.skipVersion);
3835
+
3836
+ if (updateData.hasUpdate && !updateData.skippedByUser) {
3837
+ warnings.push(updateWarningMessage(updateData, context.config.selfUpdate.skipVersion));
3838
+ }
3839
+
3840
+ return {
3841
+ ok: updateData.checked,
3842
+ action: MODES.CHECK_UPDATE,
3843
+ dryRun: null,
3844
+ summary: {
3845
+ checked: updateData.checked,
3846
+ hasUpdate: updateData.hasUpdate,
3847
+ currentVersion: updateData.currentVersion || '-',
3848
+ latestVersion: updateData.latestVersion || '-',
3849
+ source: updateData.sourceUsed,
3850
+ channel: updateData.channel,
3851
+ skippedByUser: updateData.skippedByUser,
3852
+ },
3853
+ warnings: uniqueStrings(warnings),
3854
+ errors: updateData.errors.map((message) => ({
3855
+ code: 'E_UPDATE_CHECK_FAILED',
3856
+ message,
3857
+ })),
3858
+ data: {
3859
+ update: updateData,
3860
+ },
3861
+ };
3862
+ }
3863
+
3864
+ async function runUpgradeModeNonInteractive(context, cliArgs, warnings = []) {
3865
+ const method = String(cliArgs.upgradeMethod || '').trim();
3866
+ if (!method) {
3867
+ throw new UsageError('参数 --upgrade 缺少升级方式(npm|github-script)');
3868
+ }
3869
+ if (!cliArgs.upgradeYes) {
3870
+ throw new ConfirmationRequiredError('检测到升级请求,但未提供 --upgrade-yes 确认参数。');
3871
+ }
3872
+
3873
+ const channel = resolveUpdateChannel(cliArgs, context.config);
3874
+ let targetVersion = String(cliArgs.upgradeVersion || '').trim();
3875
+ let checkResult = null;
3876
+
3877
+ if (!targetVersion) {
3878
+ checkResult = await checkLatestVersion({
3879
+ currentVersion: context.appMeta?.version || '0.0.0',
3880
+ packageName: PACKAGE_NAME,
3881
+ githubOwner: UPDATE_REPO_OWNER,
3882
+ githubRepo: UPDATE_REPO_NAME,
3883
+ channel,
3884
+ timeoutMs: UPDATE_TIMEOUT_MS,
3885
+ reason: 'manual',
3886
+ });
3887
+
3888
+ if (!checkResult.checked) {
3889
+ return {
3890
+ ok: false,
3891
+ action: MODES.UPGRADE,
3892
+ dryRun: null,
3893
+ summary: {
3894
+ executed: false,
3895
+ method,
3896
+ targetVersion: '-',
3897
+ reason: 'check_failed',
3898
+ },
3899
+ warnings: uniqueStrings(warnings),
3900
+ errors: (checkResult.errors || []).map((message) => ({
3901
+ code: 'E_UPGRADE_CHECK_FAILED',
3902
+ message,
3903
+ })),
3904
+ data: {
3905
+ update: buildUpdateData(checkResult, context.config.selfUpdate.skipVersion),
3906
+ },
3907
+ };
3908
+ }
3909
+ if (!checkResult.hasUpdate) {
3910
+ return {
3911
+ ok: true,
3912
+ action: MODES.UPGRADE,
3913
+ dryRun: null,
3914
+ summary: {
3915
+ executed: false,
3916
+ method,
3917
+ targetVersion: checkResult.currentVersion || '-',
3918
+ reason: 'already_latest',
3919
+ },
3920
+ warnings: uniqueStrings(warnings),
3921
+ errors: [],
3922
+ data: {
3923
+ update: buildUpdateData(checkResult, context.config.selfUpdate.skipVersion),
3924
+ },
3925
+ };
3926
+ }
3927
+ targetVersion = checkResult.latestVersion;
3928
+ }
3929
+
3930
+ const upgrade = runUpgrade({
3931
+ method,
3932
+ packageName: PACKAGE_NAME,
3933
+ targetVersion,
3934
+ githubOwner: UPDATE_REPO_OWNER,
3935
+ githubRepo: UPDATE_REPO_NAME,
3936
+ });
3937
+
3938
+ if (upgrade.ok) {
3939
+ context.config.selfUpdate = normalizeSelfUpdateConfig({
3940
+ ...context.config.selfUpdate,
3941
+ skipVersion: '',
3942
+ lastKnownLatest: targetVersion,
3943
+ lastKnownSource: upgrade.method,
3944
+ });
3945
+ await persistSelfUpdateState(context);
3946
+ }
3947
+
3948
+ return {
3949
+ ok: upgrade.ok,
3950
+ action: MODES.UPGRADE,
3951
+ dryRun: null,
3952
+ summary: {
3953
+ executed: true,
3954
+ method: upgrade.method,
3955
+ targetVersion: upgrade.targetVersion || targetVersion || '-',
3956
+ command: upgrade.command,
3957
+ status: upgrade.status,
3958
+ },
3959
+ warnings: uniqueStrings(warnings),
3960
+ errors: upgrade.ok
3961
+ ? []
3962
+ : [
3963
+ {
3964
+ code: 'E_UPGRADE_FAILED',
3965
+ message: upgrade.error || upgrade.stderr || 'upgrade_failed',
3966
+ },
3967
+ ],
3968
+ data: {
3969
+ update: checkResult ? buildUpdateData(checkResult, context.config.selfUpdate.skipVersion) : null,
3970
+ upgrade,
3971
+ },
3972
+ };
3973
+ }
3974
+
3975
+ function attachStartupUpdateToResult(result, startupUpdate, skipVersion) {
3976
+ if (!startupUpdate) {
3977
+ return result;
3978
+ }
3979
+ const output = result && typeof result === 'object' ? result : {};
3980
+ const warnings = uniqueStrings(Array.isArray(output.warnings) ? output.warnings : []);
3981
+ const updateData = buildUpdateData(startupUpdate, skipVersion);
3982
+ if (updateData.hasUpdate && !updateData.skippedByUser) {
3983
+ warnings.push(updateWarningMessage(updateData, skipVersion));
3984
+ }
3985
+ return {
3986
+ ...output,
3987
+ warnings: uniqueStrings(warnings),
3988
+ data: {
3989
+ ...(output.data || {}),
3990
+ update: updateData,
3991
+ },
3992
+ };
3993
+ }
3994
+
3995
+ async function maybeRunStartupUpdateCheck(context, cliArgs, action, interactiveMode) {
3996
+ if (!allowAutoUpdateByEnv()) {
3997
+ return null;
3998
+ }
3999
+ const selfUpdate = normalizeSelfUpdateConfig(context.config.selfUpdate);
4000
+ context.config.selfUpdate = selfUpdate;
4001
+
4002
+ if (!selfUpdate.enabled) {
4003
+ return null;
4004
+ }
4005
+ if (action === MODES.CHECK_UPDATE || action === MODES.UPGRADE) {
4006
+ return null;
4007
+ }
4008
+ if (!interactiveMode && action === MODES.DOCTOR) {
4009
+ return null;
4010
+ }
4011
+
4012
+ const decision = shouldCheckForUpdate(selfUpdate, Date.now());
4013
+ if (!decision.shouldCheck) {
4014
+ return null;
4015
+ }
4016
+
4017
+ const channel = resolveUpdateChannel(cliArgs, context.config);
4018
+ const checkResult = await checkLatestVersion({
4019
+ currentVersion: context.appMeta?.version || '0.0.0',
4020
+ packageName: PACKAGE_NAME,
4021
+ githubOwner: UPDATE_REPO_OWNER,
4022
+ githubRepo: UPDATE_REPO_NAME,
4023
+ channel,
4024
+ timeoutMs: UPDATE_TIMEOUT_MS,
4025
+ reason: 'startup_slot',
4026
+ });
4027
+ context.config.selfUpdate = applyUpdateCheckResult(
4028
+ normalizeSelfUpdateConfig({
4029
+ ...context.config.selfUpdate,
4030
+ channel,
4031
+ }),
4032
+ checkResult,
4033
+ decision.slot || ''
4034
+ );
4035
+ await persistSelfUpdateState(context);
4036
+ return buildUpdateData(checkResult, context.config.selfUpdate.skipVersion);
4037
+ }
4038
+
4039
+ async function maybePromptInteractiveUpgrade(context, startupUpdate) {
4040
+ if (!startupUpdate || !startupUpdate.hasUpdate || startupUpdate.skippedByUser) {
4041
+ return false;
4042
+ }
4043
+
4044
+ printSection('可用更新');
4045
+ printTextRows('更新提示', [
4046
+ {
4047
+ label: '版本',
4048
+ value: `当前 v${startupUpdate.currentVersion || '-'} -> 最新 v${startupUpdate.latestVersion || '-'}`,
4049
+ },
4050
+ { label: '来源', value: startupUpdate.sourceUsed || '-' },
4051
+ { label: '通道', value: channelLabel(startupUpdate.channel) },
4052
+ ]);
4053
+
4054
+ const choice = await askSelect({
4055
+ message: '检测到新版本,是否升级?',
4056
+ default: 'npm',
4057
+ choices: [
4058
+ { name: '通过 npm 升级(默认)', value: 'npm' },
4059
+ { name: '通过 GitHub 脚本升级', value: 'github-script' },
4060
+ { name: '稍后提醒', value: 'later' },
4061
+ { name: '跳过该版本(不再提醒)', value: 'skip-version' },
4062
+ ],
4063
+ });
4064
+
4065
+ if (choice === 'later') {
4066
+ return false;
4067
+ }
4068
+ if (choice === 'skip-version') {
4069
+ context.config.selfUpdate = normalizeSelfUpdateConfig({
4070
+ ...context.config.selfUpdate,
4071
+ skipVersion: startupUpdate.latestVersion || '',
4072
+ });
4073
+ await persistSelfUpdateState(context);
4074
+ console.log(`已记录:v${startupUpdate.latestVersion} 将不再自动提醒。`);
4075
+ return false;
4076
+ }
4077
+
4078
+ const confirmed = await askConfirm({
4079
+ message: `确认升级到 v${startupUpdate.latestVersion}?`,
4080
+ default: true,
4081
+ });
4082
+ if (!confirmed) {
4083
+ return false;
4084
+ }
4085
+
4086
+ const upgrade = runUpgrade({
4087
+ method: choice,
4088
+ packageName: PACKAGE_NAME,
4089
+ targetVersion: startupUpdate.latestVersion,
4090
+ githubOwner: UPDATE_REPO_OWNER,
4091
+ githubRepo: UPDATE_REPO_NAME,
4092
+ });
4093
+ if (!upgrade.ok) {
4094
+ printTextRows('升级结果', [
4095
+ { label: '状态', value: '失败' },
4096
+ { label: '方式', value: choice },
4097
+ { label: '命令', value: upgrade.command },
4098
+ { label: '错误', value: upgrade.error || upgrade.stderr || 'unknown_error' },
4099
+ ]);
4100
+ return false;
4101
+ }
4102
+
4103
+ context.config.selfUpdate = normalizeSelfUpdateConfig({
4104
+ ...context.config.selfUpdate,
4105
+ skipVersion: '',
4106
+ lastKnownLatest: startupUpdate.latestVersion || '',
4107
+ lastKnownSource: choice,
4108
+ });
4109
+ await persistSelfUpdateState(context);
4110
+ printTextRows('升级结果', [
4111
+ { label: '状态', value: '成功' },
4112
+ { label: '方式', value: choice },
4113
+ { label: '版本', value: `v${startupUpdate.latestVersion}` },
4114
+ ]);
4115
+ console.log('升级已完成,建议重新启动 wecom-cleaner 后继续。');
4116
+ return true;
4117
+ }
4118
+
3290
4119
  async function runNonInteractiveAction(action, context, cliArgs) {
3291
4120
  const warnings = [];
3292
4121
  if (cliArgs.actionFromMode) {
@@ -3311,9 +4140,360 @@ async function runNonInteractiveAction(action, context, cliArgs) {
3311
4140
  if (action === MODES.DOCTOR) {
3312
4141
  return runDoctorModeNonInteractive(context, cliArgs, warnings);
3313
4142
  }
4143
+ if (action === MODES.CHECK_UPDATE) {
4144
+ return runCheckUpdateModeNonInteractive(context, cliArgs, warnings);
4145
+ }
4146
+ if (action === MODES.UPGRADE) {
4147
+ return runUpgradeModeNonInteractive(context, cliArgs, warnings);
4148
+ }
3314
4149
  throw new UsageError(`不支持的无交互动作: ${action}`);
3315
4150
  }
3316
4151
 
4152
+ function phaseMatchedTargets(action, result) {
4153
+ const summary = result?.summary || {};
4154
+ if (action === MODES.CLEANUP_MONTHLY || action === MODES.SPACE_GOVERNANCE) {
4155
+ return Number(summary.matchedTargets || 0);
4156
+ }
4157
+ if (action === MODES.RESTORE) {
4158
+ return Number(summary.entryCount || 0);
4159
+ }
4160
+ if (action === MODES.RECYCLE_MAINTAIN) {
4161
+ return Number(summary.candidateCount || 0);
4162
+ }
4163
+ if (action === MODES.ANALYSIS_ONLY) {
4164
+ return Number(summary.targetCount || 0);
4165
+ }
4166
+ return 0;
4167
+ }
4168
+
4169
+ function phaseMatchedBytes(action, result) {
4170
+ const summary = result?.summary || {};
4171
+ if (action === MODES.CLEANUP_MONTHLY || action === MODES.SPACE_GOVERNANCE || action === MODES.RESTORE) {
4172
+ return Number(summary.matchedBytes || 0);
4173
+ }
4174
+ if (action === MODES.ANALYSIS_ONLY) {
4175
+ return Number(summary.totalBytes || 0);
4176
+ }
4177
+ if (action === MODES.RECYCLE_MAINTAIN) {
4178
+ return Number(summary.deletedBytes || 0);
4179
+ }
4180
+ return 0;
4181
+ }
4182
+
4183
+ function phaseReclaimedBytes(action, result) {
4184
+ const summary = result?.summary || {};
4185
+ if (action === MODES.RESTORE) {
4186
+ return Number(summary.restoredBytes || 0);
4187
+ }
4188
+ if (action === MODES.RECYCLE_MAINTAIN) {
4189
+ return Number(summary.deletedBytes || 0);
4190
+ }
4191
+ return Number(summary.reclaimedBytes || 0);
4192
+ }
4193
+
4194
+ function buildTaskPhaseEntry(action, phaseName, result, durationMs) {
4195
+ const summary = result?.summary || {};
4196
+ const warnings = Array.isArray(result?.warnings) ? result.warnings : [];
4197
+ const errors = Array.isArray(result?.errors) ? result.errors : [];
4198
+ return {
4199
+ name: phaseName,
4200
+ status: 'completed',
4201
+ ok: Boolean(result?.ok),
4202
+ dryRun: result?.dryRun ?? null,
4203
+ durationMs: Math.max(0, Number(durationMs || 0)),
4204
+ summary,
4205
+ warningCount: warnings.length,
4206
+ errorCount: errors.length,
4207
+ warnings,
4208
+ errors,
4209
+ stats: {
4210
+ matchedTargets: phaseMatchedTargets(action, result),
4211
+ matchedBytes: phaseMatchedBytes(action, result),
4212
+ reclaimedBytes: phaseReclaimedBytes(action, result),
4213
+ successCount: Number(summary.successCount || 0),
4214
+ skippedCount: Number(summary.skippedCount || 0),
4215
+ failedCount: Number(summary.failedCount || summary.failedBatches || 0),
4216
+ batchId: summary.batchId || null,
4217
+ },
4218
+ userFacingSummary: buildUserFacingSummary(action, result),
4219
+ };
4220
+ }
4221
+
4222
+ function buildSkippedTaskPhase(phaseName, reason) {
4223
+ return {
4224
+ name: phaseName,
4225
+ status: 'skipped',
4226
+ reason,
4227
+ ok: true,
4228
+ dryRun: null,
4229
+ durationMs: 0,
4230
+ summary: {},
4231
+ warningCount: 0,
4232
+ errorCount: 0,
4233
+ warnings: [],
4234
+ errors: [],
4235
+ stats: {
4236
+ matchedTargets: 0,
4237
+ matchedBytes: 0,
4238
+ reclaimedBytes: 0,
4239
+ successCount: 0,
4240
+ skippedCount: 0,
4241
+ failedCount: 0,
4242
+ batchId: null,
4243
+ },
4244
+ userFacingSummary: {},
4245
+ };
4246
+ }
4247
+
4248
+ function buildTaskCardBreakdown(action, report) {
4249
+ const matched = report?.matched || {};
4250
+ if (action === MODES.CLEANUP_MONTHLY || action === MODES.ANALYSIS_ONLY) {
4251
+ return {
4252
+ byCategory: summarizeDimensionRows(matched.categoryStats, { labelKey: 'categoryLabel' }, 16),
4253
+ byMonth: summarizeDimensionRows(matched.monthStats, { labelKey: 'monthKey' }, 16),
4254
+ byRoot: summarizeDimensionRows(matched.rootStats, { labelKey: 'rootPath' }, 12),
4255
+ topPaths: Array.isArray(matched.topPaths)
4256
+ ? matched.topPaths.slice(0, 12).map((item) => ({
4257
+ path: item.path || '-',
4258
+ category: item.categoryLabel || item.categoryKey || '-',
4259
+ month: item.monthKey || '非月份目录',
4260
+ sizeBytes: Number(item.sizeBytes || 0),
4261
+ }))
4262
+ : [],
4263
+ };
4264
+ }
4265
+ if (action === MODES.SPACE_GOVERNANCE) {
4266
+ return {
4267
+ byCategory: summarizeDimensionRows(matched.byTargetType, { labelKey: 'targetLabel' }, 16),
4268
+ byMonth: [],
4269
+ byRoot: summarizeDimensionRows(matched.byRoot, { labelKey: 'rootPath' }, 12),
4270
+ byTier: summarizeDimensionRows(matched.byTier, { labelKey: 'tierLabel' }, 8),
4271
+ topPaths: Array.isArray(matched.topPaths)
4272
+ ? matched.topPaths.slice(0, 12).map((item) => ({
4273
+ path: item.path || '-',
4274
+ category: item.targetLabel || item.targetKey || '-',
4275
+ month: '-',
4276
+ sizeBytes: Number(item.sizeBytes || 0),
4277
+ }))
4278
+ : [],
4279
+ };
4280
+ }
4281
+ if (action === MODES.RESTORE) {
4282
+ return {
4283
+ byCategory: summarizeDimensionRows(matched.byCategory, { labelKey: 'categoryLabel' }, 16),
4284
+ byMonth: summarizeDimensionRows(matched.byMonth, { labelKey: 'monthKey' }, 16),
4285
+ byRoot: summarizeDimensionRows(matched.byRoot, { labelKey: 'rootPath' }, 12),
4286
+ topPaths: Array.isArray(matched.topEntries)
4287
+ ? matched.topEntries.slice(0, 12).map((item) => ({
4288
+ path: item.originalPath || '-',
4289
+ category: item.categoryLabel || item.categoryKey || '-',
4290
+ month: item.monthKey || '非月份目录',
4291
+ sizeBytes: Number(item.sizeBytes || 0),
4292
+ }))
4293
+ : [],
4294
+ };
4295
+ }
4296
+ if (action === MODES.RECYCLE_MAINTAIN) {
4297
+ const operations = Array.isArray(report?.operations) ? report.operations : [];
4298
+ const byStatus = operations.reduce((acc, item) => {
4299
+ const key = String(item?.status || 'unknown');
4300
+ acc.set(key, (acc.get(key) || 0) + 1);
4301
+ return acc;
4302
+ }, new Map());
4303
+ return {
4304
+ byCategory: [...byStatus.entries()].map(([label, count]) => ({
4305
+ label,
4306
+ count: Number(count || 0),
4307
+ sizeBytes: 0,
4308
+ })),
4309
+ byMonth: [],
4310
+ byRoot: [],
4311
+ topPaths: [],
4312
+ };
4313
+ }
4314
+ return {
4315
+ byCategory: [],
4316
+ byMonth: [],
4317
+ byRoot: [],
4318
+ topPaths: [],
4319
+ };
4320
+ }
4321
+
4322
+ function buildRunTaskCard(action, runTaskMode, taskDecision, phases, finalResult) {
4323
+ const previewPhase = phases.find((item) => item.name === 'preview' && item.status === 'completed') || null;
4324
+ const executePhase = phases.find((item) => item.name === 'execute' && item.status === 'completed') || null;
4325
+ const verifyPhase = phases.find((item) => item.name === 'verify' && item.status === 'completed') || null;
4326
+ const report = finalResult?.data?.report || {};
4327
+ const breakdown = buildTaskCardBreakdown(action, report);
4328
+
4329
+ let conclusion = '任务已完成。';
4330
+ if (taskDecision === 'skipped_no_target') {
4331
+ conclusion = '预演命中为 0,已按安全策略跳过真实执行。';
4332
+ } else if (taskDecision === 'preview_only') {
4333
+ conclusion = '已完成预演,本次未执行真实操作。';
4334
+ } else if (taskDecision === 'execute_only' && executePhase) {
4335
+ conclusion = executePhase.ok ? '已完成真实执行。' : '已尝试真实执行,但存在失败项。';
4336
+ } else if (taskDecision === 'executed_and_verified') {
4337
+ conclusion =
4338
+ verifyPhase && Number(verifyPhase.stats.matchedTargets || 0) === 0
4339
+ ? '已完成真实执行并通过复核,范围内无剩余目标。'
4340
+ : '已完成真实执行与复核。';
4341
+ } else if (taskDecision === 'preview_failed') {
4342
+ conclusion = '预演阶段失败,后续阶段未执行。';
4343
+ }
4344
+
4345
+ return {
4346
+ action,
4347
+ actionLabel: actionDisplayName(action),
4348
+ mode: runTaskMode,
4349
+ decision: taskDecision,
4350
+ conclusion,
4351
+ phases: phases.map((item) => ({
4352
+ name: item.name,
4353
+ status: item.status,
4354
+ reason: item.reason || null,
4355
+ dryRun: item.dryRun,
4356
+ ok: item.ok,
4357
+ durationMs: item.durationMs,
4358
+ matchedTargets: Number(item?.stats?.matchedTargets || 0),
4359
+ matchedBytes: Number(item?.stats?.matchedBytes || 0),
4360
+ reclaimedBytes: Number(item?.stats?.reclaimedBytes || 0),
4361
+ successCount: Number(item?.stats?.successCount || 0),
4362
+ skippedCount: Number(item?.stats?.skippedCount || 0),
4363
+ failedCount: Number(item?.stats?.failedCount || 0),
4364
+ batchId: item?.stats?.batchId || null,
4365
+ })),
4366
+ scope: {
4367
+ accountCount: Number(finalResult?.summary?.accountCount || 0),
4368
+ monthCount: Number(finalResult?.summary?.monthCount || 0),
4369
+ categoryCount: Number(finalResult?.summary?.categoryCount || 0),
4370
+ rootPathCount: Number(finalResult?.summary?.rootPathCount || 0),
4371
+ cutoffMonth: finalResult?.summary?.cutoffMonth || null,
4372
+ selectedAccounts: uniqueStrings(finalResult?.data?.selectedAccounts || []),
4373
+ selectedMonths: uniqueStrings(finalResult?.data?.selectedMonths || []),
4374
+ selectedCategories: uniqueStrings(finalResult?.data?.selectedCategories || []),
4375
+ selectedExternalRoots: uniqueStrings(finalResult?.data?.selectedExternalRoots || []),
4376
+ },
4377
+ preview: previewPhase
4378
+ ? {
4379
+ matchedTargets: Number(previewPhase.stats.matchedTargets || 0),
4380
+ matchedBytes: Number(previewPhase.stats.matchedBytes || 0),
4381
+ reclaimedBytes: Number(previewPhase.stats.reclaimedBytes || 0),
4382
+ failedCount: Number(previewPhase.stats.failedCount || 0),
4383
+ }
4384
+ : null,
4385
+ execute: executePhase
4386
+ ? {
4387
+ successCount: Number(executePhase.stats.successCount || 0),
4388
+ skippedCount: Number(executePhase.stats.skippedCount || 0),
4389
+ failedCount: Number(executePhase.stats.failedCount || 0),
4390
+ reclaimedBytes: Number(executePhase.stats.reclaimedBytes || 0),
4391
+ batchId: executePhase.stats.batchId || null,
4392
+ }
4393
+ : null,
4394
+ verify: verifyPhase
4395
+ ? {
4396
+ matchedTargets: Number(verifyPhase.stats.matchedTargets || 0),
4397
+ matchedBytes: Number(verifyPhase.stats.matchedBytes || 0),
4398
+ failedCount: Number(verifyPhase.stats.failedCount || 0),
4399
+ }
4400
+ : null,
4401
+ breakdown,
4402
+ };
4403
+ }
4404
+
4405
+ function withRunTaskResult(baseResult, action, runTaskMode, taskDecision, phases) {
4406
+ const output = baseResult && typeof baseResult === 'object' ? baseResult : {};
4407
+ const data = output.data && typeof output.data === 'object' ? output.data : {};
4408
+ const summary = output.summary && typeof output.summary === 'object' ? output.summary : {};
4409
+ return {
4410
+ ...output,
4411
+ summary: {
4412
+ ...summary,
4413
+ runTaskMode,
4414
+ taskDecision,
4415
+ phaseCount: phases.length,
4416
+ },
4417
+ data: {
4418
+ ...data,
4419
+ taskPhases: phases,
4420
+ taskCard: buildRunTaskCard(action, runTaskMode, taskDecision, phases, output),
4421
+ },
4422
+ };
4423
+ }
4424
+
4425
+ async function runNonInteractiveTask(action, context, cliArgs) {
4426
+ const runTaskMode = normalizeRunTaskMode(cliArgs.runTask);
4427
+ if (!runTaskMode) {
4428
+ return runNonInteractiveAction(action, context, cliArgs);
4429
+ }
4430
+
4431
+ if (!isDestructiveAction(action) && runTaskMode !== RUN_TASK_PREVIEW) {
4432
+ throw new UsageError(`动作 ${actionDisplayName(action)} 仅支持 --run-task preview`);
4433
+ }
4434
+ if (
4435
+ isDestructiveAction(action) &&
4436
+ (runTaskMode === RUN_TASK_EXECUTE || runTaskMode === RUN_TASK_PREVIEW_EXECUTE_VERIFY) &&
4437
+ !cliArgs.yes
4438
+ ) {
4439
+ throw new ConfirmationRequiredError('检测到真实执行任务流程,但未提供 --yes 确认参数。');
4440
+ }
4441
+
4442
+ const execPhase = async (phaseName, phaseArgs) => {
4443
+ const startedAt = Date.now();
4444
+ const result = await runNonInteractiveAction(action, context, phaseArgs);
4445
+ return {
4446
+ result,
4447
+ phase: buildTaskPhaseEntry(action, phaseName, result, Date.now() - startedAt),
4448
+ };
4449
+ };
4450
+
4451
+ if (runTaskMode === RUN_TASK_PREVIEW) {
4452
+ const previewArgs = isDestructiveAction(action)
4453
+ ? { ...cliArgs, dryRun: true, yes: false, runTask: null }
4454
+ : { ...cliArgs, runTask: null };
4455
+ const preview = await execPhase('preview', previewArgs);
4456
+ return withRunTaskResult(preview.result, action, runTaskMode, 'preview_only', [preview.phase]);
4457
+ }
4458
+
4459
+ if (runTaskMode === RUN_TASK_EXECUTE) {
4460
+ const executeArgs = isDestructiveAction(action)
4461
+ ? { ...cliArgs, dryRun: false, yes: true, runTask: null }
4462
+ : { ...cliArgs, runTask: null };
4463
+ const execute = await execPhase('execute', executeArgs);
4464
+ return withRunTaskResult(execute.result, action, runTaskMode, 'execute_only', [execute.phase]);
4465
+ }
4466
+
4467
+ const previewArgs = { ...cliArgs, dryRun: true, yes: false, runTask: null };
4468
+ const preview = await execPhase('preview', previewArgs);
4469
+ if (!preview.result.ok) {
4470
+ const phases = [
4471
+ preview.phase,
4472
+ buildSkippedTaskPhase('execute', 'preview_failed'),
4473
+ buildSkippedTaskPhase('verify', 'preview_failed'),
4474
+ ];
4475
+ return withRunTaskResult(preview.result, action, runTaskMode, 'preview_failed', phases);
4476
+ }
4477
+
4478
+ if (phaseMatchedTargets(action, preview.result) <= 0) {
4479
+ const phases = [
4480
+ preview.phase,
4481
+ buildSkippedTaskPhase('execute', 'no_target'),
4482
+ buildSkippedTaskPhase('verify', 'no_execute'),
4483
+ ];
4484
+ return withRunTaskResult(preview.result, action, runTaskMode, 'skipped_no_target', phases);
4485
+ }
4486
+
4487
+ const executeArgs = { ...cliArgs, dryRun: false, yes: true, runTask: null };
4488
+ const execute = await execPhase('execute', executeArgs);
4489
+ const verify = await execPhase('verify', previewArgs);
4490
+ return withRunTaskResult(execute.result, action, runTaskMode, 'executed_and_verified', [
4491
+ preview.phase,
4492
+ execute.phase,
4493
+ verify.phase,
4494
+ ]);
4495
+ }
4496
+
3317
4497
  async function runCleanupMode(context) {
3318
4498
  const { config, aliases, nativeCorePath } = context;
3319
4499
 
@@ -4078,6 +5258,74 @@ async function runDoctorMode(context, options = {}) {
4078
5258
  await printDoctorReport(report, Boolean(options.jsonOutput));
4079
5259
  }
4080
5260
 
5261
+ async function runCheckUpdateMode(context, cliArgs = {}) {
5262
+ const channel = resolveUpdateChannel(cliArgs, context.config);
5263
+ const startedAt = Date.now();
5264
+ const checkResult = await checkLatestVersion({
5265
+ currentVersion: context.appMeta?.version || '0.0.0',
5266
+ packageName: PACKAGE_NAME,
5267
+ githubOwner: UPDATE_REPO_OWNER,
5268
+ githubRepo: UPDATE_REPO_NAME,
5269
+ channel,
5270
+ timeoutMs: UPDATE_TIMEOUT_MS,
5271
+ reason: 'manual',
5272
+ });
5273
+
5274
+ context.config.selfUpdate = applyUpdateCheckResult(
5275
+ normalizeSelfUpdateConfig({
5276
+ ...context.config.selfUpdate,
5277
+ channel,
5278
+ }),
5279
+ checkResult,
5280
+ ''
5281
+ );
5282
+ await persistSelfUpdateState(context);
5283
+
5284
+ const payload = {
5285
+ ok: checkResult.checked,
5286
+ action: MODES.CHECK_UPDATE,
5287
+ dryRun: null,
5288
+ summary: {
5289
+ checked: Boolean(checkResult.checked),
5290
+ hasUpdate: Boolean(checkResult.hasUpdate),
5291
+ currentVersion: checkResult.currentVersion || '-',
5292
+ latestVersion: checkResult.latestVersion || '-',
5293
+ source: checkResult.sourceUsed || 'none',
5294
+ channel: checkResult.channel || channel,
5295
+ skippedByUser: shouldSkipVersion(checkResult, context.config.selfUpdate.skipVersion),
5296
+ },
5297
+ warnings: [],
5298
+ errors: Array.isArray(checkResult.errors)
5299
+ ? checkResult.errors.map((message) => ({
5300
+ code: 'E_UPDATE_CHECK_FAILED',
5301
+ message,
5302
+ }))
5303
+ : [],
5304
+ data: {
5305
+ update: buildUpdateData(checkResult, context.config.selfUpdate.skipVersion),
5306
+ },
5307
+ meta: {
5308
+ app: APP_NAME,
5309
+ package: PACKAGE_NAME,
5310
+ version: context.appMeta?.version || '0.0.0',
5311
+ timestamp: Date.now(),
5312
+ durationMs: Date.now() - startedAt,
5313
+ output: OUTPUT_TEXT,
5314
+ engine: context.lastRunEngineUsed || (context.nativeCorePath ? 'zig_ready' : 'node'),
5315
+ },
5316
+ };
5317
+
5318
+ if (payload.summary.hasUpdate && !payload.summary.skippedByUser) {
5319
+ payload.warnings.push(updateWarningMessage(payload.data.update, context.config.selfUpdate.skipVersion));
5320
+ }
5321
+ printCheckUpdateTextResult(payload);
5322
+
5323
+ const upgraded = await maybePromptInteractiveUpgrade(context, payload.data.update);
5324
+ if (upgraded) {
5325
+ return;
5326
+ }
5327
+ }
5328
+
4081
5329
  async function runRecycleMaintainMode(context, options = {}) {
4082
5330
  const { config } = context;
4083
5331
  const policy = normalizeRecycleRetention(config.recycleRetention);
@@ -4438,6 +5686,10 @@ async function runMode(mode, context, options = {}) {
4438
5686
  await runDoctorMode(context, options);
4439
5687
  return;
4440
5688
  }
5689
+ if (mode === MODES.CHECK_UPDATE) {
5690
+ await runCheckUpdateMode(context, options.cliArgs || {});
5691
+ return;
5692
+ }
4441
5693
  if (mode === MODES.RECYCLE_MAINTAIN) {
4442
5694
  await runRecycleMaintainMode(context, options);
4443
5695
  return;
@@ -4528,6 +5780,7 @@ async function runInteractiveLoop(context) {
4528
5780
  { name: '恢复已删除批次', value: MODES.RESTORE },
4529
5781
  { name: '回收区治理(保留策略)', value: MODES.RECYCLE_MAINTAIN },
4530
5782
  { name: '系统自检(doctor)', value: MODES.DOCTOR },
5783
+ { name: '检查更新与升级', value: MODES.CHECK_UPDATE },
4531
5784
  { name: '交互配置', value: MODES.SETTINGS },
4532
5785
  { name: '退出', value: 'exit' },
4533
5786
  ],
@@ -4540,6 +5793,7 @@ async function runInteractiveLoop(context) {
4540
5793
  await runMode(mode, context, {
4541
5794
  jsonOutput: false,
4542
5795
  force: false,
5796
+ cliArgs: {},
4543
5797
  });
4544
5798
 
4545
5799
  const back = await askConfirm({
@@ -4602,6 +5856,7 @@ async function main() {
4602
5856
  nativeRepairNote: nativeProbe.repairNote || null,
4603
5857
  appMeta,
4604
5858
  projectRoot,
5859
+ readOnlyConfig,
4605
5860
  };
4606
5861
 
4607
5862
  let lockHandle = null;
@@ -4610,11 +5865,18 @@ async function main() {
4610
5865
  }
4611
5866
 
4612
5867
  try {
5868
+ const startupUpdate = await maybeRunStartupUpdateCheck(context, cliArgs, action, interactiveMode);
5869
+
4613
5870
  if (interactiveMode) {
5871
+ const upgraded = await maybePromptInteractiveUpgrade(context, startupUpdate);
5872
+ if (upgraded) {
5873
+ return;
5874
+ }
4614
5875
  if (interactiveStartMode !== MODES.START) {
4615
5876
  await runMode(interactiveStartMode, context, {
4616
5877
  jsonOutput: false,
4617
5878
  force: cliArgs.force,
5879
+ cliArgs,
4618
5880
  });
4619
5881
  return;
4620
5882
  }
@@ -4623,7 +5885,17 @@ async function main() {
4623
5885
  }
4624
5886
 
4625
5887
  const startedAt = Date.now();
4626
- const result = await runNonInteractiveAction(action, context, cliArgs);
5888
+ let result = await runNonInteractiveTask(action, context, cliArgs);
5889
+ if (action !== MODES.CHECK_UPDATE && action !== MODES.UPGRADE) {
5890
+ result = attachStartupUpdateToResult(result, startupUpdate, context.config.selfUpdate.skipVersion);
5891
+ }
5892
+ result = {
5893
+ ...(result || {}),
5894
+ data: {
5895
+ ...((result && result.data) || {}),
5896
+ userFacingSummary: buildUserFacingSummary(action, result),
5897
+ },
5898
+ };
4627
5899
  if (cliArgs.saveConfig) {
4628
5900
  await saveConfig(config);
4629
5901
  }