@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 +77 -0
- package/index.js +6 -2
- package/package.json +3 -2
- package/src/commands/edit.js +27 -13
- package/src/commands/index.js +2 -0
- package/src/commands/list.js +3 -15
- package/src/commands/new.js +4 -10
- package/src/commands/sync.js +93 -0
- package/src/commands/webdav.js +477 -0
- package/src/crypto.js +147 -0
- package/src/profiles.js +59 -0
- package/src/webdav.js +268 -0
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.
|
|
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.
|
|
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
|
}
|
package/src/commands/edit.js
CHANGED
|
@@ -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
|
-
|
|
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: ${
|
|
54
|
-
console.log(chalk.gray(` ANTHROPIC_AUTH_TOKEN: ${
|
|
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:
|
|
65
|
+
default: currentApiUrl || ''
|
|
63
66
|
},
|
|
64
67
|
{
|
|
65
68
|
type: 'input',
|
|
66
69
|
name: 'apiKey',
|
|
67
70
|
message: 'ANTHROPIC_AUTH_TOKEN:',
|
|
68
|
-
default:
|
|
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
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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,
|
|
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,
|
|
115
|
+
saveProfile(profile, currentProfile);
|
|
102
116
|
console.log(chalk.green(`\n✓ Profile "${profile}" 已更新`));
|
|
103
117
|
}
|
|
104
118
|
});
|
package/src/commands/index.js
CHANGED
|
@@ -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
|
|
package/src/commands/list.js
CHANGED
|
@@ -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,
|
|
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
|
|
35
|
-
|
|
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;
|
package/src/commands/new.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
ensureDirs,
|
|
5
5
|
getProfiles,
|
|
6
6
|
profileExists,
|
|
7
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
+
}
|