@tkpdx01/ccc 1.2.6 → 1.3.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/README.md CHANGED
@@ -45,12 +45,89 @@ ccc edit [profile] # Edit profile / 编辑配置
45
45
  ccc delete [profile] # Delete profile / 删除配置
46
46
  ```
47
47
 
48
+ ### WebDAV Cloud Sync / WebDAV 云同步
49
+
50
+ ```bash
51
+ ccc webdav setup # Configure WebDAV and sync password / 配置 WebDAV 和同步密码
52
+ ccc webdav push # Push profiles to cloud / 推送到云端
53
+ ccc webdav pull # Pull profiles from cloud / 从云端拉取
54
+ ccc webdav status # View sync status / 查看同步状态
55
+ ```
56
+
48
57
  ## Features / 功能
49
58
 
50
59
  - **Multiple Profiles / 多配置**: Manage different API configurations
51
60
  - **Template Support / 模板**: Based on `~/.claude/settings.json`
52
61
  - **Smart Import / 智能导入**: Auto-detect API URL and token
53
62
  - **Sync Settings / 同步**: Update from template, preserve credentials
63
+ - **WebDAV Cloud Sync / 云同步**: Encrypted sync across devices
64
+
65
+ ## Sync Command / 同步命令
66
+
67
+ The `sync` command updates profiles with the latest settings from `~/.claude/settings.json` while preserving each profile's API credentials (`ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_BASE_URL`).
68
+
69
+ `sync` 命令从 `~/.claude/settings.json` 同步最新设置到 profiles,同时保留每个 profile 的 API 凭证(`ANTHROPIC_AUTH_TOKEN` 和 `ANTHROPIC_BASE_URL`)。
70
+
71
+ ```bash
72
+ ccc sync [profile] # Sync single profile / 同步单个配置
73
+ ccc sync --all # Sync all profiles / 同步所有配置
74
+ ```
75
+
76
+ Use this when you've updated your main Claude settings (plugins, model, etc.) and want to apply those changes to all profiles.
77
+
78
+ 当你更新了主 Claude 设置(插件、模型等)并想将这些更改应用到所有 profiles 时使用此命令。
79
+
80
+ ## WebDAV Cloud Sync / WebDAV 云同步
81
+
82
+ Sync your profiles across multiple devices using any WebDAV service (Nutstore, Nextcloud, etc.).
83
+
84
+ 使用任意 WebDAV 服务(坚果云、Nextcloud 等)在多设备间同步配置。
85
+
86
+ ### Setup / 配置
87
+
88
+ ```bash
89
+ ccc webdav setup
90
+ ```
91
+
92
+ This will prompt for:
93
+ - WebDAV server URL
94
+ - Username / Password
95
+ - Remote storage path
96
+ - Sync password (for encryption)
97
+
98
+ ### Commands / 命令
99
+
100
+ ```bash
101
+ ccc webdav push # Upload encrypted profiles / 上传加密配置
102
+ ccc webdav pull # Download and decrypt / 下载并解密
103
+ ccc webdav status # View sync status / 查看同步状态
104
+ ccc webdav push -f # Force push (skip conflict prompts) / 强制推送
105
+ ccc webdav pull -f # Force pull (skip conflict prompts) / 强制拉取
106
+ ```
107
+
108
+ ### Security / 安全设计
109
+
110
+ - **End-to-end encryption**: All data is encrypted locally with AES-256-GCM before upload. Even if someone gains access to your WebDAV storage, they cannot read your API keys without the sync password.
111
+
112
+ - **Password-based protection**: Your sync password is never transmitted. It derives the encryption key using PBKDF2 (100,000 iterations).
113
+
114
+ - **Local password caching**: On trusted devices, the password is cached locally (encrypted with machine fingerprint), so you don't need to enter it every time.
115
+
116
+ - **Manual sync only**: Synchronization only happens when you explicitly run `push` or `pull`. No background processes, no automatic uploads. You always know when your data leaves your machine.
117
+
118
+ - **Non-destructive merge**: By default, conflicts preserve both versions instead of overwriting. Use `--force` only when you're certain.
119
+
120
+ **安全设计**:
121
+
122
+ - **端到端加密**:所有数据在上传前使用 AES-256-GCM 本地加密。即使他人获取了你的 WebDAV 存储访问权限,没有同步密码也无法读取你的 API Key。
123
+
124
+ - **密码保护**:同步密码永不传输,使用 PBKDF2(10万次迭代)派生加密密钥。
125
+
126
+ - **本机免密**:在可信设备上,密码使用机器指纹加密缓存在本地,无需每次输入。
127
+
128
+ - **手动同步**:同步仅在你显式执行 `push` 或 `pull` 时发生。无后台进程,无自动上传。你始终清楚数据何时离开本机。
129
+
130
+ - **无损合并**:默认情况下,冲突时保留两个版本而非覆盖。仅在确定时使用 `--force`。
54
131
 
55
132
  ## Storage / 存储
56
133
 
package/index.js CHANGED
@@ -16,6 +16,8 @@ import {
16
16
  newCommand,
17
17
  editCommand,
18
18
  deleteCommand,
19
+ syncCommand,
20
+ webdavCommand,
19
21
  helpCommand
20
22
  } from './src/commands/index.js';
21
23
 
@@ -25,7 +27,7 @@ const program = new Command();
25
27
  program
26
28
  .name('ccc')
27
29
  .description('Claude Code Settings Launcher - 管理多个 Claude Code 配置文件')
28
- .version('1.2.6');
30
+ .version('1.3.0');
29
31
 
30
32
  // 注册所有命令
31
33
  listCommand(program);
@@ -35,6 +37,8 @@ importCommand(program);
35
37
  newCommand(program);
36
38
  editCommand(program);
37
39
  deleteCommand(program);
40
+ syncCommand(program);
41
+ webdavCommand(program);
38
42
  helpCommand(program);
39
43
 
40
44
  // ccc <profile> 或 ccc (无参数)
@@ -47,7 +51,7 @@ program
47
51
 
48
52
  if (profile) {
49
53
  // 检查是否是子命令
50
- if (['list', 'ls', 'use', 'show', 'import', 'if', 'new', 'edit', 'delete', 'rm', 'help'].includes(profile)) {
54
+ if (['list', 'ls', 'use', 'show', 'import', 'if', 'new', 'edit', 'delete', 'rm', 'sync', 'webdav', 'help'].includes(profile)) {
51
55
  return; // 让子命令处理
52
56
  }
53
57
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tkpdx01/ccc",
3
- "version": "1.2.6",
3
+ "version": "1.3.0",
4
4
  "description": "Claude Code Settings Launcher - Manage multiple Claude Code profiles",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -34,6 +34,7 @@
34
34
  "chalk": "^5.3.0",
35
35
  "cli-table3": "^0.6.5",
36
36
  "commander": "^12.0.0",
37
- "inquirer": "^9.2.0"
37
+ "inquirer": "^9.2.0",
38
+ "webdav": "^5.8.0"
38
39
  }
39
40
  }
@@ -10,7 +10,9 @@ import {
10
10
  saveProfile,
11
11
  setDefaultProfile,
12
12
  deleteProfile,
13
- resolveProfile
13
+ resolveProfile,
14
+ getProfileCredentials,
15
+ getClaudeSettingsTemplate
14
16
  } from '../profiles.js';
15
17
 
16
18
  export function editCommand(program) {
@@ -47,11 +49,12 @@ export function editCommand(program) {
47
49
  profile = resolved;
48
50
  }
49
51
 
50
- const currentSettings = readProfile(profile);
52
+ // 使用新的 getProfileCredentials 函数获取凭证(支持新旧格式)
53
+ const { apiKey: currentApiKey, apiUrl: currentApiUrl } = getProfileCredentials(profile);
51
54
 
52
55
  console.log(chalk.cyan(`\n当前配置 (${profile}):`));
53
- console.log(chalk.gray(` ANTHROPIC_BASE_URL: ${currentSettings.ANTHROPIC_BASE_URL || '未设置'}`));
54
- console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN: ${currentSettings.ANTHROPIC_AUTH_TOKEN ? currentSettings.ANTHROPIC_AUTH_TOKEN.substring(0, 10) + '...' : '未设置'}`));
56
+ console.log(chalk.gray(` ANTHROPIC_BASE_URL: ${currentApiUrl || '未设置'}`));
57
+ console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN: ${currentApiKey ? currentApiKey.substring(0, 10) + '...' : '未设置'}`));
55
58
  console.log();
56
59
 
57
60
  const { apiUrl, apiKey, newName } = await inquirer.prompt([
@@ -59,13 +62,13 @@ export function editCommand(program) {
59
62
  type: 'input',
60
63
  name: 'apiUrl',
61
64
  message: 'ANTHROPIC_BASE_URL:',
62
- default: currentSettings.ANTHROPIC_BASE_URL || ''
65
+ default: currentApiUrl || ''
63
66
  },
64
67
  {
65
68
  type: 'input',
66
69
  name: 'apiKey',
67
70
  message: 'ANTHROPIC_AUTH_TOKEN:',
68
- default: currentSettings.ANTHROPIC_AUTH_TOKEN || ''
71
+ default: currentApiKey || ''
69
72
  },
70
73
  {
71
74
  type: 'input',
@@ -75,11 +78,22 @@ export function editCommand(program) {
75
78
  }
76
79
  ]);
77
80
 
78
- // 影子配置只存储 API 凭证
79
- const newSettings = {
80
- ANTHROPIC_AUTH_TOKEN: apiKey,
81
- ANTHROPIC_BASE_URL: apiUrl
82
- };
81
+ // 读取当前 profile 或使用主配置模板
82
+ let currentProfile = readProfile(profile);
83
+ if (!currentProfile || !currentProfile.env) {
84
+ // 如果是旧格式或空配置,基于主配置模板创建
85
+ const template = getClaudeSettingsTemplate() || {};
86
+ currentProfile = { ...template };
87
+ }
88
+
89
+ // 确保 env 对象存在
90
+ if (!currentProfile.env) {
91
+ currentProfile.env = {};
92
+ }
93
+
94
+ // 更新 env 中的 API 凭证
95
+ currentProfile.env.ANTHROPIC_AUTH_TOKEN = apiKey;
96
+ currentProfile.env.ANTHROPIC_BASE_URL = apiUrl;
83
97
 
84
98
  // 如果重命名
85
99
  if (newName && newName !== profile) {
@@ -88,7 +102,7 @@ export function editCommand(program) {
88
102
  console.log(chalk.red(`Profile "${newName}" 已存在`));
89
103
  process.exit(1);
90
104
  }
91
- saveProfile(newName, newSettings);
105
+ saveProfile(newName, currentProfile);
92
106
  deleteProfile(profile);
93
107
 
94
108
  // 更新默认 profile
@@ -98,7 +112,7 @@ export function editCommand(program) {
98
112
 
99
113
  console.log(chalk.green(`\n✓ Profile 已重命名为 "${newName}" 并保存`));
100
114
  } else {
101
- saveProfile(profile, newSettings);
115
+ saveProfile(profile, currentProfile);
102
116
  console.log(chalk.green(`\n✓ Profile "${profile}" 已更新`));
103
117
  }
104
118
  });
@@ -5,5 +5,7 @@ export { importCommand } from './import.js';
5
5
  export { newCommand } from './new.js';
6
6
  export { editCommand } from './edit.js';
7
7
  export { deleteCommand } from './delete.js';
8
+ export { syncCommand } from './sync.js';
9
+ export { webdavCommand } from './webdav.js';
8
10
  export { helpCommand, showHelp } from './help.js';
9
11
 
@@ -1,7 +1,6 @@
1
- import fs from 'fs';
2
1
  import chalk from 'chalk';
3
2
  import Table from 'cli-table3';
4
- import { getProfiles, getDefaultProfile, getProfilePath } from '../profiles.js';
3
+ import { getProfiles, getDefaultProfile, getProfileCredentials } from '../profiles.js';
5
4
 
6
5
  export function listCommand(program) {
7
6
  program
@@ -31,19 +30,8 @@ export function listCommand(program) {
31
30
 
32
31
  profiles.forEach((p, index) => {
33
32
  const isDefault = p === defaultProfile;
34
- const profilePath = getProfilePath(p);
35
- let baseUrl = chalk.gray('(未设置)');
36
-
37
- try {
38
- const content = fs.readFileSync(profilePath, 'utf-8');
39
- // 用正则从 JSON 文件内容中提取 ANTHROPIC_BASE_URL(新格式直接在顶层)
40
- const match = content.match(/"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"/);
41
- if (match && match[1]) {
42
- baseUrl = match[1];
43
- }
44
- } catch {
45
- baseUrl = chalk.red('(读取失败)');
46
- }
33
+ const { apiUrl } = getProfileCredentials(p);
34
+ const baseUrl = apiUrl || chalk.gray('(未设置)');
47
35
 
48
36
  const num = isDefault ? chalk.green(`${index + 1}`) : chalk.gray(`${index + 1}`);
49
37
  const name = isDefault ? chalk.green(`${p} *`) : p;
@@ -4,7 +4,7 @@ import {
4
4
  ensureDirs,
5
5
  getProfiles,
6
6
  profileExists,
7
- saveProfile,
7
+ createProfileFromTemplate,
8
8
  setDefaultProfile
9
9
  } from '../profiles.js';
10
10
  import { launchClaude } from '../launch.js';
@@ -12,7 +12,7 @@ import { launchClaude } from '../launch.js';
12
12
  export function newCommand(program) {
13
13
  program
14
14
  .command('new [name]')
15
- .description('创建新的影子配置(只包含 API 凭证)')
15
+ .description('创建新的配置(基于 ~/.claude/settings.json,在 env 中设置 API 凭证)')
16
16
  .action(async (name) => {
17
17
  // 如果没有提供名称,询问
18
18
  if (!name) {
@@ -80,15 +80,9 @@ export function newCommand(program) {
80
80
  }
81
81
  }
82
82
 
83
- // 影子配置只存储 API 凭证
84
- const newSettings = {
85
- ANTHROPIC_AUTH_TOKEN: apiKey,
86
- ANTHROPIC_BASE_URL: apiUrl
87
- };
88
-
89
83
  ensureDirs();
90
- saveProfile(finalName, newSettings);
91
- console.log(chalk.green(`\n✓ 配置 "${finalName}" 已创建`));
84
+ createProfileFromTemplate(finalName, apiUrl, apiKey);
85
+ console.log(chalk.green(`\n✓ 配置 "${finalName}" 已创建(基于 ~/.claude/settings.json)`));
92
86
 
93
87
  // 如果是第一个 profile,设为默认
94
88
  const profiles = getProfiles();
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import {
4
+ getProfiles,
5
+ syncProfileWithTemplate,
6
+ getClaudeSettingsTemplate,
7
+ resolveProfile
8
+ } from '../profiles.js';
9
+
10
+ export function syncCommand(program) {
11
+ program
12
+ .command('sync [profile]')
13
+ .description('同步 ~/.claude/settings.json 到 profile(保留 API 凭证)')
14
+ .option('-a, --all', '同步所有 profiles')
15
+ .action(async (profile, options) => {
16
+ // 检查主配置是否存在
17
+ const template = getClaudeSettingsTemplate();
18
+ if (!template) {
19
+ console.log(chalk.red('未找到 ~/.claude/settings.json'));
20
+ console.log(chalk.gray('请确保 Claude Code 已正确安装'));
21
+ process.exit(1);
22
+ }
23
+
24
+ const profiles = getProfiles();
25
+
26
+ if (profiles.length === 0) {
27
+ console.log(chalk.yellow('没有可用的 profiles'));
28
+ console.log(chalk.gray('使用 "ccc new" 创建配置'));
29
+ process.exit(0);
30
+ }
31
+
32
+ // 同步所有 profiles
33
+ if (options.all) {
34
+ const { confirm } = await inquirer.prompt([
35
+ {
36
+ type: 'confirm',
37
+ name: 'confirm',
38
+ message: `确定要同步所有 ${profiles.length} 个 profiles 吗?`,
39
+ default: false
40
+ }
41
+ ]);
42
+
43
+ if (!confirm) {
44
+ console.log(chalk.yellow('已取消'));
45
+ process.exit(0);
46
+ }
47
+
48
+ console.log(chalk.cyan('\n开始同步所有 profiles...\n'));
49
+
50
+ let successCount = 0;
51
+ for (const p of profiles) {
52
+ const result = syncProfileWithTemplate(p);
53
+ if (result) {
54
+ console.log(chalk.green(` ✓ ${p}`));
55
+ successCount++;
56
+ } else {
57
+ console.log(chalk.red(` ✗ ${p} (同步失败)`));
58
+ }
59
+ }
60
+
61
+ console.log(chalk.green(`\n✓ 已同步 ${successCount}/${profiles.length} 个 profiles`));
62
+ return;
63
+ }
64
+
65
+ // 同步单个 profile
66
+ if (!profile) {
67
+ const { selectedProfile } = await inquirer.prompt([
68
+ {
69
+ type: 'list',
70
+ name: 'selectedProfile',
71
+ message: '选择要同步的配置:',
72
+ choices: profiles
73
+ }
74
+ ]);
75
+ profile = selectedProfile;
76
+ } else {
77
+ const resolved = resolveProfile(profile);
78
+ if (!resolved) {
79
+ console.log(chalk.red(`Profile "${profile}" 不存在`));
80
+ process.exit(1);
81
+ }
82
+ profile = resolved;
83
+ }
84
+
85
+ const result = syncProfileWithTemplate(profile);
86
+ if (result) {
87
+ console.log(chalk.green(`\n✓ Profile "${profile}" 已同步(保留了 API 凭证)`));
88
+ } else {
89
+ console.log(chalk.red(`\n✗ 同步失败`));
90
+ process.exit(1);
91
+ }
92
+ });
93
+ }
@@ -0,0 +1,477 @@
1
+ import chalk from 'chalk';
2
+ import inquirer from 'inquirer';
3
+ import { getProfiles } from '../profiles.js';
4
+ import {
5
+ saveSyncPassword,
6
+ loadSyncPassword,
7
+ hasSyncPassword
8
+ } from '../crypto.js';
9
+ import {
10
+ getWebDAVConfig,
11
+ saveWebDAVConfig,
12
+ createWebDAVClient,
13
+ uploadProfiles,
14
+ downloadProfiles,
15
+ getRemoteInfo,
16
+ compareProfiles,
17
+ mergePull,
18
+ mergePush
19
+ } from '../webdav.js';
20
+
21
+ // 获取或请求同步密码
22
+ async function getSyncPassword(forcePrompt = false) {
23
+ if (!forcePrompt) {
24
+ const cached = loadSyncPassword();
25
+ if (cached) {
26
+ return cached;
27
+ }
28
+ }
29
+
30
+ const { password } = await inquirer.prompt([
31
+ {
32
+ type: 'password',
33
+ name: 'password',
34
+ message: '请输入同步密码:',
35
+ mask: '*',
36
+ validate: (input) => input.length >= 6 || '密码至少需要 6 个字符'
37
+ }
38
+ ]);
39
+
40
+ return password;
41
+ }
42
+
43
+ // setup 子命令
44
+ async function setupAction() {
45
+ console.log(chalk.cyan('\n配置 WebDAV 同步\n'));
46
+
47
+ const existingConfig = getWebDAVConfig();
48
+
49
+ const answers = await inquirer.prompt([
50
+ {
51
+ type: 'input',
52
+ name: 'url',
53
+ message: 'WebDAV 服务器地址:',
54
+ default: existingConfig?.url || '',
55
+ validate: (input) => {
56
+ if (!input) return '请输入 WebDAV 地址';
57
+ if (!input.startsWith('http://') && !input.startsWith('https://')) {
58
+ return '地址必须以 http:// 或 https:// 开头';
59
+ }
60
+ return true;
61
+ }
62
+ },
63
+ {
64
+ type: 'input',
65
+ name: 'username',
66
+ message: 'WebDAV 用户名:',
67
+ default: existingConfig?.username || ''
68
+ },
69
+ {
70
+ type: 'password',
71
+ name: 'password',
72
+ message: 'WebDAV 密码:',
73
+ mask: '*'
74
+ },
75
+ {
76
+ type: 'input',
77
+ name: 'path',
78
+ message: '远程存储路径:',
79
+ default: existingConfig?.path || '/ccc-sync',
80
+ validate: (input) => input.startsWith('/') || '路径必须以 / 开头'
81
+ }
82
+ ]);
83
+
84
+ // 测试连接
85
+ console.log(chalk.gray('\n测试连接...'));
86
+ try {
87
+ const client = createWebDAVClient(answers);
88
+ await client.getDirectoryContents('/');
89
+ console.log(chalk.green('✓ 连接成功'));
90
+ } catch (err) {
91
+ console.log(chalk.red(`✗ 连接失败: ${err.message}`));
92
+ const { continueAnyway } = await inquirer.prompt([
93
+ {
94
+ type: 'confirm',
95
+ name: 'continueAnyway',
96
+ message: '是否仍要保存配置?',
97
+ default: false
98
+ }
99
+ ]);
100
+ if (!continueAnyway) {
101
+ process.exit(1);
102
+ }
103
+ }
104
+
105
+ saveWebDAVConfig(answers);
106
+ console.log(chalk.green('✓ WebDAV 配置已保存'));
107
+
108
+ // 设置同步密码
109
+ const hasPassword = hasSyncPassword();
110
+ const { setupPassword } = await inquirer.prompt([
111
+ {
112
+ type: 'confirm',
113
+ name: 'setupPassword',
114
+ message: hasPassword ? '是否重新设置同步密码?' : '是否设置同步密码?',
115
+ default: !hasPassword
116
+ }
117
+ ]);
118
+
119
+ if (setupPassword) {
120
+ const { newPassword, confirmPassword } = await inquirer.prompt([
121
+ {
122
+ type: 'password',
123
+ name: 'newPassword',
124
+ message: '设置同步密码 (用于加密云端数据):',
125
+ mask: '*',
126
+ validate: (input) => input.length >= 6 || '密码至少需要 6 个字符'
127
+ },
128
+ {
129
+ type: 'password',
130
+ name: 'confirmPassword',
131
+ message: '确认同步密码:',
132
+ mask: '*'
133
+ }
134
+ ]);
135
+
136
+ if (newPassword !== confirmPassword) {
137
+ console.log(chalk.red('✗ 两次输入的密码不一致'));
138
+ process.exit(1);
139
+ }
140
+
141
+ saveSyncPassword(newPassword);
142
+ console.log(chalk.green('✓ 同步密码已保存(本机免密)'));
143
+ }
144
+
145
+ console.log(chalk.cyan('\n配置完成!使用以下命令同步:'));
146
+ console.log(chalk.gray(' ccc webdav push - 推送到云端'));
147
+ console.log(chalk.gray(' ccc webdav pull - 从云端拉取'));
148
+ console.log(chalk.gray(' ccc webdav status - 查看同步状态'));
149
+ }
150
+
151
+ // push 子命令
152
+ async function pushAction(options) {
153
+ const config = getWebDAVConfig();
154
+ if (!config) {
155
+ console.log(chalk.red('请先运行 "ccc webdav setup" 配置 WebDAV'));
156
+ process.exit(1);
157
+ }
158
+
159
+ const localProfiles = getProfiles();
160
+ if (localProfiles.length === 0) {
161
+ console.log(chalk.yellow('没有可同步的 profiles'));
162
+ process.exit(0);
163
+ }
164
+
165
+ const syncPassword = await getSyncPassword();
166
+ const client = createWebDAVClient(config);
167
+
168
+ console.log(chalk.gray('\n检查远程状态...'));
169
+
170
+ let remoteData = null;
171
+ try {
172
+ remoteData = await downloadProfiles(client, config, syncPassword);
173
+ } catch (err) {
174
+ if (err.message.includes('密码错误')) {
175
+ console.log(chalk.red(`✗ ${err.message}`));
176
+ process.exit(1);
177
+ }
178
+ // 远程可能不存在,继续
179
+ }
180
+
181
+ const diff = compareProfiles(localProfiles, remoteData);
182
+
183
+ // 显示差异
184
+ console.log(chalk.cyan('\n同步预览:'));
185
+
186
+ if (diff.localOnly.length > 0) {
187
+ console.log(chalk.green(` ↑ 新增: ${diff.localOnly.join(', ')}`));
188
+ }
189
+
190
+ if (diff.remoteOnly.length > 0) {
191
+ console.log(chalk.gray(` ○ 远程独有 (保留): ${diff.remoteOnly.join(', ')}`));
192
+ }
193
+
194
+ const unchanged = diff.both.filter(n => !diff.conflicts.find(c => c.name === n));
195
+ if (unchanged.length > 0) {
196
+ console.log(chalk.gray(` = 无变化: ${unchanged.join(', ')}`));
197
+ }
198
+
199
+ // 处理冲突
200
+ const resolutions = {};
201
+
202
+ if (diff.conflicts.length > 0 && !options.force) {
203
+ console.log(chalk.yellow(`\n ⚠️ 发现 ${diff.conflicts.length} 个冲突:`));
204
+
205
+ for (const conflict of diff.conflicts) {
206
+ const remoteTime = new Date(conflict.remoteUpdatedAt).toLocaleString();
207
+ console.log(chalk.yellow(`\n "${conflict.name}" - 云端修改于 ${remoteTime}`));
208
+
209
+ const { resolution } = await inquirer.prompt([
210
+ {
211
+ type: 'list',
212
+ name: 'resolution',
213
+ message: `如何处理 "${conflict.name}"?`,
214
+ choices: [
215
+ { name: '保留两者 (本地上传为 *_local)', value: 'keep_both' },
216
+ { name: '使用本地版本覆盖云端', value: 'use_local' },
217
+ { name: '保留云端版本', value: 'keep_remote' }
218
+ ],
219
+ default: 'keep_both'
220
+ }
221
+ ]);
222
+
223
+ resolutions[conflict.name] = resolution;
224
+ }
225
+ } else if (diff.conflicts.length > 0 && options.force) {
226
+ console.log(chalk.yellow(` ⚠️ ${diff.conflicts.length} 个冲突将使用本地版本覆盖`));
227
+ for (const conflict of diff.conflicts) {
228
+ resolutions[conflict.name] = 'use_local';
229
+ }
230
+ }
231
+
232
+ // 确认
233
+ if (!options.force) {
234
+ const { confirm } = await inquirer.prompt([
235
+ {
236
+ type: 'confirm',
237
+ name: 'confirm',
238
+ message: '确认推送?',
239
+ default: true
240
+ }
241
+ ]);
242
+
243
+ if (!confirm) {
244
+ console.log(chalk.yellow('已取消'));
245
+ process.exit(0);
246
+ }
247
+ }
248
+
249
+ // 执行推送
250
+ console.log(chalk.gray('\n正在推送...'));
251
+ try {
252
+ const result = await mergePush(client, config, syncPassword, diff.conflicts, resolutions);
253
+ console.log(chalk.green(`\n✓ 已推送 ${result.count} 个 profiles 到云端`));
254
+ } catch (err) {
255
+ console.log(chalk.red(`\n✗ 推送失败: ${err.message}`));
256
+ process.exit(1);
257
+ }
258
+ }
259
+
260
+ // pull 子命令
261
+ async function pullAction(options) {
262
+ const config = getWebDAVConfig();
263
+ if (!config) {
264
+ console.log(chalk.red('请先运行 "ccc webdav setup" 配置 WebDAV'));
265
+ process.exit(1);
266
+ }
267
+
268
+ const syncPassword = await getSyncPassword();
269
+ const client = createWebDAVClient(config);
270
+
271
+ console.log(chalk.gray('\n正在获取云端数据...'));
272
+
273
+ let remoteData = null;
274
+ try {
275
+ remoteData = await downloadProfiles(client, config, syncPassword);
276
+ } catch (err) {
277
+ console.log(chalk.red(`✗ ${err.message}`));
278
+ process.exit(1);
279
+ }
280
+
281
+ if (!remoteData) {
282
+ console.log(chalk.yellow('云端没有同步数据'));
283
+ console.log(chalk.gray('使用 "ccc webdav push" 推送本地配置'));
284
+ process.exit(0);
285
+ }
286
+
287
+ const localProfiles = getProfiles();
288
+ const diff = compareProfiles(localProfiles, remoteData);
289
+
290
+ // 显示差异
291
+ console.log(chalk.cyan('\n同步预览:'));
292
+
293
+ if (diff.remoteOnly.length > 0) {
294
+ console.log(chalk.green(` ↓ 新增: ${diff.remoteOnly.join(', ')}`));
295
+ }
296
+
297
+ if (diff.localOnly.length > 0) {
298
+ console.log(chalk.gray(` ○ 本地独有 (保留): ${diff.localOnly.join(', ')}`));
299
+ }
300
+
301
+ const unchanged = diff.both.filter(n => !diff.conflicts.find(c => c.name === n));
302
+ if (unchanged.length > 0) {
303
+ console.log(chalk.gray(` = 无变化: ${unchanged.join(', ')}`));
304
+ }
305
+
306
+ // 处理冲突
307
+ const resolutions = {};
308
+
309
+ if (diff.conflicts.length > 0 && !options.force) {
310
+ console.log(chalk.yellow(`\n ⚠️ 发现 ${diff.conflicts.length} 个冲突:`));
311
+
312
+ for (const conflict of diff.conflicts) {
313
+ const remoteTime = new Date(conflict.remoteUpdatedAt).toLocaleString();
314
+ console.log(chalk.yellow(`\n "${conflict.name}" - 云端修改于 ${remoteTime}`));
315
+
316
+ const { resolution } = await inquirer.prompt([
317
+ {
318
+ type: 'list',
319
+ name: 'resolution',
320
+ message: `如何处理 "${conflict.name}"?`,
321
+ choices: [
322
+ { name: '保留两者 (云端保存为 *_cloud)', value: 'keep_both' },
323
+ { name: '使用云端版本覆盖本地', value: 'use_remote' },
324
+ { name: '保留本地版本', value: 'keep_local' }
325
+ ],
326
+ default: 'keep_both'
327
+ }
328
+ ]);
329
+
330
+ resolutions[conflict.name] = resolution;
331
+ }
332
+ } else if (diff.conflicts.length > 0 && options.force) {
333
+ console.log(chalk.yellow(` ⚠️ ${diff.conflicts.length} 个冲突将使用云端版本覆盖`));
334
+ for (const conflict of diff.conflicts) {
335
+ resolutions[conflict.name] = 'use_remote';
336
+ }
337
+ }
338
+
339
+ // 如果没有任何变化
340
+ if (diff.remoteOnly.length === 0 && diff.conflicts.length === 0) {
341
+ console.log(chalk.green('\n✓ 本地已是最新'));
342
+ process.exit(0);
343
+ }
344
+
345
+ // 确认
346
+ if (!options.force) {
347
+ const { confirm } = await inquirer.prompt([
348
+ {
349
+ type: 'confirm',
350
+ name: 'confirm',
351
+ message: '确认拉取?',
352
+ default: true
353
+ }
354
+ ]);
355
+
356
+ if (!confirm) {
357
+ console.log(chalk.yellow('已取消'));
358
+ process.exit(0);
359
+ }
360
+ }
361
+
362
+ // 执行拉取
363
+ console.log(chalk.gray('\n正在拉取...'));
364
+ const result = mergePull(remoteData, diff.conflicts, resolutions);
365
+
366
+ if (result.imported.length > 0) {
367
+ console.log(chalk.green(` ✓ 导入: ${result.imported.join(', ')}`));
368
+ }
369
+ if (result.renamed.length > 0) {
370
+ for (const r of result.renamed) {
371
+ console.log(chalk.cyan(` ✓ ${r.original} → ${r.renamed}`));
372
+ }
373
+ }
374
+ if (result.skipped.length > 0) {
375
+ console.log(chalk.gray(` ○ 跳过: ${result.skipped.join(', ')}`));
376
+ }
377
+
378
+ const total = result.imported.length + result.renamed.length;
379
+ console.log(chalk.green(`\n✓ 已拉取 ${total} 个 profiles`));
380
+ }
381
+
382
+ // status 子命令
383
+ async function statusAction() {
384
+ const config = getWebDAVConfig();
385
+ if (!config) {
386
+ console.log(chalk.red('请先运行 "ccc webdav setup" 配置 WebDAV'));
387
+ process.exit(1);
388
+ }
389
+
390
+ console.log(chalk.cyan('\nWebDAV 同步状态\n'));
391
+ console.log(chalk.gray(`服务器: ${config.url}`));
392
+ console.log(chalk.gray(`路径: ${config.path}`));
393
+ console.log(chalk.gray(`本机免密: ${hasSyncPassword() ? '是' : '否'}`));
394
+
395
+ const syncPassword = await getSyncPassword();
396
+ const client = createWebDAVClient(config);
397
+
398
+ console.log(chalk.gray('\n检查远程状态...'));
399
+
400
+ const remoteInfo = await getRemoteInfo(client, config, syncPassword);
401
+ const localProfiles = getProfiles();
402
+
403
+ console.log(chalk.cyan('\n本地:'));
404
+ console.log(` Profiles: ${localProfiles.length}`);
405
+ if (localProfiles.length > 0) {
406
+ console.log(chalk.gray(` ${localProfiles.join(', ')}`));
407
+ }
408
+
409
+ console.log(chalk.cyan('\n云端:'));
410
+ if (remoteInfo.exists) {
411
+ console.log(` Profiles: ${remoteInfo.profileCount}`);
412
+ console.log(` 最后更新: ${new Date(remoteInfo.updatedAt).toLocaleString()}`);
413
+ if (remoteInfo.profileNames.length > 0) {
414
+ console.log(chalk.gray(` ${remoteInfo.profileNames.join(', ')}`));
415
+ }
416
+ } else if (remoteInfo.error) {
417
+ console.log(chalk.red(` 错误: ${remoteInfo.error}`));
418
+ } else {
419
+ console.log(chalk.gray(' (无数据)'));
420
+ }
421
+
422
+ // 比较差异
423
+ if (remoteInfo.exists) {
424
+ let remoteData = null;
425
+ try {
426
+ remoteData = await downloadProfiles(client, config, syncPassword);
427
+ } catch {
428
+ // ignore
429
+ }
430
+
431
+ if (remoteData) {
432
+ const diff = compareProfiles(localProfiles, remoteData);
433
+
434
+ console.log(chalk.cyan('\n差异:'));
435
+ if (diff.localOnly.length > 0) {
436
+ console.log(chalk.green(` 本地新增: ${diff.localOnly.join(', ')}`));
437
+ }
438
+ if (diff.remoteOnly.length > 0) {
439
+ console.log(chalk.blue(` 云端新增: ${diff.remoteOnly.join(', ')}`));
440
+ }
441
+ if (diff.conflicts.length > 0) {
442
+ console.log(chalk.yellow(` 有冲突: ${diff.conflicts.map(c => c.name).join(', ')}`));
443
+ }
444
+ if (diff.localOnly.length === 0 && diff.remoteOnly.length === 0 && diff.conflicts.length === 0) {
445
+ console.log(chalk.green(' ✓ 已同步'));
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ export function webdavCommand(program) {
452
+ const webdav = program
453
+ .command('webdav')
454
+ .description('WebDAV 云同步');
455
+
456
+ webdav
457
+ .command('setup')
458
+ .description('配置 WebDAV 连接和同步密码')
459
+ .action(setupAction);
460
+
461
+ webdav
462
+ .command('push')
463
+ .description('推送本地 profiles 到云端')
464
+ .option('-f, --force', '强制覆盖,跳过冲突确认')
465
+ .action(pushAction);
466
+
467
+ webdav
468
+ .command('pull')
469
+ .description('从云端拉取 profiles 到本地')
470
+ .option('-f, --force', '强制覆盖,跳过冲突确认')
471
+ .action(pullAction);
472
+
473
+ webdav
474
+ .command('status')
475
+ .description('查看同步状态')
476
+ .action(statusAction);
477
+ }
package/src/crypto.js ADDED
@@ -0,0 +1,147 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { CONFIG_DIR } from './config.js';
6
+
7
+ const ALGORITHM = 'aes-256-gcm';
8
+ const KEY_LENGTH = 32;
9
+ const IV_LENGTH = 16;
10
+ const SALT_LENGTH = 32;
11
+ const AUTH_TAG_LENGTH = 16;
12
+ const PBKDF2_ITERATIONS = 100000;
13
+ const FILE_MAGIC = 'CCC_V1';
14
+ const SYNC_KEY_FILE = path.join(CONFIG_DIR, '.sync_key');
15
+
16
+ // 获取机器指纹(用于本地密码缓存加密)
17
+ function getMachineFingerprint() {
18
+ const hostname = os.hostname();
19
+ const username = os.userInfo().username;
20
+ const platform = os.platform();
21
+ const arch = os.arch();
22
+ // 组合多个机器特征生成指纹
23
+ const fingerprint = `${hostname}:${username}:${platform}:${arch}:ccc-sync`;
24
+ return crypto.createHash('sha256').update(fingerprint).digest();
25
+ }
26
+
27
+ // 从密码派生加密密钥
28
+ function deriveKey(password, salt) {
29
+ return crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha256');
30
+ }
31
+
32
+ // 加密数据
33
+ export function encrypt(plaintext, password) {
34
+ const salt = crypto.randomBytes(SALT_LENGTH);
35
+ const iv = crypto.randomBytes(IV_LENGTH);
36
+ const key = deriveKey(password, salt);
37
+
38
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
39
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
40
+ const authTag = cipher.getAuthTag();
41
+
42
+ // 格式: MAGIC + salt + iv + authTag + encrypted
43
+ const magic = Buffer.from(FILE_MAGIC, 'utf8');
44
+ return Buffer.concat([magic, salt, iv, authTag, encrypted]);
45
+ }
46
+
47
+ // 解密数据
48
+ export function decrypt(encryptedBuffer, password) {
49
+ const magic = Buffer.from(FILE_MAGIC, 'utf8');
50
+ const magicLength = magic.length;
51
+
52
+ // 验证 magic header
53
+ if (encryptedBuffer.length < magicLength + SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH) {
54
+ throw new Error('无效的加密文件格式');
55
+ }
56
+
57
+ const fileMagic = encryptedBuffer.subarray(0, magicLength);
58
+ if (!fileMagic.equals(magic)) {
59
+ throw new Error('无效的加密文件格式');
60
+ }
61
+
62
+ let offset = magicLength;
63
+ const salt = encryptedBuffer.subarray(offset, offset + SALT_LENGTH);
64
+ offset += SALT_LENGTH;
65
+ const iv = encryptedBuffer.subarray(offset, offset + IV_LENGTH);
66
+ offset += IV_LENGTH;
67
+ const authTag = encryptedBuffer.subarray(offset, offset + AUTH_TAG_LENGTH);
68
+ offset += AUTH_TAG_LENGTH;
69
+ const encrypted = encryptedBuffer.subarray(offset);
70
+
71
+ const key = deriveKey(password, salt);
72
+
73
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
74
+ decipher.setAuthTag(authTag);
75
+
76
+ try {
77
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
78
+ return decrypted.toString('utf8');
79
+ } catch (err) {
80
+ throw new Error('解密失败:密码错误或数据损坏');
81
+ }
82
+ }
83
+
84
+ // 使用机器指纹加密本地密码缓存
85
+ export function encryptLocalPassword(password) {
86
+ const key = getMachineFingerprint();
87
+ const iv = crypto.randomBytes(IV_LENGTH);
88
+
89
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
90
+ const encrypted = Buffer.concat([cipher.update(password, 'utf8'), cipher.final()]);
91
+ const authTag = cipher.getAuthTag();
92
+
93
+ return Buffer.concat([iv, authTag, encrypted]);
94
+ }
95
+
96
+ // 使用机器指纹解密本地密码缓存
97
+ export function decryptLocalPassword(encryptedBuffer) {
98
+ if (encryptedBuffer.length < IV_LENGTH + AUTH_TAG_LENGTH) {
99
+ throw new Error('无效的本地密码缓存');
100
+ }
101
+
102
+ const key = getMachineFingerprint();
103
+ const iv = encryptedBuffer.subarray(0, IV_LENGTH);
104
+ const authTag = encryptedBuffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
105
+ const encrypted = encryptedBuffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
106
+
107
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
108
+ decipher.setAuthTag(authTag);
109
+
110
+ try {
111
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
112
+ return decrypted.toString('utf8');
113
+ } catch (err) {
114
+ throw new Error('本地密码缓存解密失败');
115
+ }
116
+ }
117
+
118
+ // 保存同步密码到本地(加密存储)
119
+ export function saveSyncPassword(password) {
120
+ const encrypted = encryptLocalPassword(password);
121
+ fs.writeFileSync(SYNC_KEY_FILE, encrypted);
122
+ }
123
+
124
+ // 读取本地缓存的同步密码
125
+ export function loadSyncPassword() {
126
+ if (!fs.existsSync(SYNC_KEY_FILE)) {
127
+ return null;
128
+ }
129
+ try {
130
+ const encrypted = fs.readFileSync(SYNC_KEY_FILE);
131
+ return decryptLocalPassword(encrypted);
132
+ } catch (err) {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ // 检查是否有本地缓存的密码
138
+ export function hasSyncPassword() {
139
+ return fs.existsSync(SYNC_KEY_FILE);
140
+ }
141
+
142
+ // 清除本地缓存的密码
143
+ export function clearSyncPassword() {
144
+ if (fs.existsSync(SYNC_KEY_FILE)) {
145
+ fs.unlinkSync(SYNC_KEY_FILE);
146
+ }
147
+ }
package/src/profiles.js CHANGED
@@ -106,6 +106,65 @@ export function saveProfile(name, settings) {
106
106
  fs.writeFileSync(profilePath, JSON.stringify(settings, null, 2));
107
107
  }
108
108
 
109
+ // 创建基于主配置的 profile(复制 ~/.claude/settings.json 并设置 env)
110
+ export function createProfileFromTemplate(name, apiUrl, apiKey) {
111
+ const template = getClaudeSettingsTemplate() || {};
112
+
113
+ // 确保 env 对象存在
114
+ if (!template.env) {
115
+ template.env = {};
116
+ }
117
+
118
+ // 只设置 API 凭证到 env
119
+ template.env.ANTHROPIC_AUTH_TOKEN = apiKey;
120
+ template.env.ANTHROPIC_BASE_URL = apiUrl;
121
+
122
+ saveProfile(name, template);
123
+ return template;
124
+ }
125
+
126
+ // 同步主配置到 profile(保留 profile 的 API 凭证)
127
+ export function syncProfileWithTemplate(name) {
128
+ const template = getClaudeSettingsTemplate();
129
+ if (!template) {
130
+ return null;
131
+ }
132
+
133
+ const currentProfile = readProfile(name);
134
+ if (!currentProfile) {
135
+ return null;
136
+ }
137
+
138
+ // 保存当前 profile 的 API 凭证(支持新旧格式)
139
+ const currentEnv = currentProfile.env || {};
140
+ const apiKey = currentEnv.ANTHROPIC_AUTH_TOKEN || currentProfile.ANTHROPIC_AUTH_TOKEN || '';
141
+ const apiUrl = currentEnv.ANTHROPIC_BASE_URL || currentProfile.ANTHROPIC_BASE_URL || '';
142
+
143
+ // 复制主配置
144
+ const newProfile = { ...template };
145
+
146
+ // 确保 env 对象存在并保留 API 凭证
147
+ newProfile.env = { ...(template.env || {}), ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_BASE_URL: apiUrl };
148
+
149
+ saveProfile(name, newProfile);
150
+ return newProfile;
151
+ }
152
+
153
+ // 从 profile 中提取 API 凭证
154
+ export function getProfileCredentials(name) {
155
+ const profile = readProfile(name);
156
+ if (!profile) {
157
+ return { apiKey: '', apiUrl: '' };
158
+ }
159
+
160
+ // 支持旧格式(直接在顶层)和新格式(在 env 中)
161
+ const env = profile.env || {};
162
+ return {
163
+ apiKey: env.ANTHROPIC_AUTH_TOKEN || profile.ANTHROPIC_AUTH_TOKEN || '',
164
+ apiUrl: env.ANTHROPIC_BASE_URL || profile.ANTHROPIC_BASE_URL || ''
165
+ };
166
+ }
167
+
109
168
  // 删除 profile
110
169
  export function deleteProfile(name) {
111
170
  const profilePath = getProfilePath(name);
package/src/webdav.js ADDED
@@ -0,0 +1,268 @@
1
+ import { createClient } from 'webdav';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { CONFIG_DIR, PROFILES_DIR } from './config.js';
5
+ import { encrypt, decrypt } from './crypto.js';
6
+ import { getProfiles, readProfile, saveProfile } from './profiles.js';
7
+
8
+ const WEBDAV_CONFIG_FILE = path.join(CONFIG_DIR, 'webdav.json');
9
+ const REMOTE_FILE_NAME = 'ccc-profiles.encrypted';
10
+
11
+ // 读取 WebDAV 配置
12
+ export function getWebDAVConfig() {
13
+ if (!fs.existsSync(WEBDAV_CONFIG_FILE)) {
14
+ return null;
15
+ }
16
+ try {
17
+ return JSON.parse(fs.readFileSync(WEBDAV_CONFIG_FILE, 'utf-8'));
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ // 保存 WebDAV 配置
24
+ export function saveWebDAVConfig(config) {
25
+ fs.writeFileSync(WEBDAV_CONFIG_FILE, JSON.stringify(config, null, 2));
26
+ }
27
+
28
+ // 创建 WebDAV 客户端
29
+ export function createWebDAVClient(config) {
30
+ return createClient(config.url, {
31
+ username: config.username,
32
+ password: config.password
33
+ });
34
+ }
35
+
36
+ // 获取远程文件路径
37
+ function getRemotePath(config) {
38
+ const basePath = config.path || '/';
39
+ return path.posix.join(basePath, REMOTE_FILE_NAME);
40
+ }
41
+
42
+ // 上传加密的 profiles 到 WebDAV
43
+ export async function uploadProfiles(client, config, syncPassword) {
44
+ const profiles = getProfiles();
45
+ const profilesData = {};
46
+
47
+ for (const name of profiles) {
48
+ const profile = readProfile(name);
49
+ if (profile) {
50
+ profilesData[name] = {
51
+ data: profile,
52
+ updatedAt: Date.now()
53
+ };
54
+ }
55
+ }
56
+
57
+ const payload = {
58
+ version: 1,
59
+ updatedAt: Date.now(),
60
+ profiles: profilesData
61
+ };
62
+
63
+ const encrypted = encrypt(JSON.stringify(payload), syncPassword);
64
+ const remotePath = getRemotePath(config);
65
+
66
+ // 确保目录存在
67
+ const baseDir = config.path || '/';
68
+ try {
69
+ await client.createDirectory(baseDir, { recursive: true });
70
+ } catch {
71
+ // 目录可能已存在
72
+ }
73
+
74
+ await client.putFileContents(remotePath, encrypted);
75
+ return { count: profiles.length, updatedAt: payload.updatedAt };
76
+ }
77
+
78
+ // 从 WebDAV 下载加密的 profiles
79
+ export async function downloadProfiles(client, config, syncPassword) {
80
+ const remotePath = getRemotePath(config);
81
+
82
+ try {
83
+ const exists = await client.exists(remotePath);
84
+ if (!exists) {
85
+ return null;
86
+ }
87
+ } catch {
88
+ return null;
89
+ }
90
+
91
+ const encrypted = await client.getFileContents(remotePath);
92
+ const decrypted = decrypt(Buffer.from(encrypted), syncPassword);
93
+ return JSON.parse(decrypted);
94
+ }
95
+
96
+ // 获取远程文件信息
97
+ export async function getRemoteInfo(client, config, syncPassword) {
98
+ try {
99
+ const data = await downloadProfiles(client, config, syncPassword);
100
+ if (!data) {
101
+ return { exists: false };
102
+ }
103
+ return {
104
+ exists: true,
105
+ updatedAt: data.updatedAt,
106
+ profileCount: Object.keys(data.profiles).length,
107
+ profileNames: Object.keys(data.profiles)
108
+ };
109
+ } catch (err) {
110
+ return { exists: false, error: err.message };
111
+ }
112
+ }
113
+
114
+ // 比较本地和远程 profiles,返回差异
115
+ export function compareProfiles(localProfiles, remoteData) {
116
+ const local = new Set(localProfiles);
117
+ const remote = new Set(remoteData ? Object.keys(remoteData.profiles) : []);
118
+
119
+ const result = {
120
+ localOnly: [], // 只在本地存在
121
+ remoteOnly: [], // 只在远程存在
122
+ both: [], // 两边都有
123
+ conflicts: [] // 两边都有且内容不同
124
+ };
125
+
126
+ // 本地存在的
127
+ for (const name of local) {
128
+ if (remote.has(name)) {
129
+ result.both.push(name);
130
+ } else {
131
+ result.localOnly.push(name);
132
+ }
133
+ }
134
+
135
+ // 只在远程存在的
136
+ for (const name of remote) {
137
+ if (!local.has(name)) {
138
+ result.remoteOnly.push(name);
139
+ }
140
+ }
141
+
142
+ // 检查内容是否相同
143
+ if (remoteData) {
144
+ for (const name of result.both) {
145
+ const localProfile = readProfile(name);
146
+ const remoteProfile = remoteData.profiles[name].data;
147
+
148
+ if (JSON.stringify(localProfile) !== JSON.stringify(remoteProfile)) {
149
+ result.conflicts.push({
150
+ name,
151
+ localData: localProfile,
152
+ remoteData: remoteProfile,
153
+ remoteUpdatedAt: remoteData.profiles[name].updatedAt
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ return result;
160
+ }
161
+
162
+ // 执行最大合并的 pull 操作
163
+ export function mergePull(remoteData, conflicts, resolutions) {
164
+ const imported = [];
165
+ const skipped = [];
166
+ const renamed = [];
167
+
168
+ if (!remoteData) {
169
+ return { imported, skipped, renamed };
170
+ }
171
+
172
+ const localProfiles = new Set(getProfiles());
173
+
174
+ for (const [name, profileData] of Object.entries(remoteData.profiles)) {
175
+ const conflict = conflicts.find(c => c.name === name);
176
+
177
+ if (conflict) {
178
+ // 有冲突
179
+ const resolution = resolutions[name] || 'keep_both';
180
+
181
+ if (resolution === 'use_remote') {
182
+ saveProfile(name, profileData.data);
183
+ imported.push(name);
184
+ } else if (resolution === 'keep_local') {
185
+ skipped.push(name);
186
+ } else {
187
+ // keep_both: 保留两者,远程版本重命名
188
+ const newName = `${name}_cloud`;
189
+ saveProfile(newName, profileData.data);
190
+ renamed.push({ original: name, renamed: newName });
191
+ }
192
+ } else if (!localProfiles.has(name)) {
193
+ // 本地不存在,直接导入
194
+ saveProfile(name, profileData.data);
195
+ imported.push(name);
196
+ }
197
+ }
198
+
199
+ return { imported, skipped, renamed };
200
+ }
201
+
202
+ // 执行最大合并的 push 操作
203
+ export async function mergePush(client, config, syncPassword, conflicts, resolutions) {
204
+ const localProfiles = getProfiles();
205
+ let remoteData = null;
206
+
207
+ try {
208
+ remoteData = await downloadProfiles(client, config, syncPassword);
209
+ } catch {
210
+ // 远程可能不存在
211
+ }
212
+
213
+ const profilesData = {};
214
+
215
+ // 保留远程独有的(最大合并)
216
+ if (remoteData) {
217
+ const localSet = new Set(localProfiles);
218
+ for (const [name, data] of Object.entries(remoteData.profiles)) {
219
+ if (!localSet.has(name)) {
220
+ profilesData[name] = data;
221
+ }
222
+ }
223
+ }
224
+
225
+ // 添加本地的
226
+ for (const name of localProfiles) {
227
+ const profile = readProfile(name);
228
+ if (!profile) continue;
229
+
230
+ const conflict = conflicts.find(c => c.name === name);
231
+ if (conflict) {
232
+ const resolution = resolutions[name] || 'keep_both';
233
+
234
+ if (resolution === 'use_local') {
235
+ profilesData[name] = { data: profile, updatedAt: Date.now() };
236
+ } else if (resolution === 'keep_remote') {
237
+ // 保留远程版本
238
+ profilesData[name] = remoteData.profiles[name];
239
+ } else {
240
+ // keep_both: 保留两者,本地版本用 _local 后缀
241
+ profilesData[name] = remoteData.profiles[name]; // 保留原名为远程版本
242
+ profilesData[`${name}_local`] = { data: profile, updatedAt: Date.now() };
243
+ }
244
+ } else {
245
+ profilesData[name] = { data: profile, updatedAt: Date.now() };
246
+ }
247
+ }
248
+
249
+ const payload = {
250
+ version: 1,
251
+ updatedAt: Date.now(),
252
+ profiles: profilesData
253
+ };
254
+
255
+ const encrypted = encrypt(JSON.stringify(payload), syncPassword);
256
+ const remotePath = getRemotePath(config);
257
+
258
+ // 确保目录存在
259
+ const baseDir = config.path || '/';
260
+ try {
261
+ await client.createDirectory(baseDir, { recursive: true });
262
+ } catch {
263
+ // 目录可能已存在
264
+ }
265
+
266
+ await client.putFileContents(remotePath, encrypted);
267
+ return { count: Object.keys(profilesData).length };
268
+ }