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.
Files changed (63) hide show
  1. package/__tests__/daemon-reconcile.test.ts +9 -2
  2. package/__tests__/daemon.test.ts +20 -6
  3. package/__tests__/push-version-metadata.test.ts +177 -0
  4. package/__tests__/repl-reset.test.ts +296 -0
  5. package/__tests__/repl-status-account.test.ts +2 -0
  6. package/__tests__/repl.test.ts +14 -10
  7. package/__tests__/snapshot-sync.test.ts +379 -0
  8. package/dist/cli.js +63 -19
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config.d.ts +8 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +18 -1
  13. package/dist/config.js.map +1 -1
  14. package/dist/daemon-worker.d.ts +2 -0
  15. package/dist/daemon-worker.d.ts.map +1 -0
  16. package/dist/daemon-worker.js +22 -0
  17. package/dist/daemon-worker.js.map +1 -0
  18. package/dist/daemon.d.ts +29 -2
  19. package/dist/daemon.d.ts.map +1 -1
  20. package/dist/daemon.js +153 -17
  21. package/dist/daemon.js.map +1 -1
  22. package/dist/logger.d.ts +11 -1
  23. package/dist/logger.d.ts.map +1 -1
  24. package/dist/logger.js +26 -2
  25. package/dist/logger.js.map +1 -1
  26. package/dist/repl.d.ts.map +1 -1
  27. package/dist/repl.js +94 -20
  28. package/dist/repl.js.map +1 -1
  29. package/dist/setup.d.ts +6 -0
  30. package/dist/setup.d.ts.map +1 -0
  31. package/dist/setup.js +181 -0
  32. package/dist/setup.js.map +1 -0
  33. package/dist/status.d.ts +35 -0
  34. package/dist/status.d.ts.map +1 -0
  35. package/dist/status.js +70 -0
  36. package/dist/status.js.map +1 -0
  37. package/dist/sync.d.ts +9 -1
  38. package/dist/sync.d.ts.map +1 -1
  39. package/dist/sync.js +65 -16
  40. package/dist/sync.js.map +1 -1
  41. package/dist/usage-sync.d.ts.map +1 -1
  42. package/dist/usage-sync.js +40 -18
  43. package/dist/usage-sync.js.map +1 -1
  44. package/dist/watcher.d.ts.map +1 -1
  45. package/dist/watcher.js +26 -3
  46. package/dist/watcher.js.map +1 -1
  47. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts +4 -0
  48. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts.map +1 -1
  49. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js +4 -0
  50. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/cli.ts +69 -19
  53. package/src/config.ts +20 -1
  54. package/src/daemon-worker.ts +27 -0
  55. package/src/daemon.ts +188 -18
  56. package/src/logger.ts +30 -2
  57. package/src/repl.ts +107 -20
  58. package/src/setup.ts +212 -0
  59. package/src/status.ts +96 -0
  60. package/src/sync.ts +73 -16
  61. package/src/usage-sync.ts +45 -18
  62. package/src/watcher.ts +28 -3
  63. 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 () => {
@@ -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) }));
@@ -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 startDaemon() and prints success', async () => {
662
- mockStartDaemon.mockResolvedValue({ success: true });
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(mockStartDaemon).toHaveBeenCalled();
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 startDaemon fails', async () => {
680
- mockStartDaemon.mockResolvedValue({ success: false, message: 'already running', error: 'ALREADY_RUNNING' });
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(/could not start|already running/i);
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
- mockGetDaemonStatus.mockReturnValue({ running: false });
698
- mockReadPid.mockResolvedValue(12345);
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
- mockGetDaemonStatus.mockReturnValue({ running: true });
722
+ mockIsRunningInProcess.mockReturnValue(true);
719
723
  mockStopDaemon.mockResolvedValue(undefined);
720
724
 
721
725
  vi.resetModules();