ultraclaude-agent 0.0.14 → 0.0.16
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__/daemon-reconcile.test.ts +9 -2
- package/__tests__/daemon.test.ts +20 -6
- package/__tests__/push-version-metadata.test.ts +177 -0
- package/__tests__/repl-reset.test.ts +296 -0
- package/__tests__/repl-status-account.test.ts +2 -0
- package/__tests__/repl.test.ts +14 -10
- package/__tests__/snapshot-sync.test.ts +379 -0
- package/dist/cli.js +63 -19
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +18 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon-worker.d.ts +2 -0
- package/dist/daemon-worker.d.ts.map +1 -0
- package/dist/daemon-worker.js +22 -0
- package/dist/daemon-worker.js.map +1 -0
- package/dist/daemon.d.ts +29 -2
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +153 -17
- package/dist/daemon.js.map +1 -1
- package/dist/logger.d.ts +11 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +26 -2
- package/dist/logger.js.map +1 -1
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +94 -20
- package/dist/repl.js.map +1 -1
- package/dist/setup.d.ts +6 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +181 -0
- package/dist/setup.js.map +1 -0
- package/dist/status.d.ts +35 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +70 -0
- package/dist/status.js.map +1 -0
- package/dist/sync.d.ts +9 -1
- package/dist/sync.d.ts.map +1 -1
- package/dist/sync.js +65 -16
- package/dist/sync.js.map +1 -1
- package/dist/usage-sync.d.ts.map +1 -1
- package/dist/usage-sync.js +40 -18
- package/dist/usage-sync.js.map +1 -1
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +26 -3
- package/dist/watcher.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts +4 -0
- package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js +4 -0
- package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +69 -19
- package/src/config.ts +20 -1
- package/src/daemon-worker.ts +27 -0
- package/src/daemon.ts +188 -18
- package/src/logger.ts +30 -2
- package/src/repl.ts +107 -20
- package/src/setup.ts +212 -0
- package/src/status.ts +96 -0
- package/src/sync.ts +73 -16
- package/src/usage-sync.ts +45 -18
- package/src/watcher.ts +28 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -47,6 +47,13 @@ vi.mock('../src/config.js', () => ({
|
|
|
47
47
|
getProjectAccount: (...args: unknown[]) => mockGetProjectAccount(...args),
|
|
48
48
|
setProjectAccount: (...args: unknown[]) => mockSetProjectAccount(...args),
|
|
49
49
|
getDefaultAccount: (...args: unknown[]) => mockGetDefaultAccount(...args),
|
|
50
|
+
resolveServerPaths: vi.fn().mockReturnValue({
|
|
51
|
+
accountsDir: '/tmp/accounts',
|
|
52
|
+
configFile: '/tmp/config.json',
|
|
53
|
+
statusFile: '/tmp/status.json',
|
|
54
|
+
logDir: '/tmp/logs',
|
|
55
|
+
}),
|
|
56
|
+
isDaemonRunning: vi.fn().mockReturnValue(false),
|
|
50
57
|
paths: {
|
|
51
58
|
claudeProjects: '/tmp/test-claude-projects',
|
|
52
59
|
projectIdFile: '.claude/ultra/project-id',
|
|
@@ -202,7 +209,7 @@ describe('daemon — reconcileWatchers edge cases', () => {
|
|
|
202
209
|
const { startDaemon, getDaemonStatus } = await import('../src/daemon.js');
|
|
203
210
|
await startDaemon();
|
|
204
211
|
|
|
205
|
-
expect(getDaemonStatus().running).toBe(true);
|
|
212
|
+
expect((await getDaemonStatus()).running).toBe(true);
|
|
206
213
|
|
|
207
214
|
// All accounts removed from disk
|
|
208
215
|
mockLoadAllCredentials.mockResolvedValue(new Map());
|
|
@@ -217,7 +224,7 @@ describe('daemon — reconcileWatchers edge cases', () => {
|
|
|
217
224
|
await addDirHandler();
|
|
218
225
|
|
|
219
226
|
// Daemon should have stopped
|
|
220
|
-
expect(getDaemonStatus().running).toBe(false);
|
|
227
|
+
expect((await getDaemonStatus()).running).toBe(false);
|
|
221
228
|
});
|
|
222
229
|
|
|
223
230
|
it('logs warning for project with removed account', async () => {
|
package/__tests__/daemon.test.ts
CHANGED
|
@@ -23,10 +23,22 @@ const mockMigrateOldConfigDir = vi.fn().mockResolvedValue(false);
|
|
|
23
23
|
const mockGetProjectAccount = vi.fn();
|
|
24
24
|
const mockSetProjectAccount = vi.fn().mockResolvedValue(undefined);
|
|
25
25
|
const mockGetDefaultAccount = vi.fn();
|
|
26
|
+
const mockLoadServerConfig = vi.fn().mockResolvedValue({
|
|
27
|
+
defaultAccount: '',
|
|
28
|
+
autoAssignNewProjects: true,
|
|
29
|
+
projectAccounts: {},
|
|
30
|
+
});
|
|
31
|
+
const mockResolveServerPaths = vi.fn().mockReturnValue({
|
|
32
|
+
accountsDir: '/tmp/accounts',
|
|
33
|
+
configFile: '/tmp/config.json',
|
|
34
|
+
statusFile: '/tmp/status.json',
|
|
35
|
+
logDir: '/tmp/logs',
|
|
36
|
+
});
|
|
26
37
|
|
|
27
38
|
vi.mock('../src/config.js', () => ({
|
|
28
39
|
loadCredentials: (...args: unknown[]) => mockLoadCredentials(...args),
|
|
29
40
|
loadAllCredentials: (...args: unknown[]) => mockLoadAllCredentials(...args),
|
|
41
|
+
loadServerConfig: (...args: unknown[]) => mockLoadServerConfig(...args),
|
|
30
42
|
getServerUrl: (creds: { serverUrl: string } | null) =>
|
|
31
43
|
creds?.serverUrl ?? 'https://dashboard.ultra-claude.dev',
|
|
32
44
|
loadRegistry: (...args: unknown[]) => mockLoadRegistry(...args),
|
|
@@ -38,6 +50,8 @@ vi.mock('../src/config.js', () => ({
|
|
|
38
50
|
getProjectAccount: (...args: unknown[]) => mockGetProjectAccount(...args),
|
|
39
51
|
setProjectAccount: (...args: unknown[]) => mockSetProjectAccount(...args),
|
|
40
52
|
getDefaultAccount: (...args: unknown[]) => mockGetDefaultAccount(...args),
|
|
53
|
+
resolveServerPaths: (...args: unknown[]) => mockResolveServerPaths(...args),
|
|
54
|
+
isDaemonRunning: vi.fn().mockReturnValue(false),
|
|
41
55
|
paths: {
|
|
42
56
|
claudeProjects: '/tmp/test-claude-projects',
|
|
43
57
|
projectIdFile: '.claude/ultra/project-id',
|
|
@@ -135,7 +149,7 @@ describe('daemon', () => {
|
|
|
135
149
|
expect(result.success).toBe(true);
|
|
136
150
|
expect(mockLoadAllCredentials).toHaveBeenCalledWith('http://localhost:3000');
|
|
137
151
|
|
|
138
|
-
const status = getDaemonStatus();
|
|
152
|
+
const status = await getDaemonStatus();
|
|
139
153
|
expect(status.accounts).toHaveLength(2);
|
|
140
154
|
expect(status.accounts).toEqual(
|
|
141
155
|
expect.arrayContaining([
|
|
@@ -184,7 +198,7 @@ describe('daemon', () => {
|
|
|
184
198
|
expect(callA![0].credentials.userId).toBe('user-1');
|
|
185
199
|
expect(callB![0].credentials.userId).toBe('user-2');
|
|
186
200
|
|
|
187
|
-
const status = getDaemonStatus();
|
|
201
|
+
const status = await getDaemonStatus();
|
|
188
202
|
expect(status.projects).toHaveLength(2);
|
|
189
203
|
|
|
190
204
|
await stopDaemon();
|
|
@@ -330,7 +344,7 @@ describe('daemon', () => {
|
|
|
330
344
|
const { startDaemon, getDaemonStatus, stopDaemon } = await import('../src/daemon.js');
|
|
331
345
|
await startDaemon();
|
|
332
346
|
|
|
333
|
-
const status = getDaemonStatus();
|
|
347
|
+
const status = await getDaemonStatus();
|
|
334
348
|
expect(status.running).toBe(true);
|
|
335
349
|
expect(status.accounts).toHaveLength(2);
|
|
336
350
|
expect(status.projects).toHaveLength(1);
|
|
@@ -338,7 +352,7 @@ describe('daemon', () => {
|
|
|
338
352
|
expect(status.projects[0]!.accountEmail).toBe('alice@example.com');
|
|
339
353
|
|
|
340
354
|
await stopDaemon();
|
|
341
|
-
const stopped = getDaemonStatus();
|
|
355
|
+
const stopped = await getDaemonStatus();
|
|
342
356
|
expect(stopped.running).toBe(false);
|
|
343
357
|
expect(stopped.accounts).toHaveLength(0);
|
|
344
358
|
});
|
|
@@ -368,7 +382,7 @@ describe('daemon', () => {
|
|
|
368
382
|
const { startDaemon, getDaemonStatus, stopDaemon } = await import('../src/daemon.js');
|
|
369
383
|
await startDaemon();
|
|
370
384
|
|
|
371
|
-
expect(getDaemonStatus().projects).toHaveLength(1);
|
|
385
|
+
expect((await getDaemonStatus()).projects).toHaveLength(1);
|
|
372
386
|
|
|
373
387
|
// Now user-2 account is removed — simulate reconcile trigger
|
|
374
388
|
mockLoadAllCredentials.mockResolvedValue(new Map([['user-1', testCreds]]));
|
|
@@ -383,7 +397,7 @@ describe('daemon', () => {
|
|
|
383
397
|
|
|
384
398
|
// Watcher for project-a should have been closed (account removed)
|
|
385
399
|
expect(mockClose).toHaveBeenCalled();
|
|
386
|
-
expect(getDaemonStatus().projects).toHaveLength(0);
|
|
400
|
+
expect((await getDaemonStatus()).projects).toHaveLength(0);
|
|
387
401
|
|
|
388
402
|
await stopDaemon();
|
|
389
403
|
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
// Mock fetch globally
|
|
7
|
+
const mockFetch = vi.fn();
|
|
8
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
9
|
+
|
|
10
|
+
// Mock config module
|
|
11
|
+
vi.mock('../src/config.js', () => ({
|
|
12
|
+
loadCredentials: vi.fn().mockResolvedValue({
|
|
13
|
+
apiKey: 'test-api-key',
|
|
14
|
+
userId: 'test-user',
|
|
15
|
+
serverUrl: 'http://localhost:3000',
|
|
16
|
+
}),
|
|
17
|
+
getServerUrl: vi.fn().mockReturnValue('http://localhost:3000'),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock logger
|
|
21
|
+
vi.mock('../src/logger.js', () => ({
|
|
22
|
+
logger: {
|
|
23
|
+
child: () => ({
|
|
24
|
+
info: vi.fn(),
|
|
25
|
+
warn: vi.fn(),
|
|
26
|
+
error: vi.fn(),
|
|
27
|
+
debug: vi.fn(),
|
|
28
|
+
}),
|
|
29
|
+
info: vi.fn(),
|
|
30
|
+
warn: vi.fn(),
|
|
31
|
+
error: vi.fn(),
|
|
32
|
+
debug: vi.fn(),
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const testCreds = { apiKey: 'test-api-key', userId: 'test-user', serverUrl: 'http://localhost:3000' };
|
|
37
|
+
const testProjectId = 'test-project-id';
|
|
38
|
+
|
|
39
|
+
describe('pushVersionMetadata', () => {
|
|
40
|
+
let tempDir: string;
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
mockFetch.mockReset();
|
|
44
|
+
tempDir = await mkdtemp(join(tmpdir(), 'agent-version-test-'));
|
|
45
|
+
await mkdir(join(tempDir, '.claude', 'ultra'), { recursive: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('reads lastMigrated and lastMigratedSeq from version.json and sends PATCH request', async () => {
|
|
53
|
+
const { pushVersionMetadata } = await import('../src/sync.js');
|
|
54
|
+
|
|
55
|
+
const versionData = {
|
|
56
|
+
lastMigrated: '2026.04.04-24',
|
|
57
|
+
lastMigratedSeq: 24,
|
|
58
|
+
};
|
|
59
|
+
await writeFile(
|
|
60
|
+
join(tempDir, '.claude', 'ultra', 'version.json'),
|
|
61
|
+
JSON.stringify(versionData),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
mockFetch.mockResolvedValueOnce({
|
|
65
|
+
ok: true,
|
|
66
|
+
json: async () => ({ data: { id: testProjectId, ultraVersion: '2026.04.04-24', ultraVersionSeq: 24 } }),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await pushVersionMetadata(testProjectId, tempDir, testCreds);
|
|
70
|
+
|
|
71
|
+
const [url, options] = mockFetch.mock.calls[0]!;
|
|
72
|
+
expect(url).toContain(`/api/projects/${testProjectId}`);
|
|
73
|
+
expect(options.method).toBe('PATCH');
|
|
74
|
+
const body = JSON.parse(options.body);
|
|
75
|
+
expect(body.ultraVersion).toBe('2026.04.04-24');
|
|
76
|
+
expect(body.ultraVersionSeq).toBe(24);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not call PATCH when version.json does not exist', async () => {
|
|
80
|
+
const { pushVersionMetadata } = await import('../src/sync.js');
|
|
81
|
+
|
|
82
|
+
// No version.json created
|
|
83
|
+
await pushVersionMetadata(testProjectId, tempDir, testCreds);
|
|
84
|
+
|
|
85
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('does not call PATCH when version.json has no version data', async () => {
|
|
89
|
+
const { pushVersionMetadata } = await import('../src/sync.js');
|
|
90
|
+
|
|
91
|
+
await writeFile(
|
|
92
|
+
join(tempDir, '.claude', 'ultra', 'version.json'),
|
|
93
|
+
JSON.stringify({ someOtherField: 'value' }),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
await pushVersionMetadata(testProjectId, tempDir, testCreds);
|
|
97
|
+
|
|
98
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('logs warning but does not throw when PATCH request fails', async () => {
|
|
102
|
+
const { pushVersionMetadata } = await import('../src/sync.js');
|
|
103
|
+
|
|
104
|
+
const versionData = { lastMigrated: '2026.04.04-24', lastMigratedSeq: 24 };
|
|
105
|
+
await writeFile(
|
|
106
|
+
join(tempDir, '.claude', 'ultra', 'version.json'),
|
|
107
|
+
JSON.stringify(versionData),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
111
|
+
|
|
112
|
+
// Should not throw — pushVersionMetadata is best-effort
|
|
113
|
+
await expect(
|
|
114
|
+
pushVersionMetadata(testProjectId, tempDir, testCreds),
|
|
115
|
+
).resolves.not.toThrow();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('handles server returning non-ok status without throwing', async () => {
|
|
119
|
+
const { pushVersionMetadata } = await import('../src/sync.js');
|
|
120
|
+
|
|
121
|
+
const versionData = { lastMigrated: '2026.04.04-24', lastMigratedSeq: 24 };
|
|
122
|
+
await writeFile(
|
|
123
|
+
join(tempDir, '.claude', 'ultra', 'version.json'),
|
|
124
|
+
JSON.stringify(versionData),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
mockFetch.mockResolvedValueOnce({
|
|
128
|
+
ok: false,
|
|
129
|
+
status: 500,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await expect(
|
|
133
|
+
pushVersionMetadata(testProjectId, tempDir, testCreds),
|
|
134
|
+
).resolves.not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('sends only ultraVersion when lastMigratedSeq is absent from version.json', async () => {
|
|
138
|
+
const { pushVersionMetadata } = await import('../src/sync.js');
|
|
139
|
+
|
|
140
|
+
const versionData = { lastMigrated: '2026.04.04-24' }; // no lastMigratedSeq
|
|
141
|
+
await writeFile(
|
|
142
|
+
join(tempDir, '.claude', 'ultra', 'version.json'),
|
|
143
|
+
JSON.stringify(versionData),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
mockFetch.mockResolvedValueOnce({
|
|
147
|
+
ok: true,
|
|
148
|
+
json: async () => ({ data: {} }),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await pushVersionMetadata(testProjectId, tempDir, testCreds);
|
|
152
|
+
|
|
153
|
+
const body = JSON.parse(mockFetch.mock.calls[0]![1].body);
|
|
154
|
+
expect(body.ultraVersion).toBe('2026.04.04-24');
|
|
155
|
+
expect(body.ultraVersionSeq).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('sends correct Authorization header', async () => {
|
|
159
|
+
const { pushVersionMetadata } = await import('../src/sync.js');
|
|
160
|
+
|
|
161
|
+
const versionData = { lastMigrated: '2026.04.04-24', lastMigratedSeq: 24 };
|
|
162
|
+
await writeFile(
|
|
163
|
+
join(tempDir, '.claude', 'ultra', 'version.json'),
|
|
164
|
+
JSON.stringify(versionData),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
mockFetch.mockResolvedValueOnce({
|
|
168
|
+
ok: true,
|
|
169
|
+
json: async () => ({ data: {} }),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await pushVersionMetadata(testProjectId, tempDir, testCreds);
|
|
173
|
+
|
|
174
|
+
const [, options] = mockFetch.mock.calls[0]!;
|
|
175
|
+
expect(options.headers['Authorization']).toBe('Bearer test-api-key');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for REPL `reset` command.
|
|
3
|
+
* Task 2 success criteria:
|
|
4
|
+
* - REPL `reset` prompts for confirmation before wiping
|
|
5
|
+
* - REPL `reset` stops daemon, removes accounts/, removes config.json
|
|
6
|
+
* - REPL `reset` with "no" answer does nothing
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
10
|
+
|
|
11
|
+
// Capture readline event handlers
|
|
12
|
+
let lineHandler: ((line: string) => void) | null = null;
|
|
13
|
+
let questionAnswer = 'no'; // Default answer for confirmation prompts
|
|
14
|
+
|
|
15
|
+
const mockRl = {
|
|
16
|
+
prompt: vi.fn(),
|
|
17
|
+
pause: vi.fn(),
|
|
18
|
+
resume: vi.fn(),
|
|
19
|
+
close: vi.fn(),
|
|
20
|
+
question: vi.fn().mockImplementation((_prompt: string, callback: (answer: string) => void) => {
|
|
21
|
+
callback(questionAnswer);
|
|
22
|
+
}),
|
|
23
|
+
on: vi.fn().mockImplementation((event: string, handler: unknown) => {
|
|
24
|
+
if (event === 'line') lineHandler = handler as (line: string) => void;
|
|
25
|
+
return mockRl;
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
vi.mock('node:readline', () => ({
|
|
30
|
+
createInterface: vi.fn(() => mockRl),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock('node:fs/promises', async (importOriginal) => {
|
|
34
|
+
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
|
35
|
+
return { ...actual, unlink: vi.fn().mockResolvedValue(undefined) };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Config mocks
|
|
39
|
+
const mockLoadAllCredentials = vi.fn();
|
|
40
|
+
const mockLoadServerConfig = vi.fn();
|
|
41
|
+
const mockSaveServerConfig = vi.fn().mockResolvedValue(undefined);
|
|
42
|
+
const mockLoadRegistry = vi.fn();
|
|
43
|
+
const mockGetServerUrl = vi.fn().mockReturnValue('http://localhost:3000');
|
|
44
|
+
const mockGetProjectId = vi.fn();
|
|
45
|
+
const mockLoadCredentials = vi.fn();
|
|
46
|
+
const mockReadPid = vi.fn();
|
|
47
|
+
const mockIsDaemonRunning = vi.fn();
|
|
48
|
+
const mockResolveServerPaths = vi.fn();
|
|
49
|
+
const mockClearServerConfig = vi.fn().mockResolvedValue(undefined);
|
|
50
|
+
|
|
51
|
+
vi.mock('../src/config.js', () => ({
|
|
52
|
+
loadAllCredentials: (...args: unknown[]) => mockLoadAllCredentials(...args),
|
|
53
|
+
loadServerConfig: (...args: unknown[]) => mockLoadServerConfig(...args),
|
|
54
|
+
saveServerConfig: (...args: unknown[]) => mockSaveServerConfig(...args),
|
|
55
|
+
loadRegistry: (...args: unknown[]) => mockLoadRegistry(...args),
|
|
56
|
+
getServerUrl: (...args: unknown[]) => mockGetServerUrl(...args),
|
|
57
|
+
getProjectId: (...args: unknown[]) => mockGetProjectId(...args),
|
|
58
|
+
loadCredentials: (...args: unknown[]) => mockLoadCredentials(...args),
|
|
59
|
+
readPid: (...args: unknown[]) => mockReadPid(...args),
|
|
60
|
+
isDaemonRunning: (...args: unknown[]) => mockIsDaemonRunning(...args),
|
|
61
|
+
resolveServerPaths: (...args: unknown[]) => mockResolveServerPaths(...args),
|
|
62
|
+
clearServerConfig: (...args: unknown[]) => mockClearServerConfig(...args),
|
|
63
|
+
paths: {
|
|
64
|
+
claudeProjects: '/tmp/test-claude-projects',
|
|
65
|
+
projectIdFile: '.claude/ultra/project-id',
|
|
66
|
+
oldConfigDir: '/tmp/test-old-config',
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('../src/auth.js', () => ({
|
|
71
|
+
login: vi.fn(),
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
const mockStartDaemon = vi.fn();
|
|
75
|
+
const mockStopDaemon = vi.fn().mockResolvedValue(undefined);
|
|
76
|
+
const mockGetDaemonStatus = vi.fn();
|
|
77
|
+
const mockForkDaemon = vi.fn();
|
|
78
|
+
const mockIsRunningInProcess = vi.fn().mockReturnValue(false);
|
|
79
|
+
|
|
80
|
+
vi.mock('../src/daemon.js', () => ({
|
|
81
|
+
startDaemon: (...args: unknown[]) => mockStartDaemon(...args),
|
|
82
|
+
stopDaemon: (...args: unknown[]) => mockStopDaemon(...args),
|
|
83
|
+
getDaemonStatus: (...args: unknown[]) => mockGetDaemonStatus(...args),
|
|
84
|
+
forkDaemon: (...args: unknown[]) => mockForkDaemon(...args),
|
|
85
|
+
isRunningInProcess: (...args: unknown[]) => mockIsRunningInProcess(...args),
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
vi.mock('../src/service.js', () => ({
|
|
89
|
+
isServiceInstalled: vi.fn().mockResolvedValue(false),
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
vi.mock('../src/sync.js', () => ({
|
|
93
|
+
initialSync: vi.fn().mockResolvedValue(undefined),
|
|
94
|
+
createSnapshot: vi.fn().mockResolvedValue(true),
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
vi.mock('../src/logger.js', () => ({
|
|
98
|
+
logger: {
|
|
99
|
+
child: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
|
|
100
|
+
info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), fatal: vi.fn(),
|
|
101
|
+
},
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
const SERVER_URL = 'http://localhost:3000';
|
|
105
|
+
const alice = { apiKey: 'key-alice', userId: 'user-alice', serverUrl: SERVER_URL, email: 'alice@example.com' };
|
|
106
|
+
|
|
107
|
+
function defaultConfig(overrides = {}) {
|
|
108
|
+
return { defaultAccount: 'user-alice', projectAccounts: {}, ...overrides };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function defaultRegistry() {
|
|
112
|
+
return { projects: [{ path: '/home/user/projects/my-app', name: 'my-app' }] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Run a REPL command and wait for the async handler to finish (rl.resume called in .finally). */
|
|
116
|
+
async function runCommand(cmd: string): Promise<void> {
|
|
117
|
+
return new Promise<void>((resolve) => {
|
|
118
|
+
mockRl.resume.mockImplementationOnce(() => resolve());
|
|
119
|
+
lineHandler!(cmd);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Run reset and wait — it calls rl.close() on success, so resume may not be called; handle both. */
|
|
124
|
+
async function runResetCommand(): Promise<void> {
|
|
125
|
+
return new Promise<void>((resolve) => {
|
|
126
|
+
// Either rl.resume (cancelled path) or rl.close (success path) signals completion
|
|
127
|
+
mockRl.resume.mockImplementationOnce(() => resolve());
|
|
128
|
+
mockRl.close.mockImplementationOnce(() => resolve());
|
|
129
|
+
lineHandler!('reset');
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
describe('repl — reset command', () => {
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
vi.clearAllMocks();
|
|
136
|
+
lineHandler = null;
|
|
137
|
+
questionAnswer = 'no';
|
|
138
|
+
mockGetServerUrl.mockReturnValue(SERVER_URL);
|
|
139
|
+
mockResolveServerPaths.mockReturnValue({
|
|
140
|
+
accountsDir: '/tmp/accounts',
|
|
141
|
+
configFile: '/tmp/config.json',
|
|
142
|
+
logDir: '/tmp/logs',
|
|
143
|
+
});
|
|
144
|
+
mockLoadAllCredentials.mockResolvedValue(new Map([['user-alice', alice]]));
|
|
145
|
+
mockLoadServerConfig.mockResolvedValue(defaultConfig());
|
|
146
|
+
mockLoadRegistry.mockResolvedValue(defaultRegistry());
|
|
147
|
+
mockGetDaemonStatus.mockResolvedValue({ running: false, pid: null, startedAt: null, projects: [] });
|
|
148
|
+
mockIsRunningInProcess.mockReturnValue(false);
|
|
149
|
+
mockReadPid.mockResolvedValue(null);
|
|
150
|
+
// Re-apply question mock after clearAllMocks (implementation is kept, just clear calls)
|
|
151
|
+
mockRl.question.mockImplementation((_prompt: string, callback: (answer: string) => void) => {
|
|
152
|
+
callback(questionAnswer);
|
|
153
|
+
});
|
|
154
|
+
mockRl.on.mockImplementation((event: string, handler: unknown) => {
|
|
155
|
+
if (event === 'line') lineHandler = handler as (line: string) => void;
|
|
156
|
+
return mockRl;
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('reset — prompts for confirmation before wiping', async () => {
|
|
161
|
+
questionAnswer = 'no'; // Will cancel
|
|
162
|
+
|
|
163
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
164
|
+
try {
|
|
165
|
+
vi.resetModules();
|
|
166
|
+
const { startRepl } = await import('../src/repl.js');
|
|
167
|
+
await startRepl(SERVER_URL);
|
|
168
|
+
|
|
169
|
+
await runCommand('reset');
|
|
170
|
+
|
|
171
|
+
// Must have called question() with a prompt about confirmation
|
|
172
|
+
expect(mockRl.question).toHaveBeenCalledWith(
|
|
173
|
+
expect.stringMatching(/are you sure/i),
|
|
174
|
+
expect.any(Function),
|
|
175
|
+
);
|
|
176
|
+
} finally {
|
|
177
|
+
consoleSpy.mockRestore();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('reset — with "no" answer does nothing (no config cleared, no daemon stopped)', async () => {
|
|
182
|
+
questionAnswer = 'no';
|
|
183
|
+
|
|
184
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
185
|
+
try {
|
|
186
|
+
vi.resetModules();
|
|
187
|
+
const { startRepl } = await import('../src/repl.js');
|
|
188
|
+
await startRepl(SERVER_URL);
|
|
189
|
+
|
|
190
|
+
await runCommand('reset');
|
|
191
|
+
|
|
192
|
+
// clearServerConfig must NOT have been called
|
|
193
|
+
expect(mockClearServerConfig).not.toHaveBeenCalled();
|
|
194
|
+
// stopDaemon must NOT have been called
|
|
195
|
+
expect(mockStopDaemon).not.toHaveBeenCalled();
|
|
196
|
+
} finally {
|
|
197
|
+
consoleSpy.mockRestore();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('reset — "no" answer prints cancellation message', async () => {
|
|
202
|
+
questionAnswer = 'no';
|
|
203
|
+
|
|
204
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
205
|
+
try {
|
|
206
|
+
vi.resetModules();
|
|
207
|
+
const { startRepl } = await import('../src/repl.js');
|
|
208
|
+
await startRepl(SERVER_URL);
|
|
209
|
+
|
|
210
|
+
await runCommand('reset');
|
|
211
|
+
|
|
212
|
+
const output = consoleSpy.mock.calls.flat().join('\n');
|
|
213
|
+
expect(output).toMatch(/cancel|no/i);
|
|
214
|
+
} finally {
|
|
215
|
+
consoleSpy.mockRestore();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('reset — with "yes" answer calls clearServerConfig', async () => {
|
|
220
|
+
questionAnswer = 'yes';
|
|
221
|
+
|
|
222
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
223
|
+
try {
|
|
224
|
+
vi.resetModules();
|
|
225
|
+
const { startRepl } = await import('../src/repl.js');
|
|
226
|
+
await startRepl(SERVER_URL);
|
|
227
|
+
|
|
228
|
+
await runResetCommand();
|
|
229
|
+
|
|
230
|
+
// clearServerConfig must be called with the server URL
|
|
231
|
+
expect(mockClearServerConfig).toHaveBeenCalledWith(SERVER_URL);
|
|
232
|
+
} finally {
|
|
233
|
+
consoleSpy.mockRestore();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('reset — with "yes" answer stops external daemon if running', async () => {
|
|
238
|
+
questionAnswer = 'yes';
|
|
239
|
+
mockIsRunningInProcess.mockReturnValue(false);
|
|
240
|
+
mockReadPid.mockResolvedValue(12345);
|
|
241
|
+
mockIsDaemonRunning.mockReturnValue(true);
|
|
242
|
+
|
|
243
|
+
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
244
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
245
|
+
try {
|
|
246
|
+
vi.resetModules();
|
|
247
|
+
const { startRepl } = await import('../src/repl.js');
|
|
248
|
+
await startRepl(SERVER_URL);
|
|
249
|
+
|
|
250
|
+
await runResetCommand();
|
|
251
|
+
|
|
252
|
+
// Should send SIGTERM to the daemon PID
|
|
253
|
+
expect(killSpy).toHaveBeenCalledWith(12345, 'SIGTERM');
|
|
254
|
+
// Still wipes config
|
|
255
|
+
expect(mockClearServerConfig).toHaveBeenCalledWith(SERVER_URL);
|
|
256
|
+
} finally {
|
|
257
|
+
killSpy.mockRestore();
|
|
258
|
+
consoleSpy.mockRestore();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('reset — with "yes" answer stops in-process daemon', async () => {
|
|
263
|
+
questionAnswer = 'yes';
|
|
264
|
+
mockIsRunningInProcess.mockReturnValue(true);
|
|
265
|
+
|
|
266
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
267
|
+
try {
|
|
268
|
+
vi.resetModules();
|
|
269
|
+
const { startRepl } = await import('../src/repl.js');
|
|
270
|
+
await startRepl(SERVER_URL);
|
|
271
|
+
|
|
272
|
+
await runResetCommand();
|
|
273
|
+
|
|
274
|
+
expect(mockStopDaemon).toHaveBeenCalled();
|
|
275
|
+
expect(mockClearServerConfig).toHaveBeenCalledWith(SERVER_URL);
|
|
276
|
+
} finally {
|
|
277
|
+
consoleSpy.mockRestore();
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('help — lists reset command', async () => {
|
|
282
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
283
|
+
try {
|
|
284
|
+
vi.resetModules();
|
|
285
|
+
const { startRepl } = await import('../src/repl.js');
|
|
286
|
+
await startRepl(SERVER_URL);
|
|
287
|
+
|
|
288
|
+
await runCommand('help');
|
|
289
|
+
const output = consoleSpy.mock.calls.flat().join('\n');
|
|
290
|
+
|
|
291
|
+
expect(output).toMatch(/reset/i);
|
|
292
|
+
} finally {
|
|
293
|
+
consoleSpy.mockRestore();
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
});
|
|
@@ -64,6 +64,8 @@ vi.mock('../src/daemon.js', () => ({
|
|
|
64
64
|
startDaemon: vi.fn(),
|
|
65
65
|
stopDaemon: vi.fn().mockResolvedValue(undefined),
|
|
66
66
|
getDaemonStatus: (...args: unknown[]) => mockGetDaemonStatus(...args),
|
|
67
|
+
forkDaemon: vi.fn(),
|
|
68
|
+
isRunningInProcess: vi.fn().mockReturnValue(false),
|
|
67
69
|
}));
|
|
68
70
|
|
|
69
71
|
vi.mock('../src/service.js', () => ({ isServiceInstalled: vi.fn().mockResolvedValue(false) }));
|
package/__tests__/repl.test.ts
CHANGED
|
@@ -67,10 +67,14 @@ vi.mock('../src/auth.js', () => ({
|
|
|
67
67
|
const mockStartDaemon = vi.fn();
|
|
68
68
|
const mockStopDaemon = vi.fn().mockResolvedValue(undefined);
|
|
69
69
|
const mockGetDaemonStatus = vi.fn();
|
|
70
|
+
const mockForkDaemon = vi.fn();
|
|
71
|
+
const mockIsRunningInProcess = vi.fn().mockReturnValue(false);
|
|
70
72
|
vi.mock('../src/daemon.js', () => ({
|
|
71
73
|
startDaemon: (...args: unknown[]) => mockStartDaemon(...args),
|
|
72
74
|
stopDaemon: (...args: unknown[]) => mockStopDaemon(...args),
|
|
73
75
|
getDaemonStatus: (...args: unknown[]) => mockGetDaemonStatus(...args),
|
|
76
|
+
forkDaemon: (...args: unknown[]) => mockForkDaemon(...args),
|
|
77
|
+
isRunningInProcess: (...args: unknown[]) => mockIsRunningInProcess(...args),
|
|
74
78
|
}));
|
|
75
79
|
|
|
76
80
|
// Mock service
|
|
@@ -658,8 +662,9 @@ describe('repl — start/stop/status/push/snapshot commands', () => {
|
|
|
658
662
|
mockLoadRegistry.mockResolvedValue(defaultRegistry());
|
|
659
663
|
});
|
|
660
664
|
|
|
661
|
-
it('start — calls
|
|
662
|
-
|
|
665
|
+
it('start — calls forkDaemon() and prints success', async () => {
|
|
666
|
+
mockGetDaemonStatus.mockResolvedValue({ running: false, pid: null });
|
|
667
|
+
mockForkDaemon.mockReturnValue({ pid: 12345 });
|
|
663
668
|
|
|
664
669
|
vi.resetModules();
|
|
665
670
|
const { startRepl } = await import('../src/repl.js');
|
|
@@ -668,7 +673,7 @@ describe('repl — start/stop/status/push/snapshot commands', () => {
|
|
|
668
673
|
try {
|
|
669
674
|
await startRepl(SERVER_URL);
|
|
670
675
|
await runCommand('start');
|
|
671
|
-
expect(
|
|
676
|
+
expect(mockForkDaemon).toHaveBeenCalled();
|
|
672
677
|
const output = consoleSpy.mock.calls.flat().join('\n');
|
|
673
678
|
expect(output).toMatch(/daemon started/i);
|
|
674
679
|
} finally {
|
|
@@ -676,8 +681,8 @@ describe('repl — start/stop/status/push/snapshot commands', () => {
|
|
|
676
681
|
}
|
|
677
682
|
});
|
|
678
683
|
|
|
679
|
-
it('start — prints failure message when
|
|
680
|
-
|
|
684
|
+
it('start — prints failure message when already running', async () => {
|
|
685
|
+
mockGetDaemonStatus.mockResolvedValue({ running: true, pid: 99999 });
|
|
681
686
|
|
|
682
687
|
vi.resetModules();
|
|
683
688
|
const { startRepl } = await import('../src/repl.js');
|
|
@@ -687,16 +692,15 @@ describe('repl — start/stop/status/push/snapshot commands', () => {
|
|
|
687
692
|
await startRepl(SERVER_URL);
|
|
688
693
|
await runCommand('start');
|
|
689
694
|
const output = consoleSpy.mock.calls.flat().join('\n');
|
|
690
|
-
expect(output).toMatch(/
|
|
695
|
+
expect(output).toMatch(/already running/i);
|
|
691
696
|
} finally {
|
|
692
697
|
consoleSpy.mockRestore();
|
|
693
698
|
}
|
|
694
699
|
});
|
|
695
700
|
|
|
696
701
|
it('stop — sends SIGTERM to external daemon process', async () => {
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
mockIsDaemonRunning.mockReturnValue(true);
|
|
702
|
+
mockIsRunningInProcess.mockReturnValue(false);
|
|
703
|
+
mockGetDaemonStatus.mockResolvedValue({ running: true, pid: 12345 });
|
|
700
704
|
|
|
701
705
|
const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true);
|
|
702
706
|
|
|
@@ -715,7 +719,7 @@ describe('repl — start/stop/status/push/snapshot commands', () => {
|
|
|
715
719
|
});
|
|
716
720
|
|
|
717
721
|
it('stop — stops in-process daemon directly', async () => {
|
|
718
|
-
|
|
722
|
+
mockIsRunningInProcess.mockReturnValue(true);
|
|
719
723
|
mockStopDaemon.mockResolvedValue(undefined);
|
|
720
724
|
|
|
721
725
|
vi.resetModules();
|