@gricha/perry 0.2.3 → 0.2.4
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 +3 -0
- package/dist/agent/file-watcher.js +2 -6
- package/dist/agent/router.js +43 -1
- package/dist/agent/run.js +15 -1
- package/dist/agent/web/assets/index-BmFYrCoX.css +1 -0
- package/dist/agent/web/assets/index-IavvQP8G.js +104 -0
- package/dist/agent/web/index.html +2 -2
- package/dist/agents/__tests__/claude-code.test.js +125 -0
- package/dist/agents/__tests__/codex.test.js +64 -0
- package/dist/agents/__tests__/opencode.test.js +130 -0
- package/dist/agents/__tests__/sync.test.js +272 -0
- package/dist/agents/index.js +177 -0
- package/dist/agents/sync/claude-code.js +84 -0
- package/dist/agents/sync/codex.js +29 -0
- package/dist/agents/sync/copier.js +89 -0
- package/dist/agents/sync/opencode.js +51 -0
- package/dist/agents/sync/types.js +1 -0
- package/dist/agents/types.js +1 -0
- package/dist/chat/base-chat-websocket.js +1 -1
- package/dist/chat/opencode-websocket.js +1 -1
- package/dist/chat/websocket.js +2 -2
- package/dist/client/api.js +25 -0
- package/dist/config/loader.js +20 -2
- package/dist/docker/eager-pull.js +19 -3
- package/dist/docker/index.js +27 -0
- package/dist/index.js +83 -12
- package/dist/perry-worker +0 -0
- package/dist/workspace/manager.js +178 -115
- package/package.json +1 -1
- package/dist/agent/web/assets/index-BF-4SpMu.js +0 -104
- package/dist/agent/web/assets/index-DIOWcVH-.css +0 -1
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<title>Perry</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-IavvQP8G.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BmFYrCoX.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { claudeCodeSync } from '../sync/claude-code';
|
|
3
|
+
function createMockContext(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
containerName: 'test-container',
|
|
6
|
+
agentConfig: {
|
|
7
|
+
port: 7777,
|
|
8
|
+
credentials: { env: {}, files: {} },
|
|
9
|
+
scripts: {},
|
|
10
|
+
},
|
|
11
|
+
hostFileExists: async () => false,
|
|
12
|
+
hostDirExists: async () => false,
|
|
13
|
+
readHostFile: async () => null,
|
|
14
|
+
readContainerFile: async () => null,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe('claudeCodeSync', () => {
|
|
19
|
+
describe('getRequiredDirs', () => {
|
|
20
|
+
it('returns .claude directory', () => {
|
|
21
|
+
const dirs = claudeCodeSync.getRequiredDirs();
|
|
22
|
+
expect(dirs).toContain('/home/workspace/.claude');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('getFilesToSync', () => {
|
|
26
|
+
it('returns credentials file', async () => {
|
|
27
|
+
const context = createMockContext();
|
|
28
|
+
const files = await claudeCodeSync.getFilesToSync(context);
|
|
29
|
+
const credFile = files.find((f) => f.source === '~/.claude/.credentials.json');
|
|
30
|
+
expect(credFile).toBeDefined();
|
|
31
|
+
expect(credFile?.category).toBe('credential');
|
|
32
|
+
expect(credFile?.permissions).toBe('600');
|
|
33
|
+
expect(credFile?.optional).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('returns settings file', async () => {
|
|
36
|
+
const context = createMockContext();
|
|
37
|
+
const files = await claudeCodeSync.getFilesToSync(context);
|
|
38
|
+
const settingsFile = files.find((f) => f.source === '~/.claude/settings.json');
|
|
39
|
+
expect(settingsFile).toBeDefined();
|
|
40
|
+
expect(settingsFile?.category).toBe('preference');
|
|
41
|
+
expect(settingsFile?.optional).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
it('returns CLAUDE.md file', async () => {
|
|
44
|
+
const context = createMockContext();
|
|
45
|
+
const files = await claudeCodeSync.getFilesToSync(context);
|
|
46
|
+
const claudeMdFile = files.find((f) => f.source === '~/.claude/CLAUDE.md');
|
|
47
|
+
expect(claudeMdFile).toBeDefined();
|
|
48
|
+
expect(claudeMdFile?.category).toBe('preference');
|
|
49
|
+
expect(claudeMdFile?.optional).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('marks all files as optional', async () => {
|
|
52
|
+
const context = createMockContext();
|
|
53
|
+
const files = await claudeCodeSync.getFilesToSync(context);
|
|
54
|
+
expect(files.every((f) => f.optional === true)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('getDirectoriesToSync', () => {
|
|
58
|
+
it('returns empty when agents dir does not exist', async () => {
|
|
59
|
+
const context = createMockContext({
|
|
60
|
+
hostDirExists: async () => false,
|
|
61
|
+
});
|
|
62
|
+
const dirs = await claudeCodeSync.getDirectoriesToSync(context);
|
|
63
|
+
expect(dirs).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
it('returns agents directory when it exists', async () => {
|
|
66
|
+
const context = createMockContext({
|
|
67
|
+
hostDirExists: async (path) => path === '~/.claude/agents',
|
|
68
|
+
});
|
|
69
|
+
const dirs = await claudeCodeSync.getDirectoriesToSync(context);
|
|
70
|
+
expect(dirs).toHaveLength(1);
|
|
71
|
+
expect(dirs[0].source).toBe('~/.claude/agents');
|
|
72
|
+
expect(dirs[0].dest).toBe('/home/workspace/.claude/agents');
|
|
73
|
+
expect(dirs[0].category).toBe('preference');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('getGeneratedConfigs', () => {
|
|
77
|
+
it('generates .claude.json with onboarding flag', async () => {
|
|
78
|
+
const context = createMockContext();
|
|
79
|
+
const configs = await claudeCodeSync.getGeneratedConfigs(context);
|
|
80
|
+
expect(configs).toHaveLength(1);
|
|
81
|
+
expect(configs[0].dest).toBe('/home/workspace/.claude.json');
|
|
82
|
+
expect(configs[0].category).toBe('preference');
|
|
83
|
+
const parsed = JSON.parse(configs[0].content);
|
|
84
|
+
expect(parsed.hasCompletedOnboarding).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
it('does not include mcpServers when host has none', async () => {
|
|
87
|
+
const context = createMockContext();
|
|
88
|
+
const configs = await claudeCodeSync.getGeneratedConfigs(context);
|
|
89
|
+
const parsed = JSON.parse(configs[0].content);
|
|
90
|
+
expect(parsed.mcpServers).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
it('merges mcpServers from host config', async () => {
|
|
93
|
+
const hostConfig = {
|
|
94
|
+
mcpServers: {
|
|
95
|
+
'my-server': { command: 'node', args: ['server.js'] },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const context = createMockContext({
|
|
99
|
+
readHostFile: async (path) => path === '~/.claude.json' ? JSON.stringify(hostConfig) : null,
|
|
100
|
+
});
|
|
101
|
+
const configs = await claudeCodeSync.getGeneratedConfigs(context);
|
|
102
|
+
const parsed = JSON.parse(configs[0].content);
|
|
103
|
+
expect(parsed.mcpServers).toEqual(hostConfig.mcpServers);
|
|
104
|
+
expect(parsed.hasCompletedOnboarding).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
it('handles invalid JSON in host config gracefully', async () => {
|
|
107
|
+
const context = createMockContext({
|
|
108
|
+
readHostFile: async (path) => (path === '~/.claude.json' ? 'invalid json{' : null),
|
|
109
|
+
});
|
|
110
|
+
const configs = await claudeCodeSync.getGeneratedConfigs(context);
|
|
111
|
+
const parsed = JSON.parse(configs[0].content);
|
|
112
|
+
expect(parsed.hasCompletedOnboarding).toBe(true);
|
|
113
|
+
expect(parsed.mcpServers).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
it('handles empty mcpServers object', async () => {
|
|
116
|
+
const hostConfig = { mcpServers: {} };
|
|
117
|
+
const context = createMockContext({
|
|
118
|
+
readHostFile: async (path) => path === '~/.claude.json' ? JSON.stringify(hostConfig) : null,
|
|
119
|
+
});
|
|
120
|
+
const configs = await claudeCodeSync.getGeneratedConfigs(context);
|
|
121
|
+
const parsed = JSON.parse(configs[0].content);
|
|
122
|
+
expect(parsed.mcpServers).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { codexSync } from '../sync/codex';
|
|
3
|
+
function createMockContext(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
containerName: 'test-container',
|
|
6
|
+
agentConfig: {
|
|
7
|
+
port: 7777,
|
|
8
|
+
credentials: { env: {}, files: {} },
|
|
9
|
+
scripts: {},
|
|
10
|
+
},
|
|
11
|
+
hostFileExists: async () => false,
|
|
12
|
+
hostDirExists: async () => false,
|
|
13
|
+
readHostFile: async () => null,
|
|
14
|
+
readContainerFile: async () => null,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe('codexSync', () => {
|
|
19
|
+
describe('getRequiredDirs', () => {
|
|
20
|
+
it('returns .codex directory', () => {
|
|
21
|
+
const dirs = codexSync.getRequiredDirs();
|
|
22
|
+
expect(dirs).toContain('/home/workspace/.codex');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('getFilesToSync', () => {
|
|
26
|
+
it('returns auth.json as credential', async () => {
|
|
27
|
+
const context = createMockContext();
|
|
28
|
+
const files = await codexSync.getFilesToSync(context);
|
|
29
|
+
const authFile = files.find((f) => f.source === '~/.codex/auth.json');
|
|
30
|
+
expect(authFile).toBeDefined();
|
|
31
|
+
expect(authFile?.category).toBe('credential');
|
|
32
|
+
expect(authFile?.permissions).toBe('600');
|
|
33
|
+
expect(authFile?.optional).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
it('returns config.toml as preference', async () => {
|
|
36
|
+
const context = createMockContext();
|
|
37
|
+
const files = await codexSync.getFilesToSync(context);
|
|
38
|
+
const configFile = files.find((f) => f.source === '~/.codex/config.toml');
|
|
39
|
+
expect(configFile).toBeDefined();
|
|
40
|
+
expect(configFile?.category).toBe('preference');
|
|
41
|
+
expect(configFile?.permissions).toBe('600');
|
|
42
|
+
expect(configFile?.optional).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
it('marks all files as optional', async () => {
|
|
45
|
+
const context = createMockContext();
|
|
46
|
+
const files = await codexSync.getFilesToSync(context);
|
|
47
|
+
expect(files.every((f) => f.optional === true)).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('getDirectoriesToSync', () => {
|
|
51
|
+
it('returns empty array (no directories to sync)', async () => {
|
|
52
|
+
const context = createMockContext();
|
|
53
|
+
const dirs = await codexSync.getDirectoriesToSync(context);
|
|
54
|
+
expect(dirs).toHaveLength(0);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('getGeneratedConfigs', () => {
|
|
58
|
+
it('returns empty array (no generated configs)', async () => {
|
|
59
|
+
const context = createMockContext();
|
|
60
|
+
const configs = await codexSync.getGeneratedConfigs(context);
|
|
61
|
+
expect(configs).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { opencodeSync } from '../sync/opencode';
|
|
3
|
+
function createMockContext(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
containerName: 'test-container',
|
|
6
|
+
agentConfig: {
|
|
7
|
+
port: 7777,
|
|
8
|
+
credentials: { env: {}, files: {} },
|
|
9
|
+
scripts: {},
|
|
10
|
+
},
|
|
11
|
+
hostFileExists: async () => false,
|
|
12
|
+
hostDirExists: async () => false,
|
|
13
|
+
readHostFile: async () => null,
|
|
14
|
+
readContainerFile: async () => null,
|
|
15
|
+
...overrides,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe('opencodeSync', () => {
|
|
19
|
+
describe('getRequiredDirs', () => {
|
|
20
|
+
it('returns opencode config directory', () => {
|
|
21
|
+
const dirs = opencodeSync.getRequiredDirs();
|
|
22
|
+
expect(dirs).toContain('/home/workspace/.config/opencode');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('getFilesToSync', () => {
|
|
26
|
+
it('returns empty array (no files to copy)', async () => {
|
|
27
|
+
const context = createMockContext();
|
|
28
|
+
const files = await opencodeSync.getFilesToSync(context);
|
|
29
|
+
expect(files).toHaveLength(0);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('getDirectoriesToSync', () => {
|
|
33
|
+
it('returns empty array (no directories to copy)', async () => {
|
|
34
|
+
const context = createMockContext();
|
|
35
|
+
const dirs = await opencodeSync.getDirectoriesToSync(context);
|
|
36
|
+
expect(dirs).toHaveLength(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('getGeneratedConfigs', () => {
|
|
40
|
+
it('returns empty when no zen_token configured', async () => {
|
|
41
|
+
const context = createMockContext();
|
|
42
|
+
const configs = await opencodeSync.getGeneratedConfigs(context);
|
|
43
|
+
expect(configs).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
it('generates config with provider when zen_token is set', async () => {
|
|
46
|
+
const context = createMockContext({
|
|
47
|
+
agentConfig: {
|
|
48
|
+
port: 7777,
|
|
49
|
+
credentials: { env: {}, files: {} },
|
|
50
|
+
scripts: {},
|
|
51
|
+
agents: {
|
|
52
|
+
opencode: {
|
|
53
|
+
zen_token: 'test-token-123',
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
const configs = await opencodeSync.getGeneratedConfigs(context);
|
|
59
|
+
expect(configs).toHaveLength(1);
|
|
60
|
+
expect(configs[0].dest).toBe('/home/workspace/.config/opencode/opencode.json');
|
|
61
|
+
expect(configs[0].category).toBe('credential');
|
|
62
|
+
expect(configs[0].permissions).toBe('600');
|
|
63
|
+
const parsed = JSON.parse(configs[0].content);
|
|
64
|
+
expect(parsed.provider.opencode.options.apiKey).toBe('test-token-123');
|
|
65
|
+
expect(parsed.model).toBe('opencode/claude-sonnet-4');
|
|
66
|
+
});
|
|
67
|
+
it('does not include mcp when host has none', async () => {
|
|
68
|
+
const context = createMockContext({
|
|
69
|
+
agentConfig: {
|
|
70
|
+
port: 7777,
|
|
71
|
+
credentials: { env: {}, files: {} },
|
|
72
|
+
scripts: {},
|
|
73
|
+
agents: { opencode: { zen_token: 'test-token' } },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const configs = await opencodeSync.getGeneratedConfigs(context);
|
|
77
|
+
const parsed = JSON.parse(configs[0].content);
|
|
78
|
+
expect(parsed.mcp).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
it('merges mcp config from host', async () => {
|
|
81
|
+
const hostConfig = {
|
|
82
|
+
mcp: {
|
|
83
|
+
'my-mcp': { type: 'local', command: ['bun', 'run', 'server'] },
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
const context = createMockContext({
|
|
87
|
+
agentConfig: {
|
|
88
|
+
port: 7777,
|
|
89
|
+
credentials: { env: {}, files: {} },
|
|
90
|
+
scripts: {},
|
|
91
|
+
agents: { opencode: { zen_token: 'test-token' } },
|
|
92
|
+
},
|
|
93
|
+
readHostFile: async (path) => path === '~/.config/opencode/opencode.json' ? JSON.stringify(hostConfig) : null,
|
|
94
|
+
});
|
|
95
|
+
const configs = await opencodeSync.getGeneratedConfigs(context);
|
|
96
|
+
const parsed = JSON.parse(configs[0].content);
|
|
97
|
+
expect(parsed.mcp).toEqual(hostConfig.mcp);
|
|
98
|
+
});
|
|
99
|
+
it('handles invalid JSON in host config gracefully', async () => {
|
|
100
|
+
const context = createMockContext({
|
|
101
|
+
agentConfig: {
|
|
102
|
+
port: 7777,
|
|
103
|
+
credentials: { env: {}, files: {} },
|
|
104
|
+
scripts: {},
|
|
105
|
+
agents: { opencode: { zen_token: 'test-token' } },
|
|
106
|
+
},
|
|
107
|
+
readHostFile: async (path) => path === '~/.config/opencode/opencode.json' ? 'not valid json' : null,
|
|
108
|
+
});
|
|
109
|
+
const configs = await opencodeSync.getGeneratedConfigs(context);
|
|
110
|
+
const parsed = JSON.parse(configs[0].content);
|
|
111
|
+
expect(parsed.provider.opencode.options.apiKey).toBe('test-token');
|
|
112
|
+
expect(parsed.mcp).toBeUndefined();
|
|
113
|
+
});
|
|
114
|
+
it('handles empty mcp object', async () => {
|
|
115
|
+
const hostConfig = { mcp: {} };
|
|
116
|
+
const context = createMockContext({
|
|
117
|
+
agentConfig: {
|
|
118
|
+
port: 7777,
|
|
119
|
+
credentials: { env: {}, files: {} },
|
|
120
|
+
scripts: {},
|
|
121
|
+
agents: { opencode: { zen_token: 'test-token' } },
|
|
122
|
+
},
|
|
123
|
+
readHostFile: async (path) => path === '~/.config/opencode/opencode.json' ? JSON.stringify(hostConfig) : null,
|
|
124
|
+
});
|
|
125
|
+
const configs = await opencodeSync.getGeneratedConfigs(context);
|
|
126
|
+
const parsed = JSON.parse(configs[0].content);
|
|
127
|
+
expect(parsed.mcp).toBeUndefined();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { syncAgent, syncAllAgents, getCredentialFilePaths, createSyncContext } from '../index';
|
|
3
|
+
import { createMockFileCopier } from '../sync/copier';
|
|
4
|
+
function createMockContext(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
containerName: 'test-container',
|
|
7
|
+
agentConfig: {
|
|
8
|
+
port: 7777,
|
|
9
|
+
credentials: { env: {}, files: {} },
|
|
10
|
+
scripts: {},
|
|
11
|
+
},
|
|
12
|
+
hostFileExists: async () => false,
|
|
13
|
+
hostDirExists: async () => false,
|
|
14
|
+
readHostFile: async () => null,
|
|
15
|
+
readContainerFile: async () => null,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe('syncAgent', () => {
|
|
20
|
+
it('creates required directories', async () => {
|
|
21
|
+
const provider = {
|
|
22
|
+
getRequiredDirs: () => ['/home/workspace/.test'],
|
|
23
|
+
getFilesToSync: async () => [],
|
|
24
|
+
getDirectoriesToSync: async () => [],
|
|
25
|
+
getGeneratedConfigs: async () => [],
|
|
26
|
+
};
|
|
27
|
+
const context = createMockContext();
|
|
28
|
+
const copier = createMockFileCopier();
|
|
29
|
+
await syncAgent(provider, context, copier);
|
|
30
|
+
expect(copier.calls).toContainEqual({
|
|
31
|
+
method: 'ensureDir',
|
|
32
|
+
args: ['test-container', '/home/workspace/.test'],
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
it('copies files that exist', async () => {
|
|
36
|
+
const provider = {
|
|
37
|
+
getRequiredDirs: () => [],
|
|
38
|
+
getFilesToSync: async () => [
|
|
39
|
+
{
|
|
40
|
+
source: '~/.test/file.json',
|
|
41
|
+
dest: '/home/workspace/.test/file.json',
|
|
42
|
+
category: 'credential',
|
|
43
|
+
permissions: '600',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
getDirectoriesToSync: async () => [],
|
|
47
|
+
getGeneratedConfigs: async () => [],
|
|
48
|
+
};
|
|
49
|
+
const context = createMockContext({
|
|
50
|
+
hostFileExists: async (path) => path === '~/.test/file.json',
|
|
51
|
+
});
|
|
52
|
+
const copier = createMockFileCopier();
|
|
53
|
+
const result = await syncAgent(provider, context, copier);
|
|
54
|
+
expect(copier.calls).toContainEqual({
|
|
55
|
+
method: 'copyFile',
|
|
56
|
+
args: [
|
|
57
|
+
'test-container',
|
|
58
|
+
{
|
|
59
|
+
source: '~/.test/file.json',
|
|
60
|
+
dest: '/home/workspace/.test/file.json',
|
|
61
|
+
category: 'credential',
|
|
62
|
+
permissions: '600',
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
expect(result.copied).toContain('~/.test/file.json');
|
|
67
|
+
});
|
|
68
|
+
it('skips optional files that do not exist', async () => {
|
|
69
|
+
const provider = {
|
|
70
|
+
getRequiredDirs: () => [],
|
|
71
|
+
getFilesToSync: async () => [
|
|
72
|
+
{
|
|
73
|
+
source: '~/.test/optional.json',
|
|
74
|
+
dest: '/home/workspace/.test/optional.json',
|
|
75
|
+
category: 'preference',
|
|
76
|
+
optional: true,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
getDirectoriesToSync: async () => [],
|
|
80
|
+
getGeneratedConfigs: async () => [],
|
|
81
|
+
};
|
|
82
|
+
const context = createMockContext({
|
|
83
|
+
hostFileExists: async () => false,
|
|
84
|
+
});
|
|
85
|
+
const copier = createMockFileCopier();
|
|
86
|
+
const result = await syncAgent(provider, context, copier);
|
|
87
|
+
expect(result.skipped).toContain('~/.test/optional.json');
|
|
88
|
+
expect(result.errors).toHaveLength(0);
|
|
89
|
+
expect(copier.calls.filter((c) => c.method === 'copyFile')).toHaveLength(0);
|
|
90
|
+
});
|
|
91
|
+
it('reports errors for missing required files', async () => {
|
|
92
|
+
const provider = {
|
|
93
|
+
getRequiredDirs: () => [],
|
|
94
|
+
getFilesToSync: async () => [
|
|
95
|
+
{
|
|
96
|
+
source: '~/.test/required.json',
|
|
97
|
+
dest: '/home/workspace/.test/required.json',
|
|
98
|
+
category: 'credential',
|
|
99
|
+
optional: false,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
getDirectoriesToSync: async () => [],
|
|
103
|
+
getGeneratedConfigs: async () => [],
|
|
104
|
+
};
|
|
105
|
+
const context = createMockContext({
|
|
106
|
+
hostFileExists: async () => false,
|
|
107
|
+
});
|
|
108
|
+
const copier = createMockFileCopier();
|
|
109
|
+
const result = await syncAgent(provider, context, copier);
|
|
110
|
+
expect(result.errors).toContainEqual({
|
|
111
|
+
path: '~/.test/required.json',
|
|
112
|
+
error: 'File not found',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
it('copies directories that exist', async () => {
|
|
116
|
+
const provider = {
|
|
117
|
+
getRequiredDirs: () => [],
|
|
118
|
+
getFilesToSync: async () => [],
|
|
119
|
+
getDirectoriesToSync: async () => [
|
|
120
|
+
{
|
|
121
|
+
source: '~/.test/dir',
|
|
122
|
+
dest: '/home/workspace/.test/dir',
|
|
123
|
+
category: 'preference',
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
getGeneratedConfigs: async () => [],
|
|
127
|
+
};
|
|
128
|
+
const context = createMockContext({
|
|
129
|
+
hostDirExists: async (path) => path === '~/.test/dir',
|
|
130
|
+
});
|
|
131
|
+
const copier = createMockFileCopier();
|
|
132
|
+
const result = await syncAgent(provider, context, copier);
|
|
133
|
+
expect(copier.calls).toContainEqual({
|
|
134
|
+
method: 'copyDirectory',
|
|
135
|
+
args: [
|
|
136
|
+
'test-container',
|
|
137
|
+
{
|
|
138
|
+
source: '~/.test/dir',
|
|
139
|
+
dest: '/home/workspace/.test/dir',
|
|
140
|
+
category: 'preference',
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
expect(result.copied).toContain('~/.test/dir');
|
|
145
|
+
});
|
|
146
|
+
it('generates configs', async () => {
|
|
147
|
+
const provider = {
|
|
148
|
+
getRequiredDirs: () => [],
|
|
149
|
+
getFilesToSync: async () => [],
|
|
150
|
+
getDirectoriesToSync: async () => [],
|
|
151
|
+
getGeneratedConfigs: async () => [
|
|
152
|
+
{
|
|
153
|
+
dest: '/home/workspace/.test/config.json',
|
|
154
|
+
content: '{"key": "value"}',
|
|
155
|
+
permissions: '644',
|
|
156
|
+
category: 'preference',
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
const context = createMockContext();
|
|
161
|
+
const copier = createMockFileCopier();
|
|
162
|
+
const result = await syncAgent(provider, context, copier);
|
|
163
|
+
expect(copier.calls).toContainEqual({
|
|
164
|
+
method: 'writeConfig',
|
|
165
|
+
args: [
|
|
166
|
+
'test-container',
|
|
167
|
+
{
|
|
168
|
+
dest: '/home/workspace/.test/config.json',
|
|
169
|
+
content: '{"key": "value"}',
|
|
170
|
+
permissions: '644',
|
|
171
|
+
category: 'preference',
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
});
|
|
175
|
+
expect(result.generated).toContain('/home/workspace/.test/config.json');
|
|
176
|
+
});
|
|
177
|
+
it('tracks all results correctly', async () => {
|
|
178
|
+
const provider = {
|
|
179
|
+
getRequiredDirs: () => ['/home/workspace/.test'],
|
|
180
|
+
getFilesToSync: async () => [
|
|
181
|
+
{
|
|
182
|
+
source: '~/.test/exists.json',
|
|
183
|
+
dest: '/home/workspace/.test/exists.json',
|
|
184
|
+
category: 'credential',
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
source: '~/.test/optional.json',
|
|
188
|
+
dest: '/home/workspace/.test/optional.json',
|
|
189
|
+
category: 'preference',
|
|
190
|
+
optional: true,
|
|
191
|
+
},
|
|
192
|
+
],
|
|
193
|
+
getDirectoriesToSync: async () => [],
|
|
194
|
+
getGeneratedConfigs: async () => [
|
|
195
|
+
{
|
|
196
|
+
dest: '/home/workspace/.test/generated.json',
|
|
197
|
+
content: '{}',
|
|
198
|
+
category: 'preference',
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
const context = createMockContext({
|
|
203
|
+
hostFileExists: async (path) => path === '~/.test/exists.json',
|
|
204
|
+
});
|
|
205
|
+
const copier = createMockFileCopier();
|
|
206
|
+
const result = await syncAgent(provider, context, copier);
|
|
207
|
+
expect(result.copied).toEqual(['~/.test/exists.json']);
|
|
208
|
+
expect(result.skipped).toEqual(['~/.test/optional.json']);
|
|
209
|
+
expect(result.generated).toEqual(['/home/workspace/.test/generated.json']);
|
|
210
|
+
expect(result.errors).toHaveLength(0);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
describe('syncAllAgents', () => {
|
|
214
|
+
it('syncs all three agents', async () => {
|
|
215
|
+
const copier = createMockFileCopier();
|
|
216
|
+
const results = await syncAllAgents('test-container', {
|
|
217
|
+
port: 7777,
|
|
218
|
+
credentials: { env: {}, files: {} },
|
|
219
|
+
scripts: {},
|
|
220
|
+
}, copier);
|
|
221
|
+
expect(results['claude-code']).toBeDefined();
|
|
222
|
+
expect(results['opencode']).toBeDefined();
|
|
223
|
+
expect(results['codex']).toBeDefined();
|
|
224
|
+
});
|
|
225
|
+
it('returns results per agent', async () => {
|
|
226
|
+
const copier = createMockFileCopier();
|
|
227
|
+
const results = await syncAllAgents('test-container', {
|
|
228
|
+
port: 7777,
|
|
229
|
+
credentials: { env: {}, files: {} },
|
|
230
|
+
scripts: {},
|
|
231
|
+
}, copier);
|
|
232
|
+
for (const agentType of ['claude-code', 'opencode', 'codex']) {
|
|
233
|
+
const result = results[agentType];
|
|
234
|
+
expect(result).toHaveProperty('copied');
|
|
235
|
+
expect(result).toHaveProperty('generated');
|
|
236
|
+
expect(result).toHaveProperty('skipped');
|
|
237
|
+
expect(result).toHaveProperty('errors');
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
describe('getCredentialFilePaths', () => {
|
|
242
|
+
it('returns credential files from providers', () => {
|
|
243
|
+
const paths = getCredentialFilePaths();
|
|
244
|
+
expect(paths).toContain('~/.claude/.credentials.json');
|
|
245
|
+
expect(paths).toContain('~/.codex/auth.json');
|
|
246
|
+
});
|
|
247
|
+
it('does not include preference-only files', () => {
|
|
248
|
+
const paths = getCredentialFilePaths();
|
|
249
|
+
expect(paths).not.toContain('~/.claude/settings.json');
|
|
250
|
+
expect(paths).not.toContain('~/.codex/config.toml');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe('createSyncContext', () => {
|
|
254
|
+
it('creates context with correct container name', () => {
|
|
255
|
+
const context = createSyncContext('my-container', {
|
|
256
|
+
port: 7777,
|
|
257
|
+
credentials: { env: {}, files: {} },
|
|
258
|
+
scripts: {},
|
|
259
|
+
});
|
|
260
|
+
expect(context.containerName).toBe('my-container');
|
|
261
|
+
});
|
|
262
|
+
it('creates context with agent config', () => {
|
|
263
|
+
const config = {
|
|
264
|
+
port: 7777,
|
|
265
|
+
credentials: { env: {}, files: {} },
|
|
266
|
+
scripts: {},
|
|
267
|
+
agents: { opencode: { zen_token: 'test' } },
|
|
268
|
+
};
|
|
269
|
+
const context = createSyncContext('my-container', config);
|
|
270
|
+
expect(context.agentConfig).toBe(config);
|
|
271
|
+
});
|
|
272
|
+
});
|