aihezu 2.5.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/aihezu.js CHANGED
@@ -7,7 +7,10 @@ const readline = require('readline');
7
7
  const commands = {
8
8
  install: require('../commands/install'),
9
9
  clear: require('../commands/clear'),
10
- usage: require('../commands/usage')
10
+ usage: require('../commands/usage'),
11
+ backup: require('../commands/backup'),
12
+ recover: require('../commands/recover'),
13
+ reset: require('../commands/reset')
11
14
  };
12
15
 
13
16
  const services = {
@@ -22,7 +25,10 @@ function showHelp() {
22
25
  console.log('用法: aihezu <command> [service]');
23
26
  console.log('\n命令:');
24
27
  console.log(' install <service> 配置服务 (API Key, URL, 网络设置)');
25
- console.log(' clear <service> 清理缓存并刷新网络设置');
28
+ console.log(' clear <service> 清理缓存并刷新网络设置 (自动备份)');
29
+ console.log(' backup <service> 手动备份配置');
30
+ console.log(' recover <service> 从备份恢复配置');
31
+ console.log(' reset <service> 重置服务配置 (删除所有配置)');
26
32
  console.log(' usage [service] 查看用量统计');
27
33
  console.log('');
28
34
  console.log('usage 命令可选参数:');
@@ -47,10 +53,13 @@ function showHelp() {
47
53
  console.log('\n示例:');
48
54
  console.log(' aihezu install claude');
49
55
  console.log(' aihezu clear codex');
50
- console.log(' aihezu usage (查看所有服务用量)');
51
- console.log(' aihezu usage cc (只查看 Claude Code 用量)');
52
- console.log(' aihezu install (交互式安装)');
53
- console.log(' aihezu help (显示帮助信息)');
56
+ console.log(' aihezu backup claude (备份 Claude Code 配置)');
57
+ console.log(' aihezu recover claude (恢复 Claude Code 配置)');
58
+ console.log(' aihezu reset claude (重置 Claude Code 配置)');
59
+ console.log(' aihezu usage (查看所有服务用量)');
60
+ console.log(' aihezu usage cc (只查看 Claude Code 用量)');
61
+ console.log(' aihezu install (交互式安装)');
62
+ console.log(' aihezu help (显示帮助信息)');
54
63
  }
55
64
 
56
65
  async function askQuestion(rl, question) {
@@ -0,0 +1,48 @@
1
+ const { createBackup, cleanOldBackups } = require('../lib/backup');
2
+
3
+ async function backupCommand(service) {
4
+ console.log('');
5
+ console.log('💾 正在备份 ' + service.displayName + ' 配置...');
6
+ console.log('');
7
+
8
+ if (!service.cacheConfig) {
9
+ console.log('❌ 此服务未定义配置信息,无法备份。');
10
+ return;
11
+ }
12
+
13
+ const { dir: targetDir, items: itemsToBackup } = service.cacheConfig;
14
+
15
+ // Add settings.json to backup list if it exists
16
+ const allItems = [...itemsToBackup];
17
+ if (service.name === 'claude') {
18
+ allItems.push('settings.json');
19
+ }
20
+
21
+ // Create backup
22
+ const result = createBackup({
23
+ targetDir: targetDir,
24
+ itemsToBackup: allItems,
25
+ description: service.displayName
26
+ });
27
+
28
+ if (!result.success) {
29
+ console.log(`❌ ${result.error}`);
30
+ return;
31
+ }
32
+
33
+ // Clean old backups (keep 3 most recent)
34
+ console.log('');
35
+ const deletedCount = cleanOldBackups({
36
+ targetDir: targetDir,
37
+ keepCount: 3
38
+ });
39
+
40
+ if (deletedCount > 0) {
41
+ console.log(`\n🧹 已清理 ${deletedCount} 个旧备份`);
42
+ }
43
+
44
+ console.log('');
45
+ console.log('✅ 备份完成!');
46
+ }
47
+
48
+ module.exports = backupCommand;
package/commands/clear.js CHANGED
@@ -1,23 +1,52 @@
1
1
  const { modifyHostsFile } = require('../lib/hosts');
2
2
  const { cleanCache } = require('../lib/cache');
3
+ const { createBackup, cleanOldBackups } = require('../lib/backup');
3
4
 
4
5
  async function clearCommand(service) {
5
6
  console.log('');
6
7
  console.log('🧹 正在清理 ' + service.displayName + ' 环境...');
7
8
  console.log('');
8
9
 
9
- // 1. Clean Cache
10
+ // 1. Create backup before clearing
11
+ if (service.cacheConfig) {
12
+ const allItems = [...service.cacheConfig.items];
13
+ if (service.name === 'claude') {
14
+ allItems.push('settings.json');
15
+ }
16
+
17
+ const backupResult = createBackup({
18
+ targetDir: service.cacheConfig.dir,
19
+ itemsToBackup: allItems,
20
+ description: service.displayName
21
+ });
22
+
23
+ if (backupResult.success) {
24
+ console.log('');
25
+ } else {
26
+ console.log(`⚠️ 备份失败: ${backupResult.error}`);
27
+ console.log(' 继续清理缓存...');
28
+ console.log('');
29
+ }
30
+ }
31
+
32
+ // 2. Clean Cache
10
33
  if (service.cacheConfig) {
11
34
  cleanCache({
12
35
  targetDir: service.cacheConfig.dir,
13
36
  itemsToClean: service.cacheConfig.items,
14
37
  showHeader: true
15
38
  });
39
+
40
+ // Clean old backups (keep 3 most recent)
41
+ cleanOldBackups({
42
+ targetDir: service.cacheConfig.dir,
43
+ keepCount: 3
44
+ });
16
45
  } else {
17
46
  console.log('ℹ️ 此服务未定义缓存配置。');
18
47
  }
19
48
 
20
- // 2. Refresh Hosts (Ensure they are still set correctly)
49
+ // 3. Refresh Hosts (Ensure they are still set correctly)
21
50
  if (service.hostsConfig && service.hostsConfig.length > 0) {
22
51
  console.log('');
23
52
  console.log('=== 刷新网络配置 ===');
@@ -0,0 +1,107 @@
1
+ const readline = require('readline');
2
+ const { listBackups, restoreBackup, createBackup } = require('../lib/backup');
3
+
4
+ async function askQuestion(rl, question) {
5
+ return new Promise(resolve => rl.question(question, answer => resolve(answer.trim())));
6
+ }
7
+
8
+ async function recoverCommand(service) {
9
+ console.log('');
10
+ console.log('🔄 ' + service.displayName + ' 配置恢复');
11
+ console.log('');
12
+
13
+ if (!service.cacheConfig) {
14
+ console.log('❌ 此服务未定义配置信息,无法恢复。');
15
+ return;
16
+ }
17
+
18
+ const { dir: targetDir, items: itemsToBackup } = service.cacheConfig;
19
+
20
+ // List available backups
21
+ const backups = listBackups(targetDir);
22
+
23
+ if (backups.length === 0) {
24
+ console.log('❌ 没有找到可用的备份文件。');
25
+ console.log('');
26
+ console.log('提示: 使用 `aihezu backup ' + service.name + '` 创建备份。');
27
+ return;
28
+ }
29
+
30
+ // Display available backups
31
+ console.log('可用的备份:');
32
+ console.log('');
33
+ backups.forEach((backup, index) => {
34
+ console.log(` [${index + 1}] ${backup.formattedDate} (${backup.formattedSize})`);
35
+ });
36
+ console.log('');
37
+
38
+ const rl = readline.createInterface({
39
+ input: process.stdin,
40
+ output: process.stdout
41
+ });
42
+
43
+ try {
44
+ // Ask user to select a backup
45
+ let selectedIndex = -1;
46
+ let validChoice = false;
47
+
48
+ while (!validChoice) {
49
+ const answer = await askQuestion(rl, '请选择要恢复的备份 (输入编号, 或输入 q 退出): ');
50
+
51
+ if (answer.toLowerCase() === 'q') {
52
+ console.log('');
53
+ console.log('已取消恢复操作。');
54
+ return;
55
+ }
56
+
57
+ const index = parseInt(answer, 10) - 1;
58
+ if (index >= 0 && index < backups.length) {
59
+ selectedIndex = index;
60
+ validChoice = true;
61
+ } else {
62
+ console.log('无效的选择,请输入列表中的数字。');
63
+ }
64
+ }
65
+
66
+ const selectedBackup = backups[selectedIndex];
67
+
68
+ // Warn user about overwriting
69
+ console.log('');
70
+ console.log('⚠️ 警告: 恢复备份将覆盖当前配置!');
71
+ console.log('');
72
+ console.log(' 恢复的备份: ' + selectedBackup.formattedDate);
73
+ console.log(' 目标目录: ' + targetDir);
74
+ console.log('');
75
+ console.log('💡 建议: 在继续之前,你可以先运行 `aihezu backup ' + service.name + '` 备份当前配置。');
76
+ console.log('');
77
+
78
+ const confirm = await askQuestion(rl, '确认恢复? (输入 yes 确认): ');
79
+
80
+ if (confirm.toLowerCase() !== 'yes') {
81
+ console.log('');
82
+ console.log('已取消恢复操作。');
83
+ return;
84
+ }
85
+
86
+ // Restore the backup
87
+ const result = restoreBackup({
88
+ backupFile: selectedBackup.filePath,
89
+ targetDir: targetDir
90
+ });
91
+
92
+ if (!result.success) {
93
+ console.log(`❌ ${result.error}`);
94
+ return;
95
+ }
96
+
97
+ console.log('');
98
+ console.log('✅ 恢复完成!');
99
+ console.log('');
100
+ console.log('提示: 如果你使用的是 Claude Code,可能需要重启才能使配置生效。');
101
+
102
+ } finally {
103
+ rl.close();
104
+ }
105
+ }
106
+
107
+ module.exports = recoverCommand;
@@ -0,0 +1,131 @@
1
+ const readline = require('readline');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { createBackup } = require('../lib/backup');
5
+
6
+ async function askQuestion(rl, question) {
7
+ return new Promise(resolve => rl.question(question, answer => resolve(answer.trim())));
8
+ }
9
+
10
+ async function resetCommand(service) {
11
+ console.log('');
12
+ console.log('🔄 重置 ' + service.displayName + ' 配置');
13
+ console.log('');
14
+
15
+ if (!service.cacheConfig) {
16
+ console.log('❌ 此服务未定义配置信息,无法重置。');
17
+ return;
18
+ }
19
+
20
+ const { dir: targetDir, items: itemsToClean } = service.cacheConfig;
21
+
22
+ if (!fs.existsSync(targetDir)) {
23
+ console.log('ℹ️ 配置目录不存在,无需重置。');
24
+ return;
25
+ }
26
+
27
+ const rl = readline.createInterface({
28
+ input: process.stdin,
29
+ output: process.stdout
30
+ });
31
+
32
+ try {
33
+ // Collect items that exist
34
+ const existingItems = [];
35
+ for (const item of itemsToClean) {
36
+ const itemPath = path.join(targetDir, item);
37
+ if (fs.existsSync(itemPath)) {
38
+ existingItems.push(item);
39
+ }
40
+ }
41
+
42
+ // Also check for settings.json
43
+ if (service.name === 'claude') {
44
+ const settingsPath = path.join(targetDir, 'settings.json');
45
+ if (fs.existsSync(settingsPath)) {
46
+ existingItems.push('settings.json');
47
+ }
48
+ }
49
+
50
+ if (existingItems.length === 0) {
51
+ console.log('ℹ️ 没有找到需要清理的配置文件。');
52
+ return;
53
+ }
54
+
55
+ // Show what will be deleted
56
+ console.log('⚠️ 警告: 以下配置将被永久删除:');
57
+ console.log('');
58
+ existingItems.forEach(item => {
59
+ const itemPath = path.join(targetDir, item);
60
+ const stat = fs.statSync(itemPath);
61
+ const type = stat.isDirectory() ? '目录' : '文件';
62
+ console.log(` - ${item} (${type})`);
63
+ });
64
+ console.log('');
65
+ console.log('💡 建议: 在继续之前,你可以先运行 `aihezu backup ' + service.name + '` 备份当前配置。');
66
+ console.log('');
67
+
68
+ const shouldBackup = await askQuestion(rl, '是否先创建备份? (yes/no, 默认 yes): ');
69
+
70
+ if (shouldBackup.toLowerCase() !== 'no') {
71
+ console.log('');
72
+ const backupResult = createBackup({
73
+ targetDir: targetDir,
74
+ itemsToBackup: existingItems,
75
+ description: service.displayName
76
+ });
77
+
78
+ if (backupResult.success) {
79
+ console.log('');
80
+ } else {
81
+ console.log(`⚠️ 备份失败: ${backupResult.error}`);
82
+ console.log('');
83
+ }
84
+ }
85
+
86
+ console.log('⚠️ 最后确认: 确定要删除所有配置吗?此操作不可恢复!');
87
+ console.log('');
88
+ const confirm = await askQuestion(rl, '请输入 "DELETE" (大写) 确认删除: ');
89
+
90
+ if (confirm !== 'DELETE') {
91
+ console.log('');
92
+ console.log('已取消重置操作。');
93
+ return;
94
+ }
95
+
96
+ // Delete all items
97
+ console.log('');
98
+ console.log('🗑️ 正在删除配置...');
99
+ console.log('');
100
+
101
+ let deletedCount = 0;
102
+ for (const item of existingItems) {
103
+ const itemPath = path.join(targetDir, item);
104
+
105
+ try {
106
+ const stat = fs.statSync(itemPath);
107
+
108
+ if (stat.isDirectory()) {
109
+ fs.rmSync(itemPath, { recursive: true, force: true });
110
+ console.log(` ✓ 已删除目录: ${item}`);
111
+ } else {
112
+ fs.unlinkSync(itemPath);
113
+ console.log(` ✓ 已删除文件: ${item}`);
114
+ }
115
+ deletedCount++;
116
+ } catch (e) {
117
+ console.log(` ✗ 删除失败: ${item} - ${e.message}`);
118
+ }
119
+ }
120
+
121
+ console.log('');
122
+ console.log(`✅ 重置完成!已删除 ${deletedCount} 个项目。`);
123
+ console.log('');
124
+ console.log('提示: 使用 `aihezu install ' + service.name + '` 重新配置服务。');
125
+
126
+ } finally {
127
+ rl.close();
128
+ }
129
+ }
130
+
131
+ module.exports = resetCommand;
package/commands/usage.js CHANGED
@@ -263,6 +263,54 @@ function formatPercent(current, limit) {
263
263
  return `${((c / l) * 100).toFixed(1)}%`;
264
264
  }
265
265
 
266
+ function normalizeEpochMs(value) {
267
+ const n = asNumber(value);
268
+ if (n === null) return null;
269
+ return n < 1e12 ? n * 1000 : n;
270
+ }
271
+
272
+ function formatDateTime(value) {
273
+ const ms = normalizeEpochMs(value);
274
+ if (ms === null) return '-';
275
+ const date = new Date(ms);
276
+ if (Number.isNaN(date.getTime())) return '-';
277
+ const year = date.getFullYear();
278
+ const month = String(date.getMonth() + 1).padStart(2, '0');
279
+ const day = String(date.getDate()).padStart(2, '0');
280
+ const hours = String(date.getHours()).padStart(2, '0');
281
+ const minutes = String(date.getMinutes()).padStart(2, '0');
282
+ const seconds = String(date.getSeconds()).padStart(2, '0');
283
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
284
+ }
285
+
286
+ function formatDurationSeconds(value) {
287
+ const total = asNumber(value);
288
+ if (total === null) return '-';
289
+ let remaining = Math.max(0, Math.floor(total));
290
+ const days = Math.floor(remaining / 86400);
291
+ remaining %= 86400;
292
+ const hours = Math.floor(remaining / 3600);
293
+ remaining %= 3600;
294
+ const minutes = Math.floor(remaining / 60);
295
+ const seconds = remaining % 60;
296
+
297
+ const parts = [];
298
+ if (days) parts.push(`${days} 天`);
299
+ if (hours) parts.push(`${hours} 小时`);
300
+ if (minutes) parts.push(`${minutes} 分`);
301
+ if (seconds || parts.length === 0) parts.push(`${seconds} 秒`);
302
+ return parts.join(' ');
303
+ }
304
+
305
+ function formatDurationMinutes(value) {
306
+ const minutes = asNumber(value);
307
+ if (minutes === null || minutes <= 0) return '-';
308
+ const human = formatDurationSeconds(minutes * 60);
309
+ if (human === '-') return '-';
310
+ const minuteText = Number.isInteger(minutes) ? String(minutes) : minutes.toFixed(2);
311
+ return `${human} (${minuteText} 分钟)`;
312
+ }
313
+
266
314
  function renderBar(current, limit, width = 18) {
267
315
  const c = asNumber(current);
268
316
  const l = asNumber(limit);
@@ -326,6 +374,27 @@ function displayUsageStats(stats, origin, source) {
326
374
  const dailyCostLimit = (stats.dailyCostLimit !== undefined) ? stats.dailyCostLimit : limits.dailyCostLimit;
327
375
  const currentWindowCost = (stats.currentWindowCost !== undefined) ? stats.currentWindowCost : limits.currentWindowCost;
328
376
  const rateLimitCost = (stats.rateLimitCost !== undefined) ? stats.rateLimitCost : limits.rateLimitCost;
377
+ const windowStartTime = (stats.windowStartTime !== undefined) ? stats.windowStartTime : limits.windowStartTime;
378
+ const windowEndTime = (stats.windowEndTime !== undefined) ? stats.windowEndTime : limits.windowEndTime;
379
+ const windowRemainingSeconds = (() => {
380
+ const raw = (stats.windowRemainingSeconds !== undefined)
381
+ ? stats.windowRemainingSeconds
382
+ : limits.windowRemainingSeconds;
383
+ const provided = asNumber(raw);
384
+ if (provided !== null) return provided;
385
+ const endMs = normalizeEpochMs(windowEndTime);
386
+ if (endMs === null) return null;
387
+ return Math.max(0, Math.floor((endMs - Date.now()) / 1000));
388
+ })();
389
+ const windowDurationMinutes = (() => {
390
+ const raw = (stats.rateLimitWindow !== undefined) ? stats.rateLimitWindow : limits.rateLimitWindow;
391
+ const minutes = asNumber(raw);
392
+ if (minutes !== null) return minutes;
393
+ const startMs = normalizeEpochMs(windowStartTime);
394
+ const endMs = normalizeEpochMs(windowEndTime);
395
+ if (startMs === null || endMs === null || endMs <= startMs) return null;
396
+ return Math.round((endMs - startMs) / 60000);
397
+ })();
329
398
 
330
399
  const dailyRemaining = (() => {
331
400
  const c = asNumber(currentDailyCost);
@@ -360,6 +429,25 @@ function displayUsageStats(stats, origin, source) {
360
429
  if (windowRemaining !== null) {
361
430
  console.log(`窗口剩余: ${formatCost(windowRemaining)}`);
362
431
  }
432
+ const windowStartText = formatDateTime(windowStartTime);
433
+ const windowEndText = formatDateTime(windowEndTime);
434
+ if (windowStartText !== '-' || windowEndText !== '-') {
435
+ if (windowStartText !== '-' && windowEndText !== '-') {
436
+ console.log(`时间窗口: ${windowStartText} ~ ${windowEndText}`);
437
+ } else if (windowStartText !== '-') {
438
+ console.log(`时间窗口开始: ${windowStartText}`);
439
+ } else {
440
+ console.log(`时间窗口结束: ${windowEndText}`);
441
+ }
442
+ }
443
+ const windowDurationText = formatDurationMinutes(windowDurationMinutes);
444
+ if (windowDurationText !== '-') {
445
+ console.log(`周时间窗口: ${windowDurationText}`);
446
+ }
447
+ const resetCountdownText = formatDurationSeconds(windowRemainingSeconds);
448
+ if (resetCountdownText !== '-') {
449
+ console.log(`距离重置: ${resetCountdownText}`);
450
+ }
363
451
  console.log('');
364
452
  }
365
453
 
package/lib/backup.js ADDED
@@ -0,0 +1,237 @@
1
+ const path = require('path');
2
+ const fs = require('fs');
3
+ const { execSync } = require('child_process');
4
+ const { getLocalTimestamp } = require('./cache');
5
+
6
+ /**
7
+ * Create a tar.gz backup of files/directories
8
+ * @param {Object} options - Backup options
9
+ * @param {string} options.targetDir - The directory containing items to backup
10
+ * @param {Array<string>} options.itemsToBackup - Items to backup (files or directories)
11
+ * @param {string} options.description - Description for logging
12
+ * @returns {Object} - { success: boolean, backupFile: string, error: string }
13
+ */
14
+ function createBackup(options = {}) {
15
+ const { targetDir, itemsToBackup = [], description = '配置' } = options;
16
+
17
+ if (!targetDir || !fs.existsSync(targetDir)) {
18
+ return {
19
+ success: false,
20
+ error: `目录不存在: ${targetDir}`
21
+ };
22
+ }
23
+
24
+ if (itemsToBackup.length === 0) {
25
+ return {
26
+ success: false,
27
+ error: '没有指定要备份的项目'
28
+ };
29
+ }
30
+
31
+ const timestamp = getLocalTimestamp();
32
+ const backupFileName = `backup-${timestamp}.tar.gz`;
33
+ const backupFilePath = path.join(targetDir, backupFileName);
34
+
35
+ console.log(`\n📦 正在创建备份...`);
36
+ console.log(` 备份目录: ${targetDir}`);
37
+
38
+ try {
39
+ // Collect items that actually exist
40
+ const existingItems = [];
41
+ for (const item of itemsToBackup) {
42
+ const itemPath = path.join(targetDir, item);
43
+ if (fs.existsSync(itemPath)) {
44
+ existingItems.push(item);
45
+ const stat = fs.statSync(itemPath);
46
+ const type = stat.isDirectory() ? '目录' : '文件';
47
+ console.log(` - ${item} (${type})`);
48
+ }
49
+ }
50
+
51
+ if (existingItems.length === 0) {
52
+ return {
53
+ success: false,
54
+ error: '没有找到可以备份的项目'
55
+ };
56
+ }
57
+
58
+ // Create tar.gz with all items
59
+ const itemsArg = existingItems.map(item => `'${item}'`).join(' ');
60
+ const tarCommand = process.platform === 'win32'
61
+ ? `tar -czf "${backupFilePath}" -C "${targetDir}" ${existingItems.map(i => `"${i}"`).join(' ')}`
62
+ : `tar -czf '${backupFilePath}' -C '${targetDir}' ${itemsArg}`;
63
+
64
+ execSync(tarCommand, { stdio: 'pipe' });
65
+
66
+ console.log(`\n✅ 备份创建成功: ${backupFileName}`);
67
+
68
+ return {
69
+ success: true,
70
+ backupFile: backupFilePath,
71
+ backupFileName: backupFileName
72
+ };
73
+
74
+ } catch (e) {
75
+ return {
76
+ success: false,
77
+ error: `备份失败: ${e.message}`
78
+ };
79
+ }
80
+ }
81
+
82
+ /**
83
+ * List all available backups in a directory
84
+ * @param {string} targetDir - Directory to search for backups
85
+ * @returns {Array} - Array of backup objects sorted by timestamp (newest first)
86
+ */
87
+ function listBackups(targetDir) {
88
+ if (!targetDir || !fs.existsSync(targetDir)) {
89
+ return [];
90
+ }
91
+
92
+ try {
93
+ const items = fs.readdirSync(targetDir);
94
+ const backups = [];
95
+
96
+ for (const item of items) {
97
+ // Match pattern: backup-YYYYMMDDHHMMSS.tar.gz
98
+ const match = item.match(/^backup-(\d{14})\.tar\.gz$/);
99
+
100
+ if (match) {
101
+ const timestamp = match[1];
102
+ const itemPath = path.join(targetDir, item);
103
+ const stat = fs.statSync(itemPath);
104
+
105
+ // Format timestamp for display: YYYY-MM-DD HH:MM:SS
106
+ const year = timestamp.substring(0, 4);
107
+ const month = timestamp.substring(4, 6);
108
+ const day = timestamp.substring(6, 8);
109
+ const hour = timestamp.substring(8, 10);
110
+ const minute = timestamp.substring(10, 12);
111
+ const second = timestamp.substring(12, 14);
112
+ const formattedDate = `${year}-${month}-${day} ${hour}:${minute}:${second}`;
113
+
114
+ backups.push({
115
+ fileName: item,
116
+ filePath: itemPath,
117
+ timestamp: timestamp,
118
+ formattedDate: formattedDate,
119
+ size: stat.size,
120
+ formattedSize: formatBytes(stat.size)
121
+ });
122
+ }
123
+ }
124
+
125
+ // Sort by timestamp descending (newest first)
126
+ backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
127
+
128
+ return backups;
129
+
130
+ } catch (e) {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Restore a backup file
137
+ * @param {Object} options - Restore options
138
+ * @param {string} options.backupFile - Path to the backup file
139
+ * @param {string} options.targetDir - Directory to restore to
140
+ * @returns {Object} - { success: boolean, error: string }
141
+ */
142
+ function restoreBackup(options = {}) {
143
+ const { backupFile, targetDir } = options;
144
+
145
+ if (!backupFile || !fs.existsSync(backupFile)) {
146
+ return {
147
+ success: false,
148
+ error: `备份文件不存在: ${backupFile}`
149
+ };
150
+ }
151
+
152
+ if (!targetDir || !fs.existsSync(targetDir)) {
153
+ return {
154
+ success: false,
155
+ error: `目标目录不存在: ${targetDir}`
156
+ };
157
+ }
158
+
159
+ console.log(`\n🔄 正在恢复备份...`);
160
+ console.log(` 备份文件: ${path.basename(backupFile)}`);
161
+ console.log(` 恢复目录: ${targetDir}`);
162
+
163
+ try {
164
+ // Extract tar.gz to target directory
165
+ const tarCommand = process.platform === 'win32'
166
+ ? `tar -xzf "${backupFile}" -C "${targetDir}"`
167
+ : `tar -xzf '${backupFile}' -C '${targetDir}'`;
168
+
169
+ execSync(tarCommand, { stdio: 'pipe' });
170
+
171
+ console.log(`\n✅ 备份恢复成功`);
172
+
173
+ return {
174
+ success: true
175
+ };
176
+
177
+ } catch (e) {
178
+ return {
179
+ success: false,
180
+ error: `恢复失败: ${e.message}`
181
+ };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Clean old backups, keeping only the most recent N backups
187
+ * @param {Object} options - Clean options
188
+ * @param {string} options.targetDir - Directory containing backups
189
+ * @param {number} options.keepCount - Number of recent backups to keep (default: 3)
190
+ * @returns {number} - Number of backups deleted
191
+ */
192
+ function cleanOldBackups(options = {}) {
193
+ const { targetDir, keepCount = 3 } = options;
194
+
195
+ if (!targetDir || !fs.existsSync(targetDir)) {
196
+ return 0;
197
+ }
198
+
199
+ const backups = listBackups(targetDir);
200
+
201
+ if (backups.length <= keepCount) {
202
+ return 0;
203
+ }
204
+
205
+ const backupsToDelete = backups.slice(keepCount);
206
+ let deletedCount = 0;
207
+
208
+ for (const backup of backupsToDelete) {
209
+ try {
210
+ console.log(`🗑️ 删除旧备份: ${backup.fileName}`);
211
+ fs.unlinkSync(backup.filePath);
212
+ deletedCount++;
213
+ } catch (e) {
214
+ console.log(`⚠️ 删除备份失败: ${e.message}`);
215
+ }
216
+ }
217
+
218
+ return deletedCount;
219
+ }
220
+
221
+ /**
222
+ * Format bytes to human readable format
223
+ */
224
+ function formatBytes(bytes) {
225
+ if (bytes === 0) return '0 B';
226
+ const k = 1024;
227
+ const sizes = ['B', 'KB', 'MB', 'GB'];
228
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
229
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
230
+ }
231
+
232
+ module.exports = {
233
+ createBackup,
234
+ listBackups,
235
+ restoreBackup,
236
+ cleanOldBackups
237
+ };
package/lib/cache.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
3
  const fs = require('fs');
4
+ const { execSync } = require('child_process');
4
5
 
5
6
  // Generate local timestamp: YYYYMMDDHHMMSS
6
7
  function getLocalTimestamp() {
@@ -14,6 +15,27 @@ function getLocalTimestamp() {
14
15
  return `${year}${month}${day}${hours}${minutes}${seconds}`;
15
16
  }
16
17
 
18
+ // Create a tar.gz archive of a file or directory
19
+ function createTarGzBackup(sourcePath, backupPath) {
20
+ const sourceDir = path.dirname(sourcePath);
21
+ const sourceName = path.basename(sourcePath);
22
+ const backupFile = `${backupPath}.tar.gz`;
23
+
24
+ try {
25
+ // Use tar command to create compressed archive
26
+ // -czf: create gzip compressed archive
27
+ // -C: change to directory before operation
28
+ const tarCommand = process.platform === 'win32'
29
+ ? `tar -czf "${backupFile}" -C "${sourceDir}" "${sourceName}"`
30
+ : `tar -czf '${backupFile}' -C '${sourceDir}' '${sourceName}'`;
31
+
32
+ execSync(tarCommand, { stdio: 'pipe' });
33
+ return backupFile;
34
+ } catch (e) {
35
+ throw new Error(`打包失败: ${e.message}`);
36
+ }
37
+ }
38
+
17
39
  function cleanCache(options = {}) {
18
40
  const {
19
41
  targetDir,
@@ -43,7 +65,7 @@ function cleanCache(options = {}) {
43
65
  try {
44
66
  if (fs.existsSync(itemPath)) {
45
67
  const stat = fs.statSync(itemPath);
46
- const backupPath = `${itemPath}-backup-${timestamp}`;
68
+ const backupBasePath = `${itemPath}-backup-${timestamp}`;
47
69
 
48
70
  if (stat.isDirectory()) {
49
71
  console.log(`📦 备份并清理目录: ${item}/`);
@@ -51,7 +73,17 @@ function cleanCache(options = {}) {
51
73
  console.log(`📦 备份并清理文件: ${item}`);
52
74
  }
53
75
 
54
- fs.renameSync(itemPath, backupPath);
76
+ // Create tar.gz backup
77
+ const backupFile = createTarGzBackup(itemPath, backupBasePath);
78
+ console.log(` ✓ 已打包为: ${path.basename(backupFile)}`);
79
+
80
+ // Remove original after successful backup
81
+ if (stat.isDirectory()) {
82
+ fs.rmSync(itemPath, { recursive: true, force: true });
83
+ } else {
84
+ fs.unlinkSync(itemPath);
85
+ }
86
+
55
87
  cleanedCount++;
56
88
  }
57
89
  } catch (e) {
@@ -59,24 +91,61 @@ function cleanCache(options = {}) {
59
91
  }
60
92
  }
61
93
 
62
- // Clean old backups (generic logic: starting with 'backup-' or hidden backups)
94
+ // Clean old backups (keep only the 3 most recent backups)
63
95
  try {
64
96
  const items = fs.readdirSync(targetDir);
97
+ const backupItems = [];
98
+
99
+ // Collect all backup items with their timestamps
65
100
  for (const item of items) {
66
- // Logic for backups: usually we renamed them to 'name-backup-timestamp'
67
- // Or if the original logic was specific: '.claude-*' or 'backup-*'
68
- // Let's stick to generic 'backup-*' or if the file ends with '-backup-\d+'
69
-
70
- const isBackup = item.startsWith('backup-') || /backup-\d{14}$/.test(item);
101
+ // Match pattern: *-backup-YYYYMMDDHHMMSS.tar.gz
102
+ const match = item.match(/^(.+)-backup-(\d{14})\.tar\.gz$/);
71
103
 
72
- if (isBackup) {
104
+ if (match) {
105
+ const [, baseName, backupTimestamp] = match;
73
106
  const itemPath = path.join(targetDir, item);
74
- const stat = fs.statSync(itemPath);
75
107
 
76
- if (stat.isDirectory()) {
77
- console.log(`🗑️ 删除旧备份: ${item}/`);
78
- fs.rmSync(itemPath, { recursive: true, force: true });
108
+ try {
109
+ const stat = fs.statSync(itemPath);
110
+ backupItems.push({
111
+ name: item,
112
+ baseName: baseName,
113
+ timestamp: backupTimestamp,
114
+ path: itemPath,
115
+ isFile: stat.isFile()
116
+ });
117
+ } catch (e) {
118
+ // Skip if stat fails
119
+ }
120
+ }
121
+ }
122
+
123
+ // Group backups by base name
124
+ const backupGroups = {};
125
+ for (const backup of backupItems) {
126
+ if (!backupGroups[backup.baseName]) {
127
+ backupGroups[backup.baseName] = [];
128
+ }
129
+ backupGroups[backup.baseName].push(backup);
130
+ }
131
+
132
+ // For each group, keep only the 3 most recent backups
133
+ for (const baseName in backupGroups) {
134
+ const group = backupGroups[baseName];
135
+
136
+ // Sort by timestamp descending (newest first)
137
+ group.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
138
+
139
+ // Delete backups beyond the 3 most recent
140
+ const backupsToDelete = group.slice(3);
141
+
142
+ for (const backup of backupsToDelete) {
143
+ try {
144
+ console.log(`🗑️ 删除旧备份: ${backup.name}`);
145
+ fs.unlinkSync(backup.path);
79
146
  cleanedCount++;
147
+ } catch (e) {
148
+ console.log(`⚠️ 删除备份 ${backup.name} 时出错: ${e.message}`);
80
149
  }
81
150
  }
82
151
  }
@@ -0,0 +1,105 @@
1
+ const fs = require('fs');
2
+ const { execSync } = require('child_process');
3
+
4
+ /**
5
+ * Fix file/directory permissions when running with sudo
6
+ * This ensures files are owned by the actual user, not root
7
+ *
8
+ * @param {string|string[]} paths - File or directory path(s) to fix
9
+ * @returns {boolean} - Whether permissions were fixed
10
+ */
11
+ function fixPermissions(paths) {
12
+ // Check if running with sudo
13
+ const isSudo = process.getuid && process.getuid() === 0;
14
+
15
+ if (!isSudo) {
16
+ // Not running with sudo, no need to fix permissions
17
+ return false;
18
+ }
19
+
20
+ // Get the real user who invoked sudo
21
+ const realUser = process.env.SUDO_USER;
22
+
23
+ if (!realUser) {
24
+ console.log('⚠️ 警告: 以 root 权限运行,但无法确定真实用户。');
25
+ console.log(' 配置文件可能需要手动修改权限。');
26
+ return false;
27
+ }
28
+
29
+ // Get the real user's UID and GID
30
+ let uid, gid;
31
+ try {
32
+ const userInfo = execSync(`id -u ${realUser}`, { encoding: 'utf8' }).trim();
33
+ const groupInfo = execSync(`id -g ${realUser}`, { encoding: 'utf8' }).trim();
34
+ uid = parseInt(userInfo, 10);
35
+ gid = parseInt(groupInfo, 10);
36
+ } catch (e) {
37
+ console.log(`⚠️ 警告: 无法获取用户 ${realUser} 的 UID/GID`);
38
+ return false;
39
+ }
40
+
41
+ // Convert single path to array for uniform processing
42
+ const pathArray = Array.isArray(paths) ? paths : [paths];
43
+
44
+ let fixedCount = 0;
45
+
46
+ for (const itemPath of pathArray) {
47
+ if (!fs.existsSync(itemPath)) {
48
+ continue;
49
+ }
50
+
51
+ try {
52
+ // Fix ownership recursively for directories
53
+ const stat = fs.statSync(itemPath);
54
+
55
+ if (stat.isDirectory()) {
56
+ // Recursively fix directory and all its contents
57
+ fixDirectoryOwnership(itemPath, uid, gid);
58
+ fixedCount++;
59
+ } else {
60
+ // Fix file ownership
61
+ fs.chownSync(itemPath, uid, gid);
62
+ fixedCount++;
63
+ }
64
+ } catch (e) {
65
+ console.log(`⚠️ 无法修改 ${itemPath} 的权限: ${e.message}`);
66
+ }
67
+ }
68
+
69
+ if (fixedCount > 0) {
70
+ console.log(`\n🔧 已修复文件权限 (所有者: ${realUser})`);
71
+ }
72
+
73
+ return fixedCount > 0;
74
+ }
75
+
76
+ /**
77
+ * Recursively fix directory ownership
78
+ */
79
+ function fixDirectoryOwnership(dirPath, uid, gid) {
80
+ // Fix the directory itself
81
+ fs.chownSync(dirPath, uid, gid);
82
+
83
+ // Fix all contents
84
+ const items = fs.readdirSync(dirPath);
85
+
86
+ for (const item of items) {
87
+ const itemPath = require('path').join(dirPath, item);
88
+
89
+ try {
90
+ const stat = fs.lstatSync(itemPath); // Use lstatSync to handle symlinks
91
+
92
+ if (stat.isDirectory()) {
93
+ fixDirectoryOwnership(itemPath, uid, gid);
94
+ } else {
95
+ fs.chownSync(itemPath, uid, gid);
96
+ }
97
+ } catch (e) {
98
+ // Skip items that can't be accessed
99
+ }
100
+ }
101
+ }
102
+
103
+ module.exports = {
104
+ fixPermissions
105
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aihezu",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "AI 开发环境配置工具 - 支持 Claude Code, Codex, Google Gemini 的本地化配置、代理设置与缓存清理",
5
5
  "main": "bin/aihezu.js",
6
6
  "bin": {
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
3
  const fs = require('fs');
4
+ const { fixPermissions } = require('../lib/permissions');
4
5
 
5
6
  const homeDir = os.homedir();
6
7
  const configDir = path.join(homeDir, '.claude');
@@ -58,7 +59,10 @@ module.exports = {
58
59
 
59
60
  // Write file
60
61
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
61
-
62
+
63
+ // Fix permissions if running with sudo
64
+ fixPermissions([configDir, settingsPath]);
65
+
62
66
  return [settingsPath];
63
67
  }
64
68
  };
package/services/codex.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
3
  const fs = require('fs');
4
+ const { fixPermissions } = require('../lib/permissions');
4
5
 
5
6
  const homeDir = os.homedir();
6
7
  const configDir = path.join(homeDir, '.codex');
@@ -114,6 +115,9 @@ module.exports = {
114
115
  authData.AIHEZU_OAI_KEY = apiKey;
115
116
  fs.writeFileSync(authPath, JSON.stringify(authData, null, 2), 'utf8');
116
117
 
118
+ // Fix permissions if running with sudo
119
+ fixPermissions([configDir, configPath, authPath]);
120
+
117
121
  return [configPath, authPath];
118
122
  }
119
123
  };
@@ -1,6 +1,7 @@
1
1
  const path = require('path');
2
2
  const os = require('os');
3
3
  const fs = require('fs');
4
+ const { fixPermissions } = require('../lib/permissions');
4
5
 
5
6
  const homeDir = os.homedir();
6
7
  const configDir = path.join(homeDir, '.gemini');
@@ -39,7 +40,7 @@ module.exports = {
39
40
  fs.writeFileSync(envFilePath, envContent, 'utf8');
40
41
 
41
42
  const configFiles = [envFilePath];
42
-
43
+
43
44
  // 2. Conditionally write settings.json only if options are provided
44
45
  if (Object.keys(options).length > 0) {
45
46
  const settings = {
@@ -48,6 +49,9 @@ module.exports = {
48
49
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf8');
49
50
  configFiles.push(settingsPath);
50
51
  }
51
-
52
+
53
+ // Fix permissions if running with sudo
54
+ fixPermissions([configDir, ...configFiles]);
55
+
52
56
  return configFiles; }
53
57
  };