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.
@@ -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 CONFIG_DIR is resolved at module load time, we must reset modules between tests
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 { paths } = await import('../src/config.js');
36
+ const { resolveConfigDir } = await import('../src/config.js');
37
+ const result = resolveConfigDir(TEST_SERVER_URL);
34
38
 
35
- expect(paths.configDir).toContain('ultraclaude-agent');
36
- expect(paths.configDir).toMatch(/AppData[/\\]Roaming[/\\]ultraclaude-agent/);
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 { paths } = await import('../src/config.js');
57
+ const { resolveConfigDir } = await import('../src/config.js');
58
+ const result = resolveConfigDir(TEST_SERVER_URL);
53
59
 
54
- expect(paths.configDir).toContain('ultraclaude-agent');
60
+ expect(result).toContain('ultraclaude-agent');
61
+ expect(result).toContain(TEST_SERVER_KEY);
55
62
  // Falls back to homedir/AppData/Roaming
56
- expect(paths.configDir).toMatch(/testuser/);
57
- expect(paths.configDir).toMatch(/AppData/);
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/dashboard path on Linux', async () => {
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 { paths } = await import('../src/config.js');
76
+ const { resolveConfigDir } = await import('../src/config.js');
70
77
 
71
- expect(paths.configDir).toBe('/home/testuser/.claude/ultra/dashboard');
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/dashboard path on macOS', async () => {
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 { paths } = await import('../src/config.js');
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(paths.configDir).toBe('/home/testuser/.claude/ultra/dashboard');
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 { paths } = await import('../src/config.js');
109
+ const { resolveServerPaths } = await import('../src/config.js');
110
+ const sp = resolveServerPaths(TEST_SERVER_URL);
88
111
 
89
- expect(paths.credentials).toBe('/home/testuser/.claude/ultra/dashboard/credentials.json');
90
- expect(paths.pid).toBe('/home/testuser/.claude/ultra/dashboard/daemon.pid');
91
- expect(paths.logDir).toBe('/home/testuser/.claude/ultra/dashboard/logs');
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
+ });
@@ -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
- configDir: '/tmp/test-config',
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).toHaveBeenCalled();
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).toHaveBeenCalled();
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
- configDir: '/tmp/test-config',
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');