@tengx5383/aitool-sync-cli 0.1.1 → 0.2.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/dist/cli.js +4 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +13 -16
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +71 -26
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/list.js +1 -1
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/pull.d.ts.map +1 -1
- package/dist/commands/pull.js +9 -10
- package/dist/commands/pull.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +39 -22
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +15 -8
- package/dist/commands/status.js.map +1 -1
- package/dist/config/config-file.js +1 -1
- package/dist/config/config-file.js.map +1 -1
- package/dist/config/schema.d.ts +3 -3
- package/dist/config/schema.js +1 -1
- package/dist/config/schema.js.map +1 -1
- package/dist/core/backup.d.ts.map +1 -1
- package/dist/core/backup.js +41 -13
- package/dist/core/backup.js.map +1 -1
- package/dist/core/file-collector.js +70 -1
- package/dist/core/file-collector.js.map +1 -1
- package/dist/core/manifest.d.ts.map +1 -1
- package/dist/core/sync-engine.d.ts +7 -14
- package/dist/core/sync-engine.d.ts.map +1 -1
- package/dist/core/sync-engine.js +192 -140
- package/dist/core/sync-engine.js.map +1 -1
- package/dist/git/github-api.d.ts +14 -0
- package/dist/git/github-api.d.ts.map +1 -0
- package/dist/git/github-api.js +38 -0
- package/dist/git/github-api.js.map +1 -0
- package/dist/git/repo-client.d.ts +17 -0
- package/dist/git/repo-client.d.ts.map +1 -0
- package/dist/git/repo-client.js +40 -0
- package/dist/git/repo-client.js.map +1 -0
- package/dist/types/config.d.ts +2 -2
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/git.d.ts +5 -0
- package/dist/types/git.d.ts.map +1 -0
- package/dist/types/git.js +2 -0
- package/dist/types/git.js.map +1 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +10 -3
- package/.aitoolsync.json +0 -54
- package/.claude/settings.local.json +0 -8
- package/tests/integration/encryption-flow.test.ts +0 -142
- package/tests/integration/init-flow.test.ts +0 -69
- package/tests/integration/push-pull-flow.test.ts +0 -183
- package/tests/integration/status-flow.test.ts +0 -149
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tengx5383/aitool-sync-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI for syncing AI agent tool configs across devices via
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "CLI for syncing AI agent tool configs across devices via Git repositories",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
"test:watch": "vitest",
|
|
17
17
|
"prettier": "prettier --write ."
|
|
18
18
|
},
|
|
19
|
-
"keywords": [
|
|
19
|
+
"keywords": [
|
|
20
|
+
"cli",
|
|
21
|
+
"sync",
|
|
22
|
+
"ai-tools",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"config"
|
|
25
|
+
],
|
|
20
26
|
"license": "MIT",
|
|
21
27
|
"dependencies": {
|
|
22
28
|
"@octokit/rest": "^21.0.0",
|
|
@@ -27,6 +33,7 @@
|
|
|
27
33
|
"inquirer": "^12.0.0",
|
|
28
34
|
"micromatch": "^4.0.8",
|
|
29
35
|
"ora": "^8.1.0",
|
|
36
|
+
"simple-git": "^3.36.0",
|
|
30
37
|
"zod": "^3.23.0"
|
|
31
38
|
},
|
|
32
39
|
"devDependencies": {
|
package/.aitoolsync.json
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 1,
|
|
3
|
-
"gistId": "18d8aee30d5238441184ac39def40f79",
|
|
4
|
-
"githubToken": "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",
|
|
5
|
-
"syncPaths": [
|
|
6
|
-
{
|
|
7
|
-
"name": "claude-code-settings",
|
|
8
|
-
"path": "~/.claude/settings.json",
|
|
9
|
-
"enabled": true
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
"name": "claude-code-mcp",
|
|
13
|
-
"path": "~/.claude/mcp.json",
|
|
14
|
-
"enabled": true
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
"name": "claude-code-claude-json",
|
|
18
|
-
"path": "~/.claude/claude.json",
|
|
19
|
-
"enabled": true
|
|
20
|
-
},
|
|
21
|
-
{
|
|
22
|
-
"name": "claude-code-skills",
|
|
23
|
-
"path": "~/.claude/skills/",
|
|
24
|
-
"enabled": true
|
|
25
|
-
}
|
|
26
|
-
],
|
|
27
|
-
"exclusions": [
|
|
28
|
-
{
|
|
29
|
-
"pattern": "**/node_modules/**"
|
|
30
|
-
},
|
|
31
|
-
{
|
|
32
|
-
"pattern": "**/.git/**"
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
"pattern": "**/dist/**"
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
"pattern": "**/*.log"
|
|
39
|
-
},
|
|
40
|
-
{
|
|
41
|
-
"pattern": "**/.DS_Store"
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
"pattern": "**/.claude/credentials.json"
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
"pattern": "**/.claude/*.log"
|
|
48
|
-
}
|
|
49
|
-
],
|
|
50
|
-
"encryption": [],
|
|
51
|
-
"lastKnownManifest": "{\"version\":1,\"generatedAt\":\"2026-06-07T00:11:20.916Z\",\"entries\":[{\"relativePath\":\"settings.json\",\"checksum\":\"5e2096bd743385a4539a90b58bd33c91816e84ca40b76e4fb17928c21dfcf98a\",\"size\":587,\"modifiedAt\":\"2026-06-06T16:45:01.161Z\",\"sourceName\":\"claude-code-settings\",\"encrypted\":false,\"encryptionPattern\":null}]}",
|
|
52
|
-
"lastPushAt": "2026-06-07T00:11:21.875Z",
|
|
53
|
-
"lastPullAt": null
|
|
54
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"permissions": {
|
|
3
|
-
"allow": [
|
|
4
|
-
"Bash(node -e \"const p = require\\('./package-lock.json'\\); p.name = '@tengx5383/aitool-sync-cli'; p.packages[''].name = '@tengx5383/aitool-sync-cli'; require\\('fs'\\).writeFileSync\\('package-lock.json', JSON.stringify\\(p, null, 2\\) + '\\\\n'\\)\")",
|
|
5
|
-
"Bash(npm run *)"
|
|
6
|
-
]
|
|
7
|
-
}
|
|
8
|
-
}
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import { encrypt, decrypt, isEncrypted } from '../../src/crypto/encryptor.js';
|
|
6
|
-
import { setPassword, clearPassword, resolvePassword, encryptWithPassword, decryptWithPassword, hasPassword } from '../../src/crypto/key-resolver.js';
|
|
7
|
-
import { createDefaultConfig, writeConfig } from '../../src/config/config-file.js';
|
|
8
|
-
import { buildSyncPackage, parseSyncPackage } from '../../src/core/sync-package.js';
|
|
9
|
-
import { createManifest, serializeManifest } from '../../src/core/manifest.js';
|
|
10
|
-
import { pushSync, pullSync, writeRemoteFiles } from '../../src/core/sync-engine.js';
|
|
11
|
-
import { MockGistClient } from '../../src/gist/mock-client.js';
|
|
12
|
-
import type { AppConfig } from '../../src/types/index.js';
|
|
13
|
-
|
|
14
|
-
const TEST_PASSWORD = 'test-secret-password-123';
|
|
15
|
-
|
|
16
|
-
function createTestDir(base: string): string {
|
|
17
|
-
const dir = path.join(base, 'encrypted-tools');
|
|
18
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
-
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ version: 1 }));
|
|
20
|
-
fs.writeFileSync(path.join(dir, 'token.json'), JSON.stringify({ apiKey: 'secret-token-123' }));
|
|
21
|
-
return dir;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
describe('encryption flow', () => {
|
|
25
|
-
let tmpDir: string;
|
|
26
|
-
let mockClient: MockGistClient;
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aitoolsync-crypto-'));
|
|
30
|
-
process.chdir(tmpDir);
|
|
31
|
-
mockClient = new MockGistClient();
|
|
32
|
-
setPassword(TEST_PASSWORD);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
37
|
-
clearPassword();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('encrypts and decrypts a string', () => {
|
|
41
|
-
const plaintext = 'Hello, World!';
|
|
42
|
-
const encrypted = encrypt(plaintext, TEST_PASSWORD);
|
|
43
|
-
expect(encrypted).not.toBe(plaintext);
|
|
44
|
-
|
|
45
|
-
const decrypted = decrypt(encrypted, TEST_PASSWORD);
|
|
46
|
-
expect(decrypted).toBe(plaintext);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('detects encrypted payloads', () => {
|
|
50
|
-
const encrypted = encrypt('test data', TEST_PASSWORD);
|
|
51
|
-
expect(isEncrypted(encrypted)).toBe(true);
|
|
52
|
-
expect(isEncrypted('plain text')).toBe(false);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('key resolver caches and resolves password', () => {
|
|
56
|
-
clearPassword();
|
|
57
|
-
expect(hasPassword()).toBe(false);
|
|
58
|
-
|
|
59
|
-
setPassword('my-password');
|
|
60
|
-
expect(hasPassword()).toBe(true);
|
|
61
|
-
expect(resolvePassword()).toBe('my-password');
|
|
62
|
-
|
|
63
|
-
clearPassword();
|
|
64
|
-
expect(hasPassword()).toBe(false);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('encrypt with password convenience function', () => {
|
|
68
|
-
const plaintext = 'sensitive data';
|
|
69
|
-
const encrypted = encryptWithPassword(plaintext);
|
|
70
|
-
expect(encrypted).not.toBe(plaintext);
|
|
71
|
-
|
|
72
|
-
const decrypted = decryptWithPassword(encrypted);
|
|
73
|
-
expect(decrypted).toBe(plaintext);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('push and pull with encrypted files', async () => {
|
|
77
|
-
const testDir = createTestDir(tmpDir);
|
|
78
|
-
const config = createDefaultConfig();
|
|
79
|
-
config.syncPaths = [{ name: 'enc-tools', path: testDir, enabled: true }];
|
|
80
|
-
config.encryption = [{ pattern: '**/token.json' }];
|
|
81
|
-
|
|
82
|
-
const gist = await mockClient.createGist('aitoolsync', {
|
|
83
|
-
'aitoolsync-package.json': { content: '{}' },
|
|
84
|
-
});
|
|
85
|
-
config.gistId = gist.gistId;
|
|
86
|
-
writeConfig(config);
|
|
87
|
-
|
|
88
|
-
// Push with encryption
|
|
89
|
-
const pushResult = await pushSync(config, mockClient, false, encryptWithPassword);
|
|
90
|
-
expect(pushResult.success).toBe(true);
|
|
91
|
-
|
|
92
|
-
// Verify Gist content is encrypted for token.json
|
|
93
|
-
const fetched = await mockClient.getGist(gist.gistId);
|
|
94
|
-
const pkgFile = fetched.files.find((f) => f.filename === 'aitoolsync-package.json');
|
|
95
|
-
const pkg = parseSyncPackage(pkgFile!.content);
|
|
96
|
-
const tokenFile = pkg!.files.find((f) => f.relativePath === 'token.json');
|
|
97
|
-
expect(tokenFile!.encrypted).toBe(true);
|
|
98
|
-
expect(tokenFile!.content).not.toContain('secret-token');
|
|
99
|
-
|
|
100
|
-
const configFile = pkg!.files.find((f) => f.relativePath === 'config.json');
|
|
101
|
-
expect(configFile!.encrypted).toBe(false);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('pull and decrypt files', async () => {
|
|
105
|
-
const testDir = createTestDir(tmpDir);
|
|
106
|
-
const config = createDefaultConfig();
|
|
107
|
-
config.syncPaths = [{ name: 'enc-tools', path: testDir, enabled: true }];
|
|
108
|
-
config.encryption = [{ pattern: '**/token.json' }];
|
|
109
|
-
|
|
110
|
-
const gist = await mockClient.createGist('aitoolsync', {
|
|
111
|
-
'aitoolsync-package.json': { content: '{}' },
|
|
112
|
-
});
|
|
113
|
-
config.gistId = gist.gistId;
|
|
114
|
-
writeConfig(config);
|
|
115
|
-
|
|
116
|
-
// Push with encryption first
|
|
117
|
-
await pushSync(config, mockClient, false, encryptWithPassword);
|
|
118
|
-
|
|
119
|
-
// Corrupt local files
|
|
120
|
-
fs.writeFileSync(path.join(testDir, 'token.json'), 'CORRUPTED');
|
|
121
|
-
|
|
122
|
-
// Pull and decrypt
|
|
123
|
-
const pullResult = await pullSync(config, mockClient, false, decryptWithPassword);
|
|
124
|
-
expect(pullResult.success).toBe(true);
|
|
125
|
-
|
|
126
|
-
// Verify file was restored correctly
|
|
127
|
-
const content = JSON.parse(fs.readFileSync(path.join(testDir, 'token.json'), 'utf-8'));
|
|
128
|
-
expect(content.apiKey).toBe('secret-token-123');
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('fails to decrypt with wrong password', () => {
|
|
132
|
-
const encrypted = encrypt('secret', TEST_PASSWORD);
|
|
133
|
-
expect(() => decrypt(encrypted, 'wrong-password')).toThrow();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('encrypted content does not leak plaintext', () => {
|
|
137
|
-
const plaintext = 'super-secret-api-key-12345';
|
|
138
|
-
const encrypted = encrypt(plaintext, TEST_PASSWORD);
|
|
139
|
-
expect(encrypted).not.toContain('super-secret-api-key');
|
|
140
|
-
expect(encrypted).not.toContain('12345');
|
|
141
|
-
});
|
|
142
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import { createDefaultConfig, readConfig, writeConfig } from '../../src/config/config-file.js';
|
|
6
|
-
import { getPreset, getAllPresets } from '../../src/presets/index.js';
|
|
7
|
-
|
|
8
|
-
describe('init flow', () => {
|
|
9
|
-
let tmpDir: string;
|
|
10
|
-
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aitoolsync-test-'));
|
|
13
|
-
process.chdir(tmpDir);
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
afterEach(() => {
|
|
17
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('creates a default config file', () => {
|
|
21
|
-
const config = createDefaultConfig();
|
|
22
|
-
writeConfig(config, tmpDir);
|
|
23
|
-
|
|
24
|
-
const configPath = path.join(tmpDir, '.aitoolsync.json');
|
|
25
|
-
expect(fs.existsSync(configPath)).toBe(true);
|
|
26
|
-
|
|
27
|
-
const loaded = readConfig(configPath);
|
|
28
|
-
expect(loaded).not.toBeNull();
|
|
29
|
-
expect(loaded!.version).toBe(1);
|
|
30
|
-
expect(loaded!.gistId).toBeNull();
|
|
31
|
-
expect(loaded!.syncPaths).toEqual([]);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('reads and validates config file', () => {
|
|
35
|
-
const config = createDefaultConfig();
|
|
36
|
-
config.gistId = 'test-gist-123';
|
|
37
|
-
writeConfig(config, tmpDir);
|
|
38
|
-
|
|
39
|
-
const loaded = readConfig();
|
|
40
|
-
expect(loaded).not.toBeNull();
|
|
41
|
-
expect(loaded!.gistId).toBe('test-gist-123');
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('returns null for non-existent config', () => {
|
|
45
|
-
const loaded = readConfig();
|
|
46
|
-
expect(loaded).toBeNull();
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('has all presets defined', () => {
|
|
50
|
-
const presets = getAllPresets();
|
|
51
|
-
expect(presets.length).toBe(3);
|
|
52
|
-
expect(presets.map((p) => p.id)).toContain('claude-code');
|
|
53
|
-
expect(presets.map((p) => p.id)).toContain('hermes');
|
|
54
|
-
expect(presets.map((p) => p.id)).toContain('openclaw');
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('can get preset by ID', () => {
|
|
58
|
-
const preset = getPreset('claude-code');
|
|
59
|
-
expect(preset).not.toBeUndefined();
|
|
60
|
-
expect(preset!.name).toBe('Claude Code');
|
|
61
|
-
expect(preset!.defaultPaths.length).toBeGreaterThan(0);
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('includes default exclusions in new config', () => {
|
|
65
|
-
const config = createDefaultConfig();
|
|
66
|
-
expect(config.exclusions.some((e) => e.pattern === '**/node_modules/**')).toBe(true);
|
|
67
|
-
expect(config.exclusions.some((e) => e.pattern === '**/.git/**')).toBe(true);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import { createDefaultConfig, writeConfig, readConfig } from '../../src/config/config-file.js';
|
|
6
|
-
import { pushSync, pullSync } from '../../src/core/sync-engine.js';
|
|
7
|
-
import { buildSyncPackage, parseSyncPackage } from '../../src/core/sync-package.js';
|
|
8
|
-
import { createManifest, serializeManifest, parseManifest } from '../../src/core/manifest.js';
|
|
9
|
-
import { MockGistClient } from '../../src/gist/mock-client.js';
|
|
10
|
-
import type { AppConfig, Manifest, ManifestEntry } from '../../src/types/index.js';
|
|
11
|
-
|
|
12
|
-
function createTestDir(base: string): string {
|
|
13
|
-
const dir = path.join(base, 'my-tools');
|
|
14
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
15
|
-
fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ version: 1 }));
|
|
16
|
-
fs.writeFileSync(path.join(dir, 'secrets.env'), 'SECRET=abc');
|
|
17
|
-
return dir;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe('push-pull flow', () => {
|
|
21
|
-
let tmpDir: string;
|
|
22
|
-
let mockClient: MockGistClient;
|
|
23
|
-
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aitoolsync-pushpull-'));
|
|
26
|
-
process.chdir(tmpDir);
|
|
27
|
-
mockClient = new MockGistClient();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('pushSync creates a Gist and uploads package', async () => {
|
|
35
|
-
const testDir = createTestDir(tmpDir);
|
|
36
|
-
const config = createDefaultConfig();
|
|
37
|
-
config.syncPaths = [{ name: 'my-tools', path: testDir, enabled: true }];
|
|
38
|
-
|
|
39
|
-
// Create Gist first
|
|
40
|
-
const gist = await mockClient.createGist('aitoolsync', {
|
|
41
|
-
'aitoolsync-package.json': { content: '{}' },
|
|
42
|
-
});
|
|
43
|
-
config.gistId = gist.gistId;
|
|
44
|
-
writeConfig(config);
|
|
45
|
-
|
|
46
|
-
const result = await pushSync(config, mockClient, false);
|
|
47
|
-
expect(result.success).toBe(true);
|
|
48
|
-
expect(result.filesUploaded).toBeGreaterThan(0);
|
|
49
|
-
|
|
50
|
-
// Verify Gist contains the package
|
|
51
|
-
const fetched = await mockClient.getGist(gist.gistId);
|
|
52
|
-
const pkgFile = fetched.files.find((f) => f.filename === 'aitoolsync-package.json');
|
|
53
|
-
expect(pkgFile).toBeDefined();
|
|
54
|
-
|
|
55
|
-
const pkg = parseSyncPackage(pkgFile!.content);
|
|
56
|
-
expect(pkg).not.toBeNull();
|
|
57
|
-
expect(pkg!.files.length).toBeGreaterThan(0);
|
|
58
|
-
expect(pkg!.manifest.entries.length).toBeGreaterThan(0);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('pushSync detects conflicts and fails without force', async () => {
|
|
62
|
-
const testDir = createTestDir(tmpDir);
|
|
63
|
-
const config = createDefaultConfig();
|
|
64
|
-
config.syncPaths = [{ name: 'my-tools', path: testDir, enabled: true }];
|
|
65
|
-
|
|
66
|
-
const gist = await mockClient.createGist('aitoolsync', {
|
|
67
|
-
'aitoolsync-package.json': { content: '{}' },
|
|
68
|
-
});
|
|
69
|
-
config.gistId = gist.gistId;
|
|
70
|
-
|
|
71
|
-
// Set lastKnownManifest to a different version (third checksum)
|
|
72
|
-
const baseManifest = createManifest([
|
|
73
|
-
{ relativePath: 'config.json', checksum: 'base-hash', size: 100, modifiedAt: new Date().toISOString(), sourceName: 'my-tools', encrypted: false, encryptionPattern: null },
|
|
74
|
-
]);
|
|
75
|
-
config.lastKnownManifest = serializeManifest(baseManifest);
|
|
76
|
-
writeConfig(config);
|
|
77
|
-
|
|
78
|
-
// Create a remote package with different content
|
|
79
|
-
const remoteManifest = createManifest([
|
|
80
|
-
{ relativePath: 'config.json', checksum: 'different-hash', size: 100, modifiedAt: new Date().toISOString(), sourceName: 'my-tools', encrypted: false, encryptionPattern: null },
|
|
81
|
-
]);
|
|
82
|
-
const remotePkg = await buildSyncPackage(remoteManifest, config);
|
|
83
|
-
await mockClient.updateGist(gist.gistId, {
|
|
84
|
-
'aitoolsync-package.json': { content: JSON.stringify(remotePkg) },
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
// Modify local file to be different from both
|
|
88
|
-
fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify({ version: 2, changed: true }));
|
|
89
|
-
|
|
90
|
-
const result = await pushSync(config, mockClient, false);
|
|
91
|
-
expect(result.success).toBe(false);
|
|
92
|
-
expect(result.errors.some((e) => e.includes('Conflicts'))).toBe(true);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('pushSync --force overrides conflicts', async () => {
|
|
96
|
-
const testDir = createTestDir(tmpDir);
|
|
97
|
-
const config = createDefaultConfig();
|
|
98
|
-
config.syncPaths = [{ name: 'my-tools', path: testDir, enabled: true }];
|
|
99
|
-
|
|
100
|
-
const gist = await mockClient.createGist('aitoolsync', {
|
|
101
|
-
'aitoolsync-package.json': { content: '{}' },
|
|
102
|
-
});
|
|
103
|
-
config.gistId = gist.gistId;
|
|
104
|
-
|
|
105
|
-
// Create remote with different content
|
|
106
|
-
const remoteManifest = createManifest([
|
|
107
|
-
{ relativePath: 'config.json', checksum: 'remotehash', size: 100, modifiedAt: new Date().toISOString(), sourceName: 'my-tools', encrypted: false, encryptionPattern: null },
|
|
108
|
-
]);
|
|
109
|
-
// Set lastKnownManifest to something else to create a three-way conflict
|
|
110
|
-
const baseManifest = createManifest([
|
|
111
|
-
{ relativePath: 'config.json', checksum: 'basehash', size: 100, modifiedAt: new Date().toISOString(), sourceName: 'my-tools', encrypted: false, encryptionPattern: null },
|
|
112
|
-
]);
|
|
113
|
-
config.lastKnownManifest = serializeManifest(baseManifest);
|
|
114
|
-
writeConfig(config);
|
|
115
|
-
|
|
116
|
-
// Write remote package
|
|
117
|
-
const remotePkg = await buildSyncPackage(remoteManifest, config);
|
|
118
|
-
await mockClient.updateGist(gist.gistId, {
|
|
119
|
-
'aitoolsync-package.json': { content: JSON.stringify(remotePkg) },
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// Modify local
|
|
123
|
-
fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify({ forced: true }));
|
|
124
|
-
|
|
125
|
-
const result = await pushSync(config, mockClient, true);
|
|
126
|
-
expect(result.success).toBe(true);
|
|
127
|
-
expect(result.action).toBe('force-push');
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('pullSync downloads files and writes them locally', async () => {
|
|
131
|
-
const testDir = createTestDir(tmpDir);
|
|
132
|
-
const config = createDefaultConfig();
|
|
133
|
-
config.syncPaths = [{ name: 'my-tools', path: testDir, enabled: true }];
|
|
134
|
-
|
|
135
|
-
const gist = await mockClient.createGist('aitoolsync', {
|
|
136
|
-
'aitoolsync-package.json': { content: '{}' },
|
|
137
|
-
});
|
|
138
|
-
config.gistId = gist.gistId;
|
|
139
|
-
|
|
140
|
-
// Push local first
|
|
141
|
-
const pushResult = await pushSync(config, mockClient, false);
|
|
142
|
-
expect(pushResult.success).toBe(true);
|
|
143
|
-
|
|
144
|
-
// Now modify local file
|
|
145
|
-
fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify({ version: 999 }));
|
|
146
|
-
|
|
147
|
-
// Pull should overwrite with remote version
|
|
148
|
-
const result = await pullSync(config, mockClient, false);
|
|
149
|
-
expect(result.success).toBe(true);
|
|
150
|
-
expect(result.filesDownloaded).toBeGreaterThan(0);
|
|
151
|
-
|
|
152
|
-
// Verify file was restored from remote
|
|
153
|
-
const content = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf-8'));
|
|
154
|
-
expect(content.version).toBe(1); // original version from push
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('sync package round-trips correctly', async () => {
|
|
158
|
-
const testDir = createTestDir(tmpDir);
|
|
159
|
-
const config = createDefaultConfig();
|
|
160
|
-
config.syncPaths = [{ name: 'my-tools', path: testDir, enabled: true }];
|
|
161
|
-
|
|
162
|
-
const manifest = createManifest([
|
|
163
|
-
{ relativePath: 'config.json', checksum: 'abc', size: 100, modifiedAt: new Date().toISOString(), sourceName: 'my-tools', encrypted: false, encryptionPattern: null },
|
|
164
|
-
{ relativePath: 'secrets.env', checksum: 'def', size: 200, modifiedAt: new Date().toISOString(), sourceName: 'my-tools', encrypted: false, encryptionPattern: null },
|
|
165
|
-
]);
|
|
166
|
-
|
|
167
|
-
const pkg = await buildSyncPackage(manifest, config);
|
|
168
|
-
expect(pkg.version).toBe(1);
|
|
169
|
-
expect(pkg.files.length).toBe(2);
|
|
170
|
-
|
|
171
|
-
// Verify content is base64 encoded
|
|
172
|
-
const json = JSON.stringify(pkg);
|
|
173
|
-
const parsed = parseSyncPackage(json);
|
|
174
|
-
expect(parsed).not.toBeNull();
|
|
175
|
-
expect(parsed!.files.length).toBe(2);
|
|
176
|
-
|
|
177
|
-
// Decode and verify file content exists (not all files are JSON)
|
|
178
|
-
for (const file of parsed!.files) {
|
|
179
|
-
const decoded = Buffer.from(file.content, 'base64').toString('utf-8');
|
|
180
|
-
expect(decoded.length).toBeGreaterThan(0);
|
|
181
|
-
}
|
|
182
|
-
});
|
|
183
|
-
});
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import { createDefaultConfig, writeConfig, readConfig } from '../../src/config/config-file.js';
|
|
6
|
-
import { buildLocalManifest, computeDiff } from '../../src/core/sync-engine.js';
|
|
7
|
-
import { createManifest, serializeManifest } from '../../src/core/manifest.js';
|
|
8
|
-
import { diffManifests } from '../../src/utils/diff.js';
|
|
9
|
-
import type { AppConfig, Manifest, ManifestEntry } from '../../src/types/index.js';
|
|
10
|
-
|
|
11
|
-
function makeEntry(overrides: Partial<ManifestEntry> & { relativePath: string }): ManifestEntry {
|
|
12
|
-
return {
|
|
13
|
-
checksum: 'abc123',
|
|
14
|
-
size: 100,
|
|
15
|
-
modifiedAt: new Date().toISOString(),
|
|
16
|
-
sourceName: 'test',
|
|
17
|
-
encrypted: false,
|
|
18
|
-
encryptionPattern: null,
|
|
19
|
-
...overrides,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
describe('status flow', () => {
|
|
24
|
-
let tmpDir: string;
|
|
25
|
-
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aitoolsync-status-'));
|
|
28
|
-
process.chdir(tmpDir);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
afterEach(() => {
|
|
32
|
-
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('computes diff: no changes', () => {
|
|
36
|
-
const local = createManifest([
|
|
37
|
-
makeEntry({ relativePath: 'file1.txt', checksum: 'aaa' }),
|
|
38
|
-
makeEntry({ relativePath: 'file2.txt', checksum: 'bbb' }),
|
|
39
|
-
]);
|
|
40
|
-
const remote = createManifest([
|
|
41
|
-
makeEntry({ relativePath: 'file1.txt', checksum: 'aaa' }),
|
|
42
|
-
makeEntry({ relativePath: 'file2.txt', checksum: 'bbb' }),
|
|
43
|
-
]);
|
|
44
|
-
|
|
45
|
-
const diff = diffManifests(local, remote, null);
|
|
46
|
-
expect(diff.hasChanges).toBe(false);
|
|
47
|
-
expect(diff.hasConflicts).toBe(false);
|
|
48
|
-
expect(diff.added).toHaveLength(0);
|
|
49
|
-
expect(diff.removed).toHaveLength(0);
|
|
50
|
-
expect(diff.modified).toHaveLength(0);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('detects added files', () => {
|
|
54
|
-
const local = createManifest([
|
|
55
|
-
makeEntry({ relativePath: 'old.txt', checksum: 'aaa' }),
|
|
56
|
-
makeEntry({ relativePath: 'new.txt', checksum: 'bbb' }),
|
|
57
|
-
]);
|
|
58
|
-
const remote = createManifest([
|
|
59
|
-
makeEntry({ relativePath: 'old.txt', checksum: 'aaa' }),
|
|
60
|
-
]);
|
|
61
|
-
|
|
62
|
-
const diff = diffManifests(local, remote, null);
|
|
63
|
-
expect(diff.hasChanges).toBe(true);
|
|
64
|
-
expect(diff.added).toHaveLength(1);
|
|
65
|
-
expect(diff.added[0].relativePath).toBe('new.txt');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('detects removed files', () => {
|
|
69
|
-
const local = createManifest([
|
|
70
|
-
makeEntry({ relativePath: 'old.txt', checksum: 'aaa' }),
|
|
71
|
-
]);
|
|
72
|
-
const remote = createManifest([
|
|
73
|
-
makeEntry({ relativePath: 'old.txt', checksum: 'aaa' }),
|
|
74
|
-
makeEntry({ relativePath: 'gone.txt', checksum: 'bbb' }),
|
|
75
|
-
]);
|
|
76
|
-
|
|
77
|
-
const diff = diffManifests(local, remote, null);
|
|
78
|
-
expect(diff.removed).toHaveLength(1);
|
|
79
|
-
expect(diff.removed[0].relativePath).toBe('gone.txt');
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('detects modified files', () => {
|
|
83
|
-
const local = createManifest([
|
|
84
|
-
makeEntry({ relativePath: 'file.txt', checksum: 'newhash' }),
|
|
85
|
-
]);
|
|
86
|
-
const remote = createManifest([
|
|
87
|
-
makeEntry({ relativePath: 'file.txt', checksum: 'oldhash' }),
|
|
88
|
-
]);
|
|
89
|
-
const lastKnown = createManifest([
|
|
90
|
-
makeEntry({ relativePath: 'file.txt', checksum: 'oldhash' }),
|
|
91
|
-
]);
|
|
92
|
-
|
|
93
|
-
// Only local changed (checksums differ; lastKnown matches remote)
|
|
94
|
-
const diff = diffManifests(local, remote, lastKnown);
|
|
95
|
-
expect(diff.modified).toHaveLength(1);
|
|
96
|
-
expect(diff.modified[0].relativePath).toBe('file.txt');
|
|
97
|
-
expect(diff.hasConflicts).toBe(false);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('detects conflicts with three-way merge', () => {
|
|
101
|
-
const local = createManifest([
|
|
102
|
-
makeEntry({ relativePath: 'file.txt', checksum: 'localhash' }),
|
|
103
|
-
]);
|
|
104
|
-
const remote = createManifest([
|
|
105
|
-
makeEntry({ relativePath: 'file.txt', checksum: 'remotehash' }),
|
|
106
|
-
]);
|
|
107
|
-
const lastKnown = createManifest([
|
|
108
|
-
makeEntry({ relativePath: 'file.txt', checksum: 'basehash' }),
|
|
109
|
-
]);
|
|
110
|
-
|
|
111
|
-
const diff = diffManifests(local, remote, lastKnown);
|
|
112
|
-
expect(diff.hasConflicts).toBe(true);
|
|
113
|
-
expect(diff.conflicts).toHaveLength(1);
|
|
114
|
-
expect(diff.conflicts[0].relativePath).toBe('file.txt');
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it('collects files from a real directory', () => {
|
|
118
|
-
// Create a test directory structure
|
|
119
|
-
const testDir = path.join(tmpDir, 'test-config');
|
|
120
|
-
fs.mkdirSync(testDir, { recursive: true });
|
|
121
|
-
fs.writeFileSync(path.join(testDir, 'settings.json'), JSON.stringify({ key: 'value' }));
|
|
122
|
-
fs.writeFileSync(path.join(testDir, 'mcp.json'), JSON.stringify({ mcp: true }));
|
|
123
|
-
|
|
124
|
-
const config = createDefaultConfig();
|
|
125
|
-
config.syncPaths = [{ name: 'test', path: testDir, enabled: true }];
|
|
126
|
-
|
|
127
|
-
const manifest = buildLocalManifest(config);
|
|
128
|
-
expect(manifest.entries.length).toBe(2);
|
|
129
|
-
const paths = manifest.entries.map((e) => e.relativePath);
|
|
130
|
-
expect(paths).toContain('settings.json');
|
|
131
|
-
expect(paths).toContain('mcp.json');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('excludes files matching exclusion rules', () => {
|
|
135
|
-
const testDir = path.join(tmpDir, 'excluded-config');
|
|
136
|
-
fs.mkdirSync(path.join(testDir, 'node_modules'), { recursive: true });
|
|
137
|
-
fs.mkdirSync(path.join(testDir, 'src'), { recursive: true });
|
|
138
|
-
fs.writeFileSync(path.join(testDir, 'src', 'main.ts'), 'code');
|
|
139
|
-
fs.writeFileSync(path.join(testDir, 'node_modules', 'package.json'), '{}');
|
|
140
|
-
|
|
141
|
-
const config = createDefaultConfig();
|
|
142
|
-
config.syncPaths = [{ name: 'excl', path: testDir, enabled: true }];
|
|
143
|
-
|
|
144
|
-
const manifest = buildLocalManifest(config);
|
|
145
|
-
const paths = manifest.entries.map((e) => e.relativePath);
|
|
146
|
-
expect(paths).toContain('src/main.ts');
|
|
147
|
-
expect(paths).not.toContain('node_modules/package.json');
|
|
148
|
-
});
|
|
149
|
-
});
|