ultraclaude-agent 0.0.9 → 0.0.12
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/__tests__/config-windows.test.ts +44 -21
- package/__tests__/config.test.ts +369 -0
- package/__tests__/daemon.test.ts +70 -8
- package/__tests__/service-windows.test.ts +13 -6
- package/__tests__/sync-bugs.test.ts +2 -5
- package/__tests__/sync.test.ts +2 -5
- package/__tests__/usage-sync.test.ts +1 -4
- package/__tests__/version-check.test.ts +4 -2
- package/dist/cli.js +33 -9
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +44 -8
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +201 -29
- package/dist/config.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +34 -15
- package/dist/daemon.js.map +1 -1
- package/dist/service.d.ts +1 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +14 -12
- package/dist/service.js.map +1 -1
- package/dist/sync.d.ts +1 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +4 -0
- package/dist/sync.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +40 -8
- package/src/config.ts +202 -29
- package/src/daemon.ts +39 -14
- package/src/service.ts +14 -12
- package/src/sync.ts +6 -1
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
|
|
3
3
|
// We need to test the platform-conditional path logic in config.ts.
|
|
4
|
-
// Since
|
|
5
|
-
// and mock os.platform() before each import.
|
|
4
|
+
// Since resolveConfigDir now takes a serverUrl parameter, we test it directly.
|
|
6
5
|
|
|
7
6
|
const mockPlatform = vi.fn();
|
|
8
7
|
vi.mock('node:os', () => ({
|
|
@@ -17,23 +16,29 @@ vi.mock('node:fs/promises', () => ({
|
|
|
17
16
|
unlink: vi.fn().mockResolvedValue(undefined),
|
|
18
17
|
access: vi.fn().mockRejectedValue(new Error('ENOENT')),
|
|
19
18
|
readdir: vi.fn().mockResolvedValue([]),
|
|
19
|
+
rename: vi.fn().mockResolvedValue(undefined),
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
|
+
const TEST_SERVER_URL = 'https://dashboard.ultra-claude.dev';
|
|
23
|
+
const TEST_SERVER_KEY = 'dashboard.ultra-claude.dev';
|
|
24
|
+
|
|
22
25
|
describe('config — path resolution', () => {
|
|
23
26
|
beforeEach(() => {
|
|
24
27
|
vi.clearAllMocks();
|
|
25
28
|
});
|
|
26
29
|
|
|
27
|
-
it('uses APPDATA path on Windows when APPDATA env is set', async () => {
|
|
30
|
+
it('uses APPDATA path on Windows with server-specific subdir when APPDATA env is set', async () => {
|
|
28
31
|
mockPlatform.mockReturnValue('win32');
|
|
29
32
|
const originalAppData = process.env.APPDATA;
|
|
30
33
|
process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming';
|
|
31
34
|
|
|
32
35
|
vi.resetModules();
|
|
33
|
-
const {
|
|
36
|
+
const { resolveConfigDir } = await import('../src/config.js');
|
|
37
|
+
const result = resolveConfigDir(TEST_SERVER_URL);
|
|
34
38
|
|
|
35
|
-
expect(
|
|
36
|
-
expect(
|
|
39
|
+
expect(result).toContain('ultraclaude-agent');
|
|
40
|
+
expect(result).toContain(TEST_SERVER_KEY);
|
|
41
|
+
expect(result).toMatch(/AppData[/\\]Roaming[/\\]ultraclaude-agent/);
|
|
37
42
|
|
|
38
43
|
// Restore
|
|
39
44
|
if (originalAppData === undefined) {
|
|
@@ -49,12 +54,14 @@ describe('config — path resolution', () => {
|
|
|
49
54
|
delete process.env.APPDATA;
|
|
50
55
|
|
|
51
56
|
vi.resetModules();
|
|
52
|
-
const {
|
|
57
|
+
const { resolveConfigDir } = await import('../src/config.js');
|
|
58
|
+
const result = resolveConfigDir(TEST_SERVER_URL);
|
|
53
59
|
|
|
54
|
-
expect(
|
|
60
|
+
expect(result).toContain('ultraclaude-agent');
|
|
61
|
+
expect(result).toContain(TEST_SERVER_KEY);
|
|
55
62
|
// Falls back to homedir/AppData/Roaming
|
|
56
|
-
expect(
|
|
57
|
-
expect(
|
|
63
|
+
expect(result).toMatch(/testuser/);
|
|
64
|
+
expect(result).toMatch(/AppData/);
|
|
58
65
|
|
|
59
66
|
// Restore
|
|
60
67
|
if (originalAppData !== undefined) {
|
|
@@ -62,32 +69,48 @@ describe('config — path resolution', () => {
|
|
|
62
69
|
}
|
|
63
70
|
});
|
|
64
71
|
|
|
65
|
-
it('uses ~/.claude/ultra/
|
|
72
|
+
it('uses ~/.claude/ultra/agent/{server-key}/ path on Linux', async () => {
|
|
66
73
|
mockPlatform.mockReturnValue('linux');
|
|
67
74
|
|
|
68
75
|
vi.resetModules();
|
|
69
|
-
const {
|
|
76
|
+
const { resolveConfigDir } = await import('../src/config.js');
|
|
70
77
|
|
|
71
|
-
expect(
|
|
78
|
+
expect(resolveConfigDir(TEST_SERVER_URL)).toBe(
|
|
79
|
+
`/home/testuser/.claude/ultra/agent/${TEST_SERVER_KEY}`,
|
|
80
|
+
);
|
|
72
81
|
});
|
|
73
82
|
|
|
74
|
-
it('uses ~/.claude/ultra/
|
|
83
|
+
it('uses ~/.claude/ultra/agent/{server-key}/ path on macOS', async () => {
|
|
75
84
|
mockPlatform.mockReturnValue('darwin');
|
|
76
85
|
|
|
77
86
|
vi.resetModules();
|
|
78
|
-
const {
|
|
87
|
+
const { resolveConfigDir } = await import('../src/config.js');
|
|
88
|
+
|
|
89
|
+
expect(resolveConfigDir(TEST_SERVER_URL)).toBe(
|
|
90
|
+
`/home/testuser/.claude/ultra/agent/${TEST_SERVER_KEY}`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('includes port in server key for non-standard port', async () => {
|
|
95
|
+
mockPlatform.mockReturnValue('linux');
|
|
96
|
+
|
|
97
|
+
vi.resetModules();
|
|
98
|
+
const { resolveConfigDir } = await import('../src/config.js');
|
|
79
99
|
|
|
80
|
-
expect(
|
|
100
|
+
expect(resolveConfigDir('http://localhost:3000')).toBe(
|
|
101
|
+
'/home/testuser/.claude/ultra/agent/localhost-3000',
|
|
102
|
+
);
|
|
81
103
|
});
|
|
82
104
|
|
|
83
|
-
it('derives credentials, pid, and logDir from configDir', async () => {
|
|
105
|
+
it('derives credentials, pid, and logDir from server-specific configDir', async () => {
|
|
84
106
|
mockPlatform.mockReturnValue('linux');
|
|
85
107
|
|
|
86
108
|
vi.resetModules();
|
|
87
|
-
const {
|
|
109
|
+
const { resolveServerPaths } = await import('../src/config.js');
|
|
110
|
+
const sp = resolveServerPaths(TEST_SERVER_URL);
|
|
88
111
|
|
|
89
|
-
expect(
|
|
90
|
-
expect(
|
|
91
|
-
expect(
|
|
112
|
+
expect(sp.credentials).toBe(`/home/testuser/.claude/ultra/agent/${TEST_SERVER_KEY}/credentials.json`);
|
|
113
|
+
expect(sp.pid).toBe(`/home/testuser/.claude/ultra/agent/${TEST_SERVER_KEY}/daemon.pid`);
|
|
114
|
+
expect(sp.logDir).toBe(`/home/testuser/.claude/ultra/agent/${TEST_SERVER_KEY}/logs`);
|
|
92
115
|
});
|
|
93
116
|
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
// These tests exercise the real config.ts functions against the real filesystem
|
|
7
|
+
// (using temp dirs), no mocking of fs.
|
|
8
|
+
|
|
9
|
+
// Mock node:os for homedir/platform control in path resolution tests
|
|
10
|
+
const mockPlatform = vi.fn().mockReturnValue('linux');
|
|
11
|
+
const mockHomedir = vi.fn().mockReturnValue('/home/testuser');
|
|
12
|
+
|
|
13
|
+
vi.mock('node:os', async (importOriginal) => {
|
|
14
|
+
const actual = await importOriginal<typeof import('node:os')>();
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
homedir: () => mockHomedir(),
|
|
18
|
+
platform: () => mockPlatform(),
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('config — serverUrlToKey', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.resetModules();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('strips protocol and returns hostname for standard HTTPS port', async () => {
|
|
28
|
+
const { serverUrlToKey } = await import('../src/config.js');
|
|
29
|
+
expect(serverUrlToKey('https://dashboard.ultra-claude.dev')).toBe('dashboard.ultra-claude.dev');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('strips protocol and returns hostname for standard HTTP port', async () => {
|
|
33
|
+
const { serverUrlToKey } = await import('../src/config.js');
|
|
34
|
+
expect(serverUrlToKey('http://example.com')).toBe('example.com');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('includes non-standard port with dash separator', async () => {
|
|
38
|
+
const { serverUrlToKey } = await import('../src/config.js');
|
|
39
|
+
expect(serverUrlToKey('http://localhost:3000')).toBe('localhost-3000');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('handles HTTPS with explicit non-standard port', async () => {
|
|
43
|
+
const { serverUrlToKey } = await import('../src/config.js');
|
|
44
|
+
expect(serverUrlToKey('https://10.0.0.1:8080')).toBe('10.0.0.1-8080');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('strips trailing slash', async () => {
|
|
48
|
+
const { serverUrlToKey } = await import('../src/config.js');
|
|
49
|
+
expect(serverUrlToKey('https://dashboard.ultra-claude.dev/')).toBe('dashboard.ultra-claude.dev');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('ignores explicit standard port (443 for HTTPS)', async () => {
|
|
53
|
+
const { serverUrlToKey } = await import('../src/config.js');
|
|
54
|
+
// URL class strips default ports automatically
|
|
55
|
+
expect(serverUrlToKey('https://dashboard.ultra-claude.dev:443')).toBe('dashboard.ultra-claude.dev');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('ignores explicit standard port (80 for HTTP)', async () => {
|
|
59
|
+
const { serverUrlToKey } = await import('../src/config.js');
|
|
60
|
+
expect(serverUrlToKey('http://example.com:80')).toBe('example.com');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('config — resolveConfigDir', () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
vi.resetModules();
|
|
67
|
+
vi.clearAllMocks();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('resolves to ~/.claude/ultra/agent/{server-key}/ on Linux', async () => {
|
|
71
|
+
mockPlatform.mockReturnValue('linux');
|
|
72
|
+
mockHomedir.mockReturnValue('/home/testuser');
|
|
73
|
+
|
|
74
|
+
const { resolveConfigDir } = await import('../src/config.js');
|
|
75
|
+
expect(resolveConfigDir('https://dashboard.ultra-claude.dev')).toBe(
|
|
76
|
+
'/home/testuser/.claude/ultra/agent/dashboard.ultra-claude.dev',
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('resolves with port for non-standard port', async () => {
|
|
81
|
+
mockPlatform.mockReturnValue('linux');
|
|
82
|
+
mockHomedir.mockReturnValue('/home/testuser');
|
|
83
|
+
|
|
84
|
+
const { resolveConfigDir } = await import('../src/config.js');
|
|
85
|
+
expect(resolveConfigDir('http://localhost:3000')).toBe(
|
|
86
|
+
'/home/testuser/.claude/ultra/agent/localhost-3000',
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('resolves to APPDATA path on Windows', async () => {
|
|
91
|
+
mockPlatform.mockReturnValue('win32');
|
|
92
|
+
const originalAppData = process.env.APPDATA;
|
|
93
|
+
process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming';
|
|
94
|
+
|
|
95
|
+
const { resolveConfigDir } = await import('../src/config.js');
|
|
96
|
+
const result = resolveConfigDir('https://dashboard.ultra-claude.dev');
|
|
97
|
+
expect(result).toContain('ultraclaude-agent');
|
|
98
|
+
expect(result).toContain('dashboard.ultra-claude.dev');
|
|
99
|
+
|
|
100
|
+
if (originalAppData === undefined) {
|
|
101
|
+
delete process.env.APPDATA;
|
|
102
|
+
} else {
|
|
103
|
+
process.env.APPDATA = originalAppData;
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('config — resolveServerPaths', () => {
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
vi.resetModules();
|
|
111
|
+
vi.clearAllMocks();
|
|
112
|
+
mockPlatform.mockReturnValue('linux');
|
|
113
|
+
mockHomedir.mockReturnValue('/home/testuser');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('derives credentials, pid, and logDir from server-specific configDir', async () => {
|
|
117
|
+
const { resolveServerPaths } = await import('../src/config.js');
|
|
118
|
+
const sp = resolveServerPaths('https://dashboard.ultra-claude.dev');
|
|
119
|
+
|
|
120
|
+
expect(sp.configDir).toBe('/home/testuser/.claude/ultra/agent/dashboard.ultra-claude.dev');
|
|
121
|
+
expect(sp.credentials).toBe('/home/testuser/.claude/ultra/agent/dashboard.ultra-claude.dev/credentials.json');
|
|
122
|
+
expect(sp.pid).toBe('/home/testuser/.claude/ultra/agent/dashboard.ultra-claude.dev/daemon.pid');
|
|
123
|
+
expect(sp.logDir).toBe('/home/testuser/.claude/ultra/agent/dashboard.ultra-claude.dev/logs');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('config — getProjectId (server-keyed)', () => {
|
|
128
|
+
let tempDir: string;
|
|
129
|
+
|
|
130
|
+
beforeEach(async () => {
|
|
131
|
+
vi.resetModules();
|
|
132
|
+
tempDir = await mkdtemp(join(tmpdir(), 'config-test-'));
|
|
133
|
+
await mkdir(join(tempDir, '.claude', 'ultra'), { recursive: true });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
afterEach(async () => {
|
|
137
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// --- REPRODUCTION TEST ---
|
|
141
|
+
// This is the core bug: a bare UUID in project-id should NOT be returned
|
|
142
|
+
// when querying for a specific server.
|
|
143
|
+
it('REPRODUCTION: bare UUID in project-id returns null (not the wrong servers ID)', async () => {
|
|
144
|
+
const { getProjectId } = await import('../src/config.js');
|
|
145
|
+
|
|
146
|
+
// Simulate old format: bare UUID written by previous agent version
|
|
147
|
+
await writeFile(
|
|
148
|
+
join(tempDir, '.claude', 'ultra', 'project-id'),
|
|
149
|
+
'550e8400-e29b-41d4-a716-446655440000',
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Querying for any server should return null — the bare UUID has no server association
|
|
153
|
+
const result = await getProjectId(tempDir, 'https://dashboard.ultra-claude.dev');
|
|
154
|
+
expect(result).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('returns correct ID for matching server from JSON map', async () => {
|
|
158
|
+
const { getProjectId } = await import('../src/config.js');
|
|
159
|
+
|
|
160
|
+
const map = {
|
|
161
|
+
'dashboard.ultra-claude.dev': 'prod-uuid-123',
|
|
162
|
+
'localhost-3000': 'dev-uuid-456',
|
|
163
|
+
};
|
|
164
|
+
await writeFile(join(tempDir, '.claude', 'ultra', 'project-id'), JSON.stringify(map));
|
|
165
|
+
|
|
166
|
+
expect(await getProjectId(tempDir, 'https://dashboard.ultra-claude.dev')).toBe('prod-uuid-123');
|
|
167
|
+
expect(await getProjectId(tempDir, 'http://localhost:3000')).toBe('dev-uuid-456');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns null for unknown server in JSON map', async () => {
|
|
171
|
+
const { getProjectId } = await import('../src/config.js');
|
|
172
|
+
|
|
173
|
+
const map = { 'localhost-3000': 'dev-uuid-456' };
|
|
174
|
+
await writeFile(join(tempDir, '.claude', 'ultra', 'project-id'), JSON.stringify(map));
|
|
175
|
+
|
|
176
|
+
expect(await getProjectId(tempDir, 'https://other-server.dev')).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('returns null when project-id file does not exist', async () => {
|
|
180
|
+
const { getProjectId } = await import('../src/config.js');
|
|
181
|
+
expect(await getProjectId(tempDir, 'https://dashboard.ultra-claude.dev')).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('deletes old format bare UUID file and returns null', async () => {
|
|
185
|
+
const { getProjectId } = await import('../src/config.js');
|
|
186
|
+
const filePath = join(tempDir, '.claude', 'ultra', 'project-id');
|
|
187
|
+
|
|
188
|
+
await writeFile(filePath, '550e8400-e29b-41d4-a716-446655440000');
|
|
189
|
+
|
|
190
|
+
const result = await getProjectId(tempDir, 'https://dashboard.ultra-claude.dev');
|
|
191
|
+
expect(result).toBeNull();
|
|
192
|
+
|
|
193
|
+
// File should be deleted
|
|
194
|
+
await expect(readFile(filePath, 'utf8')).rejects.toThrow();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('deletes corrupt (non-JSON) file and returns null', async () => {
|
|
198
|
+
const { getProjectId } = await import('../src/config.js');
|
|
199
|
+
const filePath = join(tempDir, '.claude', 'ultra', 'project-id');
|
|
200
|
+
|
|
201
|
+
await writeFile(filePath, 'this is not json or a uuid');
|
|
202
|
+
|
|
203
|
+
const result = await getProjectId(tempDir, 'https://dashboard.ultra-claude.dev');
|
|
204
|
+
expect(result).toBeNull();
|
|
205
|
+
|
|
206
|
+
// File should be deleted
|
|
207
|
+
await expect(readFile(filePath, 'utf8')).rejects.toThrow();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('deletes file with quoted UUID string (JSON string) and returns null', async () => {
|
|
211
|
+
const { getProjectId } = await import('../src/config.js');
|
|
212
|
+
const filePath = join(tempDir, '.claude', 'ultra', 'project-id');
|
|
213
|
+
|
|
214
|
+
// A quoted UUID is valid JSON (parses to a string), but it's the old format
|
|
215
|
+
await writeFile(filePath, '"550e8400-e29b-41d4-a716-446655440000"');
|
|
216
|
+
|
|
217
|
+
const result = await getProjectId(tempDir, 'https://dashboard.ultra-claude.dev');
|
|
218
|
+
expect(result).toBeNull();
|
|
219
|
+
|
|
220
|
+
// File should be deleted
|
|
221
|
+
await expect(readFile(filePath, 'utf8')).rejects.toThrow();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('deletes file with JSON array and returns null', async () => {
|
|
225
|
+
const { getProjectId } = await import('../src/config.js');
|
|
226
|
+
const filePath = join(tempDir, '.claude', 'ultra', 'project-id');
|
|
227
|
+
|
|
228
|
+
await writeFile(filePath, '["not", "a", "map"]');
|
|
229
|
+
|
|
230
|
+
const result = await getProjectId(tempDir, 'https://dashboard.ultra-claude.dev');
|
|
231
|
+
expect(result).toBeNull();
|
|
232
|
+
|
|
233
|
+
await expect(readFile(filePath, 'utf8')).rejects.toThrow();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('handles empty file gracefully', async () => {
|
|
237
|
+
const { getProjectId } = await import('../src/config.js');
|
|
238
|
+
const filePath = join(tempDir, '.claude', 'ultra', 'project-id');
|
|
239
|
+
|
|
240
|
+
await writeFile(filePath, '');
|
|
241
|
+
|
|
242
|
+
const result = await getProjectId(tempDir, 'https://dashboard.ultra-claude.dev');
|
|
243
|
+
expect(result).toBeNull();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe('config — writeProjectId (server-keyed)', () => {
|
|
248
|
+
let tempDir: string;
|
|
249
|
+
|
|
250
|
+
beforeEach(async () => {
|
|
251
|
+
vi.resetModules();
|
|
252
|
+
tempDir = await mkdtemp(join(tmpdir(), 'config-write-test-'));
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
afterEach(async () => {
|
|
256
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('creates new JSON map when file does not exist', async () => {
|
|
260
|
+
const { writeProjectId } = await import('../src/config.js');
|
|
261
|
+
|
|
262
|
+
await writeProjectId(tempDir, 'https://dashboard.ultra-claude.dev', 'new-uuid');
|
|
263
|
+
|
|
264
|
+
const content = JSON.parse(await readFile(join(tempDir, '.claude', 'ultra', 'project-id'), 'utf8'));
|
|
265
|
+
expect(content).toEqual({ 'dashboard.ultra-claude.dev': 'new-uuid' });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('updates existing JSON map preserving other server entries', async () => {
|
|
269
|
+
const { writeProjectId } = await import('../src/config.js');
|
|
270
|
+
|
|
271
|
+
// Write first entry
|
|
272
|
+
await writeProjectId(tempDir, 'http://localhost:3000', 'dev-uuid');
|
|
273
|
+
|
|
274
|
+
// Write second entry for different server
|
|
275
|
+
await writeProjectId(tempDir, 'https://dashboard.ultra-claude.dev', 'prod-uuid');
|
|
276
|
+
|
|
277
|
+
const content = JSON.parse(await readFile(join(tempDir, '.claude', 'ultra', 'project-id'), 'utf8'));
|
|
278
|
+
expect(content).toEqual({
|
|
279
|
+
'localhost-3000': 'dev-uuid',
|
|
280
|
+
'dashboard.ultra-claude.dev': 'prod-uuid',
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('overwrites entry for same server', async () => {
|
|
285
|
+
const { writeProjectId } = await import('../src/config.js');
|
|
286
|
+
|
|
287
|
+
await writeProjectId(tempDir, 'http://localhost:3000', 'old-uuid');
|
|
288
|
+
await writeProjectId(tempDir, 'http://localhost:3000', 'new-uuid');
|
|
289
|
+
|
|
290
|
+
const content = JSON.parse(await readFile(join(tempDir, '.claude', 'ultra', 'project-id'), 'utf8'));
|
|
291
|
+
expect(content).toEqual({ 'localhost-3000': 'new-uuid' });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('recovers from corrupt file by starting fresh map', async () => {
|
|
295
|
+
const { writeProjectId } = await import('../src/config.js');
|
|
296
|
+
|
|
297
|
+
// Write corrupt content
|
|
298
|
+
await mkdir(join(tempDir, '.claude', 'ultra'), { recursive: true });
|
|
299
|
+
await writeFile(join(tempDir, '.claude', 'ultra', 'project-id'), 'not json at all');
|
|
300
|
+
|
|
301
|
+
// Should overwrite with a fresh map
|
|
302
|
+
await writeProjectId(tempDir, 'https://dashboard.ultra-claude.dev', 'fresh-uuid');
|
|
303
|
+
|
|
304
|
+
const content = JSON.parse(await readFile(join(tempDir, '.claude', 'ultra', 'project-id'), 'utf8'));
|
|
305
|
+
expect(content).toEqual({ 'dashboard.ultra-claude.dev': 'fresh-uuid' });
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe('config — migrateOldConfigDir', () => {
|
|
310
|
+
let tempHome: string;
|
|
311
|
+
|
|
312
|
+
beforeEach(async () => {
|
|
313
|
+
vi.resetModules();
|
|
314
|
+
vi.clearAllMocks();
|
|
315
|
+
mockPlatform.mockReturnValue('linux');
|
|
316
|
+
|
|
317
|
+
tempHome = await mkdtemp(join(tmpdir(), 'config-migrate-test-'));
|
|
318
|
+
mockHomedir.mockReturnValue(tempHome);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
afterEach(async () => {
|
|
322
|
+
await rm(tempHome, { recursive: true, force: true });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('migrates old config dir to server-specific dir', async () => {
|
|
326
|
+
const { migrateOldConfigDir } = await import('../src/config.js');
|
|
327
|
+
|
|
328
|
+
// Create old dir with a credentials file
|
|
329
|
+
const oldDir = join(tempHome, '.claude', 'ultra', 'dashboard');
|
|
330
|
+
await mkdir(oldDir, { recursive: true });
|
|
331
|
+
await writeFile(join(oldDir, 'credentials.json'), '{"test": true}');
|
|
332
|
+
|
|
333
|
+
const migrated = await migrateOldConfigDir('https://dashboard.ultra-claude.dev');
|
|
334
|
+
expect(migrated).toBe(true);
|
|
335
|
+
|
|
336
|
+
// Old dir should be gone, new dir should exist with the file
|
|
337
|
+
const newDir = join(tempHome, '.claude', 'ultra', 'agent', 'dashboard.ultra-claude.dev');
|
|
338
|
+
const content = await readFile(join(newDir, 'credentials.json'), 'utf8');
|
|
339
|
+
expect(content).toBe('{"test": true}');
|
|
340
|
+
|
|
341
|
+
// Old dir should not exist
|
|
342
|
+
await expect(readFile(join(oldDir, 'credentials.json'), 'utf8')).rejects.toThrow();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('returns false when old dir does not exist', async () => {
|
|
346
|
+
const { migrateOldConfigDir } = await import('../src/config.js');
|
|
347
|
+
const migrated = await migrateOldConfigDir('https://dashboard.ultra-claude.dev');
|
|
348
|
+
expect(migrated).toBe(false);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('returns false when new dir already exists (no overwrite)', async () => {
|
|
352
|
+
const { migrateOldConfigDir } = await import('../src/config.js');
|
|
353
|
+
|
|
354
|
+
// Create both old and new dirs
|
|
355
|
+
const oldDir = join(tempHome, '.claude', 'ultra', 'dashboard');
|
|
356
|
+
const newDir = join(tempHome, '.claude', 'ultra', 'agent', 'dashboard.ultra-claude.dev');
|
|
357
|
+
await mkdir(oldDir, { recursive: true });
|
|
358
|
+
await mkdir(newDir, { recursive: true });
|
|
359
|
+
await writeFile(join(oldDir, 'credentials.json'), '{"old": true}');
|
|
360
|
+
await writeFile(join(newDir, 'credentials.json'), '{"new": true}');
|
|
361
|
+
|
|
362
|
+
const migrated = await migrateOldConfigDir('https://dashboard.ultra-claude.dev');
|
|
363
|
+
expect(migrated).toBe(false);
|
|
364
|
+
|
|
365
|
+
// New dir should be untouched
|
|
366
|
+
const content = await readFile(join(newDir, 'credentials.json'), 'utf8');
|
|
367
|
+
expect(content).toBe('{"new": true}');
|
|
368
|
+
});
|
|
369
|
+
});
|
package/__tests__/daemon.test.ts
CHANGED
|
@@ -18,31 +18,34 @@ const mockGetProjectId = vi.fn();
|
|
|
18
18
|
const mockWriteProjectId = vi.fn();
|
|
19
19
|
const mockWritePid = vi.fn().mockResolvedValue(undefined);
|
|
20
20
|
const mockRemovePid = vi.fn().mockResolvedValue(undefined);
|
|
21
|
+
const mockMigrateOldConfigDir = vi.fn().mockResolvedValue(false);
|
|
21
22
|
|
|
22
23
|
vi.mock('../src/config.js', () => ({
|
|
23
24
|
loadCredentials: (...args: unknown[]) => mockLoadCredentials(...args),
|
|
25
|
+
getServerUrl: (creds: { serverUrl: string } | null) =>
|
|
26
|
+
creds?.serverUrl ?? 'https://dashboard.ultra-claude.dev',
|
|
24
27
|
loadRegistry: (...args: unknown[]) => mockLoadRegistry(...args),
|
|
25
28
|
getProjectId: (...args: unknown[]) => mockGetProjectId(...args),
|
|
26
29
|
writeProjectId: (...args: unknown[]) => mockWriteProjectId(...args),
|
|
27
30
|
writePid: (...args: unknown[]) => mockWritePid(...args),
|
|
28
31
|
removePid: (...args: unknown[]) => mockRemovePid(...args),
|
|
32
|
+
migrateOldConfigDir: (...args: unknown[]) => mockMigrateOldConfigDir(...args),
|
|
29
33
|
paths: {
|
|
30
|
-
|
|
31
|
-
credentials: '/tmp/test-config/credentials.json',
|
|
32
|
-
pid: '/tmp/test-config/daemon.pid',
|
|
33
|
-
logDir: '/tmp/test-config/logs',
|
|
34
|
-
registry: '/tmp/test-registry.json',
|
|
34
|
+
claudeProjects: '/tmp/test-claude-projects',
|
|
35
35
|
projectIdFile: '.claude/ultra/project-id',
|
|
36
|
+
oldConfigDir: '/tmp/test-old-config',
|
|
36
37
|
},
|
|
37
38
|
}));
|
|
38
39
|
|
|
39
40
|
// Mock sync
|
|
40
41
|
const mockCreateProjectOnServer = vi.fn();
|
|
41
42
|
const mockInitialSync = vi.fn().mockResolvedValue(undefined);
|
|
43
|
+
const mockFetchManifest = vi.fn();
|
|
42
44
|
const mockStopSync = vi.fn();
|
|
43
45
|
vi.mock('../src/sync.js', () => ({
|
|
44
46
|
createProjectOnServer: (...args: unknown[]) => mockCreateProjectOnServer(...args),
|
|
45
47
|
initialSync: (...args: unknown[]) => mockInitialSync(...args),
|
|
48
|
+
fetchManifest: (...args: unknown[]) => mockFetchManifest(...args),
|
|
46
49
|
stopSync: (...args: unknown[]) => mockStopSync(...args),
|
|
47
50
|
}));
|
|
48
51
|
|
|
@@ -52,6 +55,13 @@ vi.mock('../src/watcher.js', () => ({
|
|
|
52
55
|
startProjectWatcher: (...args: unknown[]) => mockStartProjectWatcher(...args),
|
|
53
56
|
}));
|
|
54
57
|
|
|
58
|
+
// Mock usage-sync
|
|
59
|
+
vi.mock('../src/usage-sync.js', () => ({
|
|
60
|
+
startUsageWatcher: vi.fn().mockReturnValue({
|
|
61
|
+
close: vi.fn().mockResolvedValue(undefined),
|
|
62
|
+
}),
|
|
63
|
+
}));
|
|
64
|
+
|
|
55
65
|
// Mock logger
|
|
56
66
|
vi.mock('../src/logger.js', () => ({
|
|
57
67
|
logger: {
|
|
@@ -65,6 +75,7 @@ vi.mock('../src/logger.js', () => ({
|
|
|
65
75
|
warn: vi.fn(),
|
|
66
76
|
error: vi.fn(),
|
|
67
77
|
debug: vi.fn(),
|
|
78
|
+
fatal: vi.fn(),
|
|
68
79
|
},
|
|
69
80
|
}));
|
|
70
81
|
|
|
@@ -76,6 +87,8 @@ describe('daemon', () => {
|
|
|
76
87
|
projectPath: '/test/project',
|
|
77
88
|
close: vi.fn().mockResolvedValue(undefined),
|
|
78
89
|
});
|
|
90
|
+
// Default: manifest validation succeeds (project exists on server)
|
|
91
|
+
mockFetchManifest.mockResolvedValue({ success: true, data: [] });
|
|
79
92
|
});
|
|
80
93
|
|
|
81
94
|
it('starts daemon and initializes watchers for registered projects', async () => {
|
|
@@ -93,7 +106,8 @@ describe('daemon', () => {
|
|
|
93
106
|
const result = await startDaemon();
|
|
94
107
|
|
|
95
108
|
expect(result.success).toBe(true);
|
|
96
|
-
expect(mockWritePid).
|
|
109
|
+
expect(mockWritePid).toHaveBeenCalledWith('http://localhost:3000');
|
|
110
|
+
expect(mockGetProjectId).toHaveBeenCalledWith('/test/project', 'http://localhost:3000');
|
|
97
111
|
expect(mockStartProjectWatcher).toHaveBeenCalledWith({
|
|
98
112
|
projectId: 'proj-1',
|
|
99
113
|
projectPath: '/test/project',
|
|
@@ -101,7 +115,7 @@ describe('daemon', () => {
|
|
|
101
115
|
expect(mockInitialSync).toHaveBeenCalledWith('proj-1', '/test/project');
|
|
102
116
|
|
|
103
117
|
await stopDaemon();
|
|
104
|
-
expect(mockRemovePid).
|
|
118
|
+
expect(mockRemovePid).toHaveBeenCalledWith('http://localhost:3000');
|
|
105
119
|
});
|
|
106
120
|
|
|
107
121
|
it('returns NOT_LOGGED_IN error if not logged in', async () => {
|
|
@@ -135,7 +149,55 @@ describe('daemon', () => {
|
|
|
135
149
|
await startDaemon();
|
|
136
150
|
|
|
137
151
|
expect(mockCreateProjectOnServer).toHaveBeenCalledWith('New Project', 'new-project');
|
|
138
|
-
expect(mockWriteProjectId).toHaveBeenCalledWith('/test/new-project', 'new-id');
|
|
152
|
+
expect(mockWriteProjectId).toHaveBeenCalledWith('/test/new-project', 'http://localhost:3000', 'new-id');
|
|
153
|
+
|
|
154
|
+
await stopDaemon();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('re-creates project when server returns 404 (stale project-id)', async () => {
|
|
158
|
+
mockLoadCredentials.mockResolvedValue({
|
|
159
|
+
apiKey: 'test-key',
|
|
160
|
+
userId: 'test-user',
|
|
161
|
+
serverUrl: 'http://localhost:3000',
|
|
162
|
+
});
|
|
163
|
+
mockLoadRegistry.mockResolvedValue({
|
|
164
|
+
projects: [{ path: '/test/stale-project', name: 'Stale Project' }],
|
|
165
|
+
});
|
|
166
|
+
// getProjectId returns a stale ID
|
|
167
|
+
mockGetProjectId.mockResolvedValue('stale-uuid');
|
|
168
|
+
// But the server says project not found
|
|
169
|
+
mockFetchManifest.mockResolvedValue({
|
|
170
|
+
success: false,
|
|
171
|
+
error: 'PROJECT_NOT_FOUND',
|
|
172
|
+
message: 'Project not found',
|
|
173
|
+
});
|
|
174
|
+
mockCreateProjectOnServer.mockResolvedValue({ id: 'fresh-uuid' });
|
|
175
|
+
|
|
176
|
+
vi.resetModules();
|
|
177
|
+
const { startDaemon, stopDaemon } = await import('../src/daemon.js');
|
|
178
|
+
await startDaemon();
|
|
179
|
+
|
|
180
|
+
// Should create a new project on the server
|
|
181
|
+
expect(mockCreateProjectOnServer).toHaveBeenCalledWith('Stale Project', 'stale-project');
|
|
182
|
+
// Should write the new ID
|
|
183
|
+
expect(mockWriteProjectId).toHaveBeenCalledWith('/test/stale-project', 'http://localhost:3000', 'fresh-uuid');
|
|
184
|
+
|
|
185
|
+
await stopDaemon();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('migrates old config dir on startup', async () => {
|
|
189
|
+
mockLoadCredentials.mockResolvedValue({
|
|
190
|
+
apiKey: 'test-key',
|
|
191
|
+
userId: 'test-user',
|
|
192
|
+
serverUrl: 'http://localhost:3000',
|
|
193
|
+
});
|
|
194
|
+
mockLoadRegistry.mockResolvedValue({ projects: [] });
|
|
195
|
+
|
|
196
|
+
vi.resetModules();
|
|
197
|
+
const { startDaemon, stopDaemon } = await import('../src/daemon.js');
|
|
198
|
+
await startDaemon();
|
|
199
|
+
|
|
200
|
+
expect(mockMigrateOldConfigDir).toHaveBeenCalledWith('http://localhost:3000');
|
|
139
201
|
|
|
140
202
|
await stopDaemon();
|
|
141
203
|
});
|
|
@@ -28,12 +28,19 @@ vi.mock('node:fs/promises', () => ({
|
|
|
28
28
|
|
|
29
29
|
// Mock config
|
|
30
30
|
vi.mock('../src/config.js', () => ({
|
|
31
|
+
resolveServerPaths: (serverUrl: string) => {
|
|
32
|
+
const key = new URL(serverUrl).host.replace(':', '-');
|
|
33
|
+
return {
|
|
34
|
+
configDir: `/tmp/test-config/${key}`,
|
|
35
|
+
credentials: `/tmp/test-config/${key}/credentials.json`,
|
|
36
|
+
pid: `/tmp/test-config/${key}/daemon.pid`,
|
|
37
|
+
logDir: `/tmp/test-config/${key}/logs`,
|
|
38
|
+
};
|
|
39
|
+
},
|
|
31
40
|
paths: {
|
|
32
|
-
|
|
33
|
-
credentials: '/tmp/test-config/credentials.json',
|
|
34
|
-
pid: '/tmp/test-config/daemon.pid',
|
|
35
|
-
logDir: '/tmp/test-config/logs',
|
|
41
|
+
claudeProjects: '/home/testuser/.claude/projects',
|
|
36
42
|
projectIdFile: '.claude/ultra/project-id',
|
|
43
|
+
oldConfigDir: '/home/testuser/.claude/ultra/dashboard',
|
|
37
44
|
},
|
|
38
45
|
fileExists: vi.fn().mockResolvedValue(false),
|
|
39
46
|
}));
|
|
@@ -63,7 +70,7 @@ describe('service — Windows Task Scheduler', () => {
|
|
|
63
70
|
|
|
64
71
|
it('installService on win32 calls schtasks /create with correct args', async () => {
|
|
65
72
|
const { installService } = await import('../src/service.js');
|
|
66
|
-
await installService();
|
|
73
|
+
await installService('http://localhost:3000');
|
|
67
74
|
|
|
68
75
|
expect(mockExecFile).toHaveBeenCalledWith(
|
|
69
76
|
'schtasks.exe',
|
|
@@ -73,7 +80,7 @@ describe('service — Windows Task Scheduler', () => {
|
|
|
73
80
|
|
|
74
81
|
it('installService on win32 passes /rl limited and /f flags', async () => {
|
|
75
82
|
const { installService } = await import('../src/service.js');
|
|
76
|
-
await installService();
|
|
83
|
+
await installService('http://localhost:3000');
|
|
77
84
|
|
|
78
85
|
const args = mockExecFile.mock.calls[0]![1] as string[];
|
|
79
86
|
expect(args).toContain('/rl');
|