ultraclaude-agent 0.0.21 → 0.0.23

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 (61) hide show
  1. package/__tests__/daemon-reconcile.test.ts +1 -0
  2. package/__tests__/daemon.test.ts +1 -0
  3. package/__tests__/hide-branches.test.ts +129 -0
  4. package/__tests__/logger-multistream.test.ts +151 -0
  5. package/__tests__/repl-reset.test.ts +3 -0
  6. package/__tests__/repl-status-account.test.ts +3 -0
  7. package/__tests__/repl.test.ts +7 -2
  8. package/__tests__/snapshot-sync.test.ts +6 -6
  9. package/__tests__/status-command.test.ts +479 -0
  10. package/__tests__/status-service-type.test.ts +177 -0
  11. package/__tests__/sync-bugs.test.ts +8 -7
  12. package/__tests__/sync-queue-credentials.test.ts +4 -4
  13. package/__tests__/sync-reorder.test.ts +8 -8
  14. package/__tests__/sync.test.ts +4 -3
  15. package/__tests__/version-check.test.ts +1 -1
  16. package/__tests__/version-watcher.test.ts +8 -2
  17. package/__tests__/watcher-branch.test.ts +68 -0
  18. package/dist/cli.js +6 -96
  19. package/dist/cli.js.map +1 -1
  20. package/dist/daemon.d.ts.map +1 -1
  21. package/dist/daemon.js +3 -70
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/repl.d.ts.map +1 -1
  24. package/dist/repl.js +6 -143
  25. package/dist/repl.js.map +1 -1
  26. package/dist/sync.d.ts +13 -8
  27. package/dist/sync.d.ts.map +1 -1
  28. package/dist/sync.js +45 -21
  29. package/dist/sync.js.map +1 -1
  30. package/dist/watcher.d.ts +6 -0
  31. package/dist/watcher.d.ts.map +1 -1
  32. package/dist/watcher.js +92 -7
  33. package/dist/watcher.js.map +1 -1
  34. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts +11 -0
  35. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.d.ts.map +1 -1
  36. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js +8 -0
  37. package/node_modules/@ultra-claude/shared/dist/api/schemas/projects.js.map +1 -1
  38. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts +11 -0
  39. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.d.ts.map +1 -1
  40. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js +11 -0
  41. package/node_modules/@ultra-claude/shared/dist/api/schemas/sync.js.map +1 -1
  42. package/node_modules/@ultra-claude/shared/dist/index.d.ts +3 -3
  43. package/node_modules/@ultra-claude/shared/dist/index.d.ts.map +1 -1
  44. package/node_modules/@ultra-claude/shared/dist/index.js +2 -2
  45. package/node_modules/@ultra-claude/shared/dist/index.js.map +1 -1
  46. package/node_modules/@ultra-claude/shared/dist/types.d.ts +0 -32
  47. package/node_modules/@ultra-claude/shared/dist/types.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/cli.ts +6 -120
  50. package/src/daemon.ts +3 -82
  51. package/src/repl.ts +6 -166
  52. package/src/sync.ts +56 -14
  53. package/src/watcher.ts +101 -7
  54. package/__tests__/claude-profiles-ops.test.ts +0 -441
  55. package/__tests__/claude-profiles.test.ts +0 -407
  56. package/__tests__/credential-watcher.test.ts +0 -229
  57. package/dist/claude-profiles.d.ts +0 -83
  58. package/dist/claude-profiles.d.ts.map +0 -1
  59. package/dist/claude-profiles.js +0 -499
  60. package/dist/claude-profiles.js.map +0 -1
  61. package/src/claude-profiles.ts +0 -597
@@ -77,6 +77,7 @@ vi.mock('../src/sync.js', () => ({
77
77
  const mockStartProjectWatcher = vi.fn();
78
78
  vi.mock('../src/watcher.js', () => ({
79
79
  startProjectWatcher: (...args: unknown[]) => mockStartProjectWatcher(...args),
80
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
80
81
  }));
81
82
 
82
83
  // Mock usage-sync
@@ -75,6 +75,7 @@ vi.mock('../src/sync.js', () => ({
75
75
  const mockStartProjectWatcher = vi.fn();
76
76
  vi.mock('../src/watcher.js', () => ({
77
77
  startProjectWatcher: (...args: unknown[]) => mockStartProjectWatcher(...args),
78
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
78
79
  }));
79
80
 
80
81
  // Mock usage-sync
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tests for hideBranches() in sync.ts (Plan 032, Task 1).
3
+ *
4
+ * Verifies branch hide notification is sent correctly to the server.
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+
9
+ // Mock fetch globally
10
+ const mockFetch = vi.fn();
11
+ vi.stubGlobal('fetch', mockFetch);
12
+
13
+ // Mock config module
14
+ vi.mock('../src/config.js', () => ({
15
+ loadCredentials: vi.fn().mockResolvedValue({
16
+ apiKey: 'test-api-key',
17
+ userId: 'test-user',
18
+ serverUrl: 'http://localhost:3000',
19
+ }),
20
+ getServerUrl: vi.fn().mockReturnValue('http://localhost:3000'),
21
+ getProjectId: vi.fn().mockResolvedValue('test-project-id'),
22
+ writeProjectId: vi.fn().mockResolvedValue(undefined),
23
+ paths: {
24
+ claudeProjects: '/tmp/test-claude-projects',
25
+ projectIdFile: '.claude/ultra/project-id',
26
+ oldConfigDir: '/tmp/test-old-config',
27
+ },
28
+ }));
29
+
30
+ // Mock logger
31
+ vi.mock('../src/logger.js', () => ({
32
+ logger: {
33
+ child: () => ({
34
+ info: vi.fn(),
35
+ warn: vi.fn(),
36
+ error: vi.fn(),
37
+ debug: vi.fn(),
38
+ }),
39
+ info: vi.fn(),
40
+ warn: vi.fn(),
41
+ error: vi.fn(),
42
+ debug: vi.fn(),
43
+ },
44
+ }));
45
+
46
+ const testCreds = { apiKey: 'test-api-key', userId: 'test-user', serverUrl: 'http://localhost:3000' };
47
+
48
+ describe('hideBranches', () => {
49
+ beforeEach(() => {
50
+ vi.resetModules();
51
+ mockFetch.mockReset();
52
+ });
53
+
54
+ it('POSTs to /api/sync/branch-hide with projectId and branches', async () => {
55
+ const { hideBranches } = await import('../src/sync.js');
56
+
57
+ mockFetch.mockResolvedValueOnce({
58
+ ok: true,
59
+ json: async () => ({ data: {} }),
60
+ });
61
+
62
+ const result = await hideBranches('proj-uuid', ['feature/old', 'plan/done'], testCreds);
63
+ expect(result).toBe(true);
64
+
65
+ expect(mockFetch).toHaveBeenCalledOnce();
66
+ const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit];
67
+ expect(url).toContain('/api/sync/branch-hide');
68
+ expect(options.method).toBe('POST');
69
+
70
+ const body = JSON.parse(options.body as string) as { projectId: string; branches: string[] };
71
+ expect(body.projectId).toBe('proj-uuid');
72
+ expect(body.branches).toEqual(['feature/old', 'plan/done']);
73
+
74
+ const headers = options.headers as Record<string, string>;
75
+ expect(headers['Idempotency-Key']).toBeDefined();
76
+ expect(headers['Idempotency-Key']).toMatch(
77
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
78
+ );
79
+ });
80
+
81
+ it('returns false when server returns error', async () => {
82
+ const { hideBranches } = await import('../src/sync.js');
83
+
84
+ mockFetch.mockResolvedValueOnce({
85
+ ok: false,
86
+ status: 500,
87
+ });
88
+
89
+ const result = await hideBranches('proj-uuid', ['feature/old'], testCreds);
90
+ expect(result).toBe(false);
91
+ });
92
+
93
+ it('returns false when network is unreachable', async () => {
94
+ const { hideBranches } = await import('../src/sync.js');
95
+
96
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
97
+
98
+ const result = await hideBranches('proj-uuid', ['feature/old'], testCreds);
99
+ expect(result).toBe(false);
100
+ });
101
+
102
+ it('returns false when server returns 404 (project not found)', async () => {
103
+ const { hideBranches } = await import('../src/sync.js');
104
+
105
+ mockFetch.mockResolvedValueOnce({
106
+ ok: false,
107
+ status: 404,
108
+ });
109
+
110
+ const result = await hideBranches('proj-uuid', ['main'], testCreds);
111
+ expect(result).toBe(false);
112
+ });
113
+
114
+ it('sends multiple branches in a single request', async () => {
115
+ const { hideBranches } = await import('../src/sync.js');
116
+
117
+ mockFetch.mockResolvedValueOnce({
118
+ ok: true,
119
+ json: async () => ({ data: {} }),
120
+ });
121
+
122
+ const branches = ['feature/a', 'feature/b', 'plan/001', 'plan/002'];
123
+ await hideBranches('proj-uuid', branches, testCreds);
124
+
125
+ const body = JSON.parse((mockFetch.mock.calls[0] as [string, RequestInit])[1].body as string) as { branches: string[] };
126
+ expect(body.branches).toHaveLength(4);
127
+ expect(body.branches).toEqual(branches);
128
+ });
129
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Tests for unified logging setup (initMultistreamLogger / initFileLogger).
3
+ * Plan 030 Task 1 regression: "daemon.log contains structured JSON in both foreground and background mode"
4
+ * and "Foreground mode also outputs to stdout".
5
+ *
6
+ * Verifies that:
7
+ * - initMultistreamLogger sets up pino with BOTH file (JSON) and stdout (pretty) transports
8
+ * - initFileLogger sets up pino with ONLY a file (JSON) transport
9
+ * - Both write to daemon.log in the correct log directory
10
+ */
11
+
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
13
+
14
+ // Mock pino so we don't spin up real worker-thread file transports
15
+ const mockTransport = vi.fn();
16
+ const mockPinoInstance = {
17
+ info: vi.fn(),
18
+ warn: vi.fn(),
19
+ error: vi.fn(),
20
+ debug: vi.fn(),
21
+ child: vi.fn().mockReturnThis(),
22
+ };
23
+
24
+ const mockPino = vi.fn().mockReturnValue(mockPinoInstance);
25
+ (mockPino as unknown as { transport: typeof mockTransport }).transport = mockTransport;
26
+ mockTransport.mockReturnValue('mock-transport');
27
+
28
+ vi.mock('pino', () => ({
29
+ default: Object.assign(mockPino, { transport: mockTransport }),
30
+ }));
31
+
32
+ // Mock resolveServerPaths to return controlled paths
33
+ const mockResolveServerPaths = vi.fn().mockReturnValue({
34
+ logDir: '/tmp/test-logs',
35
+ accountsDir: '/tmp/accounts',
36
+ configFile: '/tmp/config.json',
37
+ statusFile: '/tmp/status.json',
38
+ pidFile: '/tmp/daemon.pid',
39
+ });
40
+
41
+ vi.mock('../src/config.js', () => ({
42
+ resolveServerPaths: (...args: unknown[]) => mockResolveServerPaths(...args),
43
+ }));
44
+
45
+ describe('logger — initMultistreamLogger (foreground mode)', () => {
46
+ beforeEach(() => {
47
+ vi.clearAllMocks();
48
+ mockTransport.mockReturnValue('mock-transport');
49
+ (mockPino as unknown as { transport: typeof mockTransport }).transport = mockTransport;
50
+ mockPino.mockReturnValue(mockPinoInstance);
51
+ });
52
+
53
+ afterEach(() => {
54
+ vi.resetModules();
55
+ });
56
+
57
+ it('calls pino.transport with two targets (file + stdout)', async () => {
58
+ const { initMultistreamLogger } = await import('../src/logger.js');
59
+ initMultistreamLogger('http://localhost:3000');
60
+
61
+ expect(mockTransport).toHaveBeenCalledOnce();
62
+ const transportArgs = mockTransport.mock.calls[0]![0] as { targets: Array<{ target: string; options?: Record<string, unknown> }> };
63
+ expect(transportArgs).toHaveProperty('targets');
64
+ expect(transportArgs.targets).toHaveLength(2);
65
+ });
66
+
67
+ it('includes pino/file target writing to daemon.log', async () => {
68
+ const { initMultistreamLogger } = await import('../src/logger.js');
69
+ initMultistreamLogger('http://localhost:3000');
70
+
71
+ const transportArgs = mockTransport.mock.calls[0]![0] as { targets: Array<{ target: string; options?: Record<string, unknown> }> };
72
+ const fileTarget = transportArgs.targets.find((t) => t.target === 'pino/file');
73
+ expect(fileTarget).toBeDefined();
74
+ expect(fileTarget!.options?.destination).toMatch(/daemon\.log$/);
75
+ });
76
+
77
+ it('includes pino-pretty target writing to stdout (destination: 1)', async () => {
78
+ const { initMultistreamLogger } = await import('../src/logger.js');
79
+ initMultistreamLogger('http://localhost:3000');
80
+
81
+ const transportArgs = mockTransport.mock.calls[0]![0] as { targets: Array<{ target: string; options?: Record<string, unknown> }> };
82
+ const prettyTarget = transportArgs.targets.find((t) => t.target === 'pino-pretty');
83
+ expect(prettyTarget).toBeDefined();
84
+ expect(prettyTarget!.options?.destination).toBe(1); // stdout fd
85
+ });
86
+
87
+ it('creates the pino logger with the transport stream', async () => {
88
+ const { initMultistreamLogger } = await import('../src/logger.js');
89
+ initMultistreamLogger('http://localhost:3000');
90
+
91
+ // pino() should have been called with the transport returned by pino.transport()
92
+ expect(mockPino).toHaveBeenCalledWith(
93
+ expect.objectContaining({ level: expect.any(String) }),
94
+ 'mock-transport',
95
+ );
96
+ });
97
+
98
+ it('writes to log dir from resolveServerPaths', async () => {
99
+ const { initMultistreamLogger } = await import('../src/logger.js');
100
+ initMultistreamLogger('http://localhost:3000');
101
+
102
+ expect(mockResolveServerPaths).toHaveBeenCalledWith('http://localhost:3000');
103
+
104
+ const transportArgs = mockTransport.mock.calls[0]![0] as { targets: Array<{ target: string; options?: { destination?: string } }> };
105
+ const fileTarget = transportArgs.targets.find((t) => t.target === 'pino/file');
106
+ expect(fileTarget?.options?.destination).toContain('/tmp/test-logs');
107
+ });
108
+ });
109
+
110
+ describe('logger — initFileLogger (background mode)', () => {
111
+ beforeEach(() => {
112
+ vi.clearAllMocks();
113
+ mockTransport.mockReturnValue('mock-transport');
114
+ (mockPino as unknown as { transport: typeof mockTransport }).transport = mockTransport;
115
+ mockPino.mockReturnValue(mockPinoInstance);
116
+ });
117
+
118
+ afterEach(() => {
119
+ vi.resetModules();
120
+ });
121
+
122
+ it('calls pino.transport with a single pino/file target (no stdout)', async () => {
123
+ const { initFileLogger } = await import('../src/logger.js');
124
+ initFileLogger('http://localhost:3000');
125
+
126
+ expect(mockTransport).toHaveBeenCalledOnce();
127
+ const transportArgs = mockTransport.mock.calls[0]![0] as { target?: string; targets?: unknown[] };
128
+
129
+ // Single target form (not array) — background daemon writes only to file
130
+ expect(transportArgs.target).toBe('pino/file');
131
+ expect(transportArgs.targets).toBeUndefined();
132
+ });
133
+
134
+ it('writes to daemon.log in log directory', async () => {
135
+ const { initFileLogger } = await import('../src/logger.js');
136
+ initFileLogger('http://localhost:3000');
137
+
138
+ const transportArgs = mockTransport.mock.calls[0]![0] as { options?: { destination?: string } };
139
+ expect(transportArgs.options?.destination).toMatch(/daemon\.log$/);
140
+ });
141
+
142
+ it('creates pino instance with the file transport', async () => {
143
+ const { initFileLogger } = await import('../src/logger.js');
144
+ initFileLogger('http://localhost:3000');
145
+
146
+ expect(mockPino).toHaveBeenCalledWith(
147
+ expect.objectContaining({ level: expect.any(String) }),
148
+ 'mock-transport',
149
+ );
150
+ });
151
+ });
@@ -93,6 +93,9 @@ vi.mock('../src/sync.js', () => ({
93
93
  initialSync: vi.fn().mockResolvedValue(undefined),
94
94
  createSnapshot: vi.fn().mockResolvedValue(true),
95
95
  }));
96
+ vi.mock('../src/watcher.js', () => ({
97
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
98
+ }));
96
99
 
97
100
  vi.mock('../src/logger.js', () => ({
98
101
  logger: {
@@ -73,6 +73,9 @@ vi.mock('../src/sync.js', () => ({
73
73
  initialSync: vi.fn().mockResolvedValue(undefined),
74
74
  createSnapshot: vi.fn().mockResolvedValue(true),
75
75
  }));
76
+ vi.mock('../src/watcher.js', () => ({
77
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
78
+ }));
76
79
  vi.mock('../src/logger.js', () => ({
77
80
  logger: {
78
81
  child: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }),
@@ -91,6 +91,11 @@ vi.mock('../src/sync.js', () => ({
91
91
  createSnapshot: (...args: unknown[]) => mockCreateSnapshot(...args),
92
92
  }));
93
93
 
94
+ // Mock watcher (getCurrentBranch is imported by repl.ts)
95
+ vi.mock('../src/watcher.js', () => ({
96
+ getCurrentBranch: vi.fn().mockReturnValue('main'),
97
+ }));
98
+
94
99
  // Mock logger
95
100
  vi.mock('../src/logger.js', () => ({
96
101
  logger: {
@@ -775,7 +780,7 @@ describe('repl — start/stop/status/push/snapshot commands', () => {
775
780
  try {
776
781
  await startRepl(SERVER_URL);
777
782
  await runCommand('push');
778
- expect(mockInitialSync).toHaveBeenCalledWith('proj-uuid-1', '/home/user/projects/my-app', alice);
783
+ expect(mockInitialSync).toHaveBeenCalledWith('proj-uuid-1', '/home/user/projects/my-app', alice, 'main');
779
784
  } finally {
780
785
  consoleSpy.mockRestore();
781
786
  }
@@ -824,7 +829,7 @@ describe('repl — start/stop/status/push/snapshot commands', () => {
824
829
  expect(mockCreateSnapshot).toHaveBeenCalledWith(
825
830
  'proj-uuid-1',
826
831
  expect.stringContaining('release v1.0'),
827
- undefined, undefined, alice,
832
+ 'main', undefined, undefined, alice,
828
833
  );
829
834
  } finally {
830
835
  consoleSpy.mockRestore();
@@ -65,7 +65,7 @@ describe('createSnapshot — Idempotency-Key header (Fix A)', () => {
65
65
  json: async () => ({ data: { id: 'snap-uuid' } }),
66
66
  });
67
67
 
68
- await createSnapshot('proj-uuid', 'test snapshot', 'abc123', 'git_commit', testCreds);
68
+ await createSnapshot('proj-uuid', 'test snapshot', 'main', 'abc123', 'git_commit', testCreds);
69
69
 
70
70
  expect(mockFetch).toHaveBeenCalledOnce();
71
71
  const [, options] = mockFetch.mock.calls[0];
@@ -86,7 +86,7 @@ describe('createSnapshot — Idempotency-Key header (Fix A)', () => {
86
86
  });
87
87
 
88
88
  // No credentials provided — uses apiRequestWithDiskCredentials
89
- await createSnapshot('proj-uuid', 'test snapshot');
89
+ await createSnapshot('proj-uuid', 'test snapshot', 'main');
90
90
 
91
91
  expect(mockFetch).toHaveBeenCalledOnce();
92
92
  const [, options] = mockFetch.mock.calls[0];
@@ -269,7 +269,7 @@ describe('flushQueue — corrupted item isolation (Fix C)', () => {
269
269
  await writeFile(filePath, '# Valid\n\nContent.');
270
270
 
271
271
  mockFetch.mockRejectedValueOnce(new Error('Network error'));
272
- await syncMarkdownFile('test-project', tempDir, filePath, testCreds);
272
+ await syncMarkdownFile('test-project', tempDir, filePath, testCreds, 'main');
273
273
  expect(getQueueSize()).toBe(1);
274
274
 
275
275
  // Now corrupt the queued item's payload by directly manipulating the queue
@@ -280,7 +280,7 @@ describe('flushQueue — corrupted item isolation (Fix C)', () => {
280
280
  await writeFile(filePath2, '# Valid2\n\nContent2.');
281
281
 
282
282
  mockFetch.mockRejectedValueOnce(new Error('Network error'));
283
- await syncMarkdownFile('test-project', tempDir, filePath2, testCreds);
283
+ await syncMarkdownFile('test-project', tempDir, filePath2, testCreds, 'main');
284
284
  expect(getQueueSize()).toBe(2);
285
285
 
286
286
  // On flush, make first apiRequest call throw unexpectedly (simulating corrupted processing)
@@ -315,7 +315,7 @@ describe('flushQueue — corrupted item isolation (Fix C)', () => {
315
315
 
316
316
  // Queue an item
317
317
  mockFetch.mockRejectedValueOnce(new Error('Network error'));
318
- await syncMarkdownFile('test-project', tempDir, filePath, testCreds);
318
+ await syncMarkdownFile('test-project', tempDir, filePath, testCreds, 'main');
319
319
  expect(getQueueSize()).toBe(1);
320
320
 
321
321
  // On flush, return 400 (permanent failure) — item should be dropped
@@ -361,7 +361,7 @@ describe('regression — syncMarkdownFile still sends Idempotency-Key', () => {
361
361
  json: async () => ({ data: { upserted: 1 } }),
362
362
  });
363
363
 
364
- await syncMarkdownFile('test-project', tempDir, filePath, testCreds);
364
+ await syncMarkdownFile('test-project', tempDir, filePath, testCreds, 'main');
365
365
 
366
366
  const postCall = mockFetch.mock.calls.find(
367
367
  (call: unknown[]) =>