ultraclaude-agent 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__tests__/config-windows.test.ts +93 -0
- package/__tests__/daemon.test.ts +166 -0
- package/__tests__/service-windows.test.ts +123 -0
- package/__tests__/sync-bugs.test.ts +246 -0
- package/__tests__/sync.test.ts +169 -0
- package/__tests__/usage-sync.test.ts +291 -0
- package/__tests__/version-check.test.ts +128 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +105 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +196 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +181 -0
- package/dist/config.js.map +1 -0
- package/dist/daemon.d.ts +27 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +214 -0
- package/dist/daemon.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +3 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +37 -0
- package/dist/logger.js.map +1 -0
- package/dist/service.d.ts +4 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +223 -0
- package/dist/service.js.map +1 -0
- package/dist/sync.d.ts +25 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +344 -0
- package/dist/sync.js.map +1 -0
- package/dist/usage-sync.d.ts +7 -0
- package/dist/usage-sync.d.ts.map +1 -0
- package/dist/usage-sync.js +208 -0
- package/dist/usage-sync.js.map +1 -0
- package/dist/watcher.d.ts +16 -0
- package/dist/watcher.d.ts.map +1 -0
- package/dist/watcher.js +90 -0
- package/dist/watcher.js.map +1 -0
- package/package.json +31 -0
- package/run.sh +28 -0
- package/src/auth.ts +127 -0
- package/src/cli.ts +235 -0
- package/src/config.ts +207 -0
- package/src/daemon.ts +264 -0
- package/src/index.ts +7 -0
- package/src/logger.ts +42 -0
- package/src/service.ts +237 -0
- package/src/sync.ts +473 -0
- package/src/usage-sync.ts +275 -0
- package/src/watcher.ts +106 -0
- package/tsconfig.build.json +6 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
getProjectId: vi.fn().mockResolvedValue('test-project-id'),
|
|
19
|
+
writeProjectId: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
paths: {
|
|
21
|
+
configDir: '/tmp/test-config',
|
|
22
|
+
credentials: '/tmp/test-config/credentials.json',
|
|
23
|
+
pid: '/tmp/test-config/daemon.pid',
|
|
24
|
+
logDir: '/tmp/test-config/logs',
|
|
25
|
+
registry: '/tmp/test-registry.json',
|
|
26
|
+
projectIdFile: '.claude/ultra/project-id',
|
|
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
|
+
describe('sync — syncMarkdownFile', () => {
|
|
47
|
+
let tempDir: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(async () => {
|
|
50
|
+
mockFetch.mockReset();
|
|
51
|
+
tempDir = await mkdtemp(join(tmpdir(), 'agent-sync-test-'));
|
|
52
|
+
await mkdir(join(tempDir, 'documentation'), { recursive: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('pushes changed sections to the server', async () => {
|
|
60
|
+
const { syncMarkdownFile } = await import('../src/sync.js');
|
|
61
|
+
|
|
62
|
+
const mdContent = '# Title\n\nSome content.\n\n## Section\n\nMore content.';
|
|
63
|
+
const filePath = join(tempDir, 'documentation', 'test.md');
|
|
64
|
+
await writeFile(filePath, mdContent);
|
|
65
|
+
|
|
66
|
+
mockFetch.mockResolvedValueOnce({
|
|
67
|
+
ok: true,
|
|
68
|
+
json: async () => ({ data: [] }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// First push — all sections are new
|
|
72
|
+
mockFetch.mockResolvedValueOnce({
|
|
73
|
+
ok: true,
|
|
74
|
+
json: async () => ({ data: { upserted: 2 } }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const count = await syncMarkdownFile('test-project-id', tempDir, filePath);
|
|
78
|
+
expect(count).toBe(2);
|
|
79
|
+
|
|
80
|
+
// Verify the POST request
|
|
81
|
+
const postCall = mockFetch.mock.calls.find(
|
|
82
|
+
(call: unknown[]) =>
|
|
83
|
+
(call[1] as { method: string }).method === 'POST' &&
|
|
84
|
+
(call[0] as string).includes('/api/sync/sections'),
|
|
85
|
+
);
|
|
86
|
+
expect(postCall).toBeDefined();
|
|
87
|
+
|
|
88
|
+
const body = JSON.parse((postCall![1] as { body: string }).body);
|
|
89
|
+
expect(body.file).toBe('test.md');
|
|
90
|
+
expect(body.sections).toHaveLength(2);
|
|
91
|
+
expect(body.sections[0].slug).toBe('title');
|
|
92
|
+
expect(body.sections[1].slug).toBe('title/section');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('queues changes when server is unreachable', async () => {
|
|
96
|
+
// Re-import to get fresh module state
|
|
97
|
+
const sync = await import('../src/sync.js');
|
|
98
|
+
|
|
99
|
+
const mdContent = '# Title\n\nContent.';
|
|
100
|
+
const filePath = join(tempDir, 'documentation', 'test2.md');
|
|
101
|
+
await writeFile(filePath, mdContent);
|
|
102
|
+
|
|
103
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
104
|
+
|
|
105
|
+
const count = await sync.syncMarkdownFile('test-project-id', tempDir, filePath);
|
|
106
|
+
expect(count).toBe(0); // Nothing pushed
|
|
107
|
+
expect(sync.getQueueSize()).toBeGreaterThan(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('sync — syncJsonFile', () => {
|
|
112
|
+
let tempDir: string;
|
|
113
|
+
|
|
114
|
+
beforeEach(async () => {
|
|
115
|
+
mockFetch.mockReset();
|
|
116
|
+
tempDir = await mkdtemp(join(tmpdir(), 'agent-sync-json-test-'));
|
|
117
|
+
await mkdir(join(tempDir, 'documentation'), { recursive: true });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterEach(async () => {
|
|
121
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('pushes JSON files with full content', async () => {
|
|
125
|
+
const { syncJsonFile } = await import('../src/sync.js');
|
|
126
|
+
|
|
127
|
+
const jsonContent = JSON.stringify({ status: 'active', tasks: 5 });
|
|
128
|
+
const filePath = join(tempDir, 'documentation', 'status.json');
|
|
129
|
+
await writeFile(filePath, jsonContent);
|
|
130
|
+
|
|
131
|
+
mockFetch.mockResolvedValueOnce({
|
|
132
|
+
ok: true,
|
|
133
|
+
json: async () => ({ data: { stored: 1 } }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const pushed = await syncJsonFile('test-project-id', tempDir, filePath);
|
|
137
|
+
expect(pushed).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('sync — createProjectOnServer', () => {
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
mockFetch.mockReset();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('creates a project and returns the ID', async () => {
|
|
147
|
+
const { createProjectOnServer } = await import('../src/sync.js');
|
|
148
|
+
|
|
149
|
+
mockFetch.mockResolvedValueOnce({
|
|
150
|
+
ok: true,
|
|
151
|
+
json: async () => ({ data: { id: 'new-project-uuid' } }),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = await createProjectOnServer('My Project', 'my-project');
|
|
155
|
+
expect(result).toEqual({ id: 'new-project-uuid' });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns null on server error', async () => {
|
|
159
|
+
const { createProjectOnServer } = await import('../src/sync.js');
|
|
160
|
+
|
|
161
|
+
mockFetch.mockResolvedValueOnce({
|
|
162
|
+
ok: false,
|
|
163
|
+
status: 500,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = await createProjectOnServer('Bad Project', 'bad-project');
|
|
167
|
+
expect(result).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
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
|
+
paths: {
|
|
19
|
+
configDir: '/tmp/test-config',
|
|
20
|
+
credentials: '/tmp/test-config/credentials.json',
|
|
21
|
+
pid: '/tmp/test-config/daemon.pid',
|
|
22
|
+
logDir: '/tmp/test-config/logs',
|
|
23
|
+
claudeProjects: '/tmp/test-projects',
|
|
24
|
+
projectIdFile: '.claude/ultra/project-id',
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Mock logger
|
|
29
|
+
vi.mock('../src/logger.js', () => ({
|
|
30
|
+
logger: {
|
|
31
|
+
child: () => ({
|
|
32
|
+
info: vi.fn(),
|
|
33
|
+
warn: vi.fn(),
|
|
34
|
+
error: vi.fn(),
|
|
35
|
+
debug: vi.fn(),
|
|
36
|
+
}),
|
|
37
|
+
info: vi.fn(),
|
|
38
|
+
warn: vi.fn(),
|
|
39
|
+
error: vi.fn(),
|
|
40
|
+
debug: vi.fn(),
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// We need to mock the paths used by usage-sync.ts to point to our temp dir
|
|
45
|
+
let tempDir: string;
|
|
46
|
+
let usageStatusPath: string;
|
|
47
|
+
let accountsDir: string;
|
|
48
|
+
|
|
49
|
+
// Dynamic mock for the homedir-based paths
|
|
50
|
+
vi.mock('node:os', async (importOriginal) => {
|
|
51
|
+
const original = await importOriginal<typeof import('node:os')>();
|
|
52
|
+
return {
|
|
53
|
+
...original,
|
|
54
|
+
homedir: () => tempDir,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('usage-sync — syncUsageData', () => {
|
|
59
|
+
beforeEach(async () => {
|
|
60
|
+
mockFetch.mockReset();
|
|
61
|
+
tempDir = await mkdtemp(join(tmpdir(), 'usage-sync-test-'));
|
|
62
|
+
usageStatusPath = join(tempDir, '.claude', 'ultra', 'usage-status.json');
|
|
63
|
+
accountsDir = join(tempDir, '.claude', 'ultra', 'accounts');
|
|
64
|
+
await mkdir(join(tempDir, '.claude', 'ultra'), { recursive: true });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
// Reset module state between tests
|
|
69
|
+
vi.resetModules();
|
|
70
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('reads and merges usage-status.json with account registry', async () => {
|
|
74
|
+
// Write usage status
|
|
75
|
+
await writeFile(usageStatusPath, JSON.stringify({
|
|
76
|
+
accounts: {
|
|
77
|
+
'dawid-duniec-at-axb-co': {
|
|
78
|
+
account_id: 'dawid-duniec-at-axb-co',
|
|
79
|
+
rate_limits: {
|
|
80
|
+
five_hour: { used_percentage: 42, resets_at: 1775329200 },
|
|
81
|
+
seven_day: { used_percentage: 18, resets_at: 1775894400 },
|
|
82
|
+
},
|
|
83
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
// Write account registry
|
|
90
|
+
await mkdir(accountsDir, { recursive: true });
|
|
91
|
+
await writeFile(join(accountsDir, 'dawid-duniec-at-axb-co.json'), JSON.stringify({
|
|
92
|
+
account_id: 'dawid-duniec-at-axb-co',
|
|
93
|
+
email: 'dawid.duniec@axb.co',
|
|
94
|
+
orgName: 'AXB',
|
|
95
|
+
subscriptionType: 'team',
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
// Mock successful response
|
|
99
|
+
mockFetch.mockResolvedValueOnce({
|
|
100
|
+
ok: true,
|
|
101
|
+
status: 200,
|
|
102
|
+
json: () => Promise.resolve({ data: { ok: true, upserted: 1 } }),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const { syncUsageData } = await import('../src/usage-sync.js');
|
|
106
|
+
await syncUsageData();
|
|
107
|
+
|
|
108
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
109
|
+
const [url, options] = mockFetch.mock.calls[0]!;
|
|
110
|
+
expect(url).toBe('http://localhost:3000/api/sync/usage');
|
|
111
|
+
expect(options.method).toBe('POST');
|
|
112
|
+
|
|
113
|
+
const body = JSON.parse(options.body);
|
|
114
|
+
expect(body.accounts['dawid-duniec-at-axb-co']).toMatchObject({
|
|
115
|
+
account_id: 'dawid-duniec-at-axb-co',
|
|
116
|
+
email: 'dawid.duniec@axb.co',
|
|
117
|
+
org_name: 'AXB',
|
|
118
|
+
subscription_type: 'team',
|
|
119
|
+
rate_limits: {
|
|
120
|
+
five_hour: { used_percentage: 42, resets_at: 1775329200 },
|
|
121
|
+
seven_day: { used_percentage: 18, resets_at: 1775894400 },
|
|
122
|
+
},
|
|
123
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('handles missing usage-status.json gracefully', async () => {
|
|
128
|
+
// Don't create the file
|
|
129
|
+
const { syncUsageData } = await import('../src/usage-sync.js');
|
|
130
|
+
await syncUsageData();
|
|
131
|
+
|
|
132
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles empty usage-status.json gracefully', async () => {
|
|
136
|
+
await writeFile(usageStatusPath, '');
|
|
137
|
+
|
|
138
|
+
const { syncUsageData } = await import('../src/usage-sync.js');
|
|
139
|
+
await syncUsageData();
|
|
140
|
+
|
|
141
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('handles malformed JSON in usage-status.json gracefully', async () => {
|
|
145
|
+
await writeFile(usageStatusPath, '{not valid json!!!');
|
|
146
|
+
|
|
147
|
+
const { syncUsageData } = await import('../src/usage-sync.js');
|
|
148
|
+
await syncUsageData();
|
|
149
|
+
|
|
150
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('handles missing accounts/ directory — sends data without account details', async () => {
|
|
154
|
+
// Write usage status but NO accounts/ directory
|
|
155
|
+
await writeFile(usageStatusPath, JSON.stringify({
|
|
156
|
+
accounts: {
|
|
157
|
+
'test-user-at-example-com': {
|
|
158
|
+
account_id: 'test-user-at-example-com',
|
|
159
|
+
rate_limits: {
|
|
160
|
+
five_hour: { used_percentage: 80, resets_at: 1775329200 },
|
|
161
|
+
seven_day: { used_percentage: 50, resets_at: 1775894400 },
|
|
162
|
+
},
|
|
163
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
167
|
+
}));
|
|
168
|
+
|
|
169
|
+
mockFetch.mockResolvedValueOnce({
|
|
170
|
+
ok: true,
|
|
171
|
+
status: 200,
|
|
172
|
+
json: () => Promise.resolve({ data: { ok: true, upserted: 1 } }),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const { syncUsageData } = await import('../src/usage-sync.js');
|
|
176
|
+
await syncUsageData();
|
|
177
|
+
|
|
178
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
179
|
+
const body = JSON.parse(mockFetch.mock.calls[0]![1].body);
|
|
180
|
+
const account = body.accounts['test-user-at-example-com'];
|
|
181
|
+
expect(account.account_id).toBe('test-user-at-example-com');
|
|
182
|
+
// No account registry → optional fields should be undefined
|
|
183
|
+
expect(account.email).toBeUndefined();
|
|
184
|
+
expect(account.org_name).toBeUndefined();
|
|
185
|
+
expect(account.subscription_type).toBeUndefined();
|
|
186
|
+
// Rate limits should still be present
|
|
187
|
+
expect(account.rate_limits.five_hour.used_percentage).toBe(80);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('handles missing individual account file — other accounts still sent', async () => {
|
|
191
|
+
await writeFile(usageStatusPath, JSON.stringify({
|
|
192
|
+
accounts: {
|
|
193
|
+
'user-a-at-example-com': {
|
|
194
|
+
account_id: 'user-a-at-example-com',
|
|
195
|
+
rate_limits: {
|
|
196
|
+
five_hour: { used_percentage: 10, resets_at: 1775329200 },
|
|
197
|
+
seven_day: { used_percentage: 5, resets_at: 1775894400 },
|
|
198
|
+
},
|
|
199
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
200
|
+
},
|
|
201
|
+
'user-b-at-example-com': {
|
|
202
|
+
account_id: 'user-b-at-example-com',
|
|
203
|
+
rate_limits: {
|
|
204
|
+
five_hour: { used_percentage: 90, resets_at: 1775329200 },
|
|
205
|
+
seven_day: { used_percentage: 70, resets_at: 1775894400 },
|
|
206
|
+
},
|
|
207
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
// Only create account file for user-a
|
|
214
|
+
await mkdir(accountsDir, { recursive: true });
|
|
215
|
+
await writeFile(join(accountsDir, 'user-a-at-example-com.json'), JSON.stringify({
|
|
216
|
+
account_id: 'user-a-at-example-com',
|
|
217
|
+
email: 'user-a@example.com',
|
|
218
|
+
orgName: 'OrgA',
|
|
219
|
+
subscriptionType: 'pro',
|
|
220
|
+
}));
|
|
221
|
+
|
|
222
|
+
mockFetch.mockResolvedValueOnce({
|
|
223
|
+
ok: true,
|
|
224
|
+
status: 200,
|
|
225
|
+
json: () => Promise.resolve({ data: { ok: true, upserted: 2 } }),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const { syncUsageData } = await import('../src/usage-sync.js');
|
|
229
|
+
await syncUsageData();
|
|
230
|
+
|
|
231
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
232
|
+
const body = JSON.parse(mockFetch.mock.calls[0]![1].body);
|
|
233
|
+
|
|
234
|
+
// user-a has registry data
|
|
235
|
+
expect(body.accounts['user-a-at-example-com'].email).toBe('user-a@example.com');
|
|
236
|
+
expect(body.accounts['user-a-at-example-com'].org_name).toBe('OrgA');
|
|
237
|
+
|
|
238
|
+
// user-b has no registry data but is still included
|
|
239
|
+
expect(body.accounts['user-b-at-example-com'].account_id).toBe('user-b-at-example-com');
|
|
240
|
+
expect(body.accounts['user-b-at-example-com'].email).toBeUndefined();
|
|
241
|
+
expect(body.accounts['user-b-at-example-com'].rate_limits.five_hour.used_percentage).toBe(90);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('stores retry payload on network failure', async () => {
|
|
245
|
+
await writeFile(usageStatusPath, JSON.stringify({
|
|
246
|
+
accounts: {
|
|
247
|
+
'test-at-example-com': {
|
|
248
|
+
account_id: 'test-at-example-com',
|
|
249
|
+
rate_limits: {
|
|
250
|
+
five_hour: { used_percentage: 50, resets_at: 1775329200 },
|
|
251
|
+
seven_day: { used_percentage: 25, resets_at: 1775894400 },
|
|
252
|
+
},
|
|
253
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
// Simulate network failure
|
|
260
|
+
mockFetch.mockRejectedValueOnce(new Error('Network error'));
|
|
261
|
+
|
|
262
|
+
const { syncUsageData } = await import('../src/usage-sync.js');
|
|
263
|
+
await syncUsageData();
|
|
264
|
+
|
|
265
|
+
// fetch was called (and failed)
|
|
266
|
+
expect(mockFetch).toHaveBeenCalledOnce();
|
|
267
|
+
|
|
268
|
+
// On second call with success, retry should work
|
|
269
|
+
mockFetch.mockResolvedValueOnce({
|
|
270
|
+
ok: true,
|
|
271
|
+
status: 200,
|
|
272
|
+
json: () => Promise.resolve({ data: { ok: true, upserted: 1 } }),
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// Call sync again (simulating a new file change that supersedes retry)
|
|
276
|
+
await syncUsageData();
|
|
277
|
+
expect(mockFetch).toHaveBeenCalledTimes(2);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('handles empty accounts object in usage-status.json', async () => {
|
|
281
|
+
await writeFile(usageStatusPath, JSON.stringify({
|
|
282
|
+
accounts: {},
|
|
283
|
+
updated_at: '2026-04-04T16:13:01Z',
|
|
284
|
+
}));
|
|
285
|
+
|
|
286
|
+
const { syncUsageData } = await import('../src/usage-sync.js');
|
|
287
|
+
await syncUsageData();
|
|
288
|
+
|
|
289
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock fetch globally
|
|
4
|
+
const mockFetch = vi.fn();
|
|
5
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
6
|
+
|
|
7
|
+
// Mock node:fs/promises for reading package.json
|
|
8
|
+
const mockReadFile = vi.fn();
|
|
9
|
+
vi.mock('node:fs/promises', () => ({
|
|
10
|
+
readFile: (...args: unknown[]) => mockReadFile(...args),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Capture log calls
|
|
14
|
+
const mockLogInfo = vi.fn();
|
|
15
|
+
vi.mock('../src/logger.js', () => ({
|
|
16
|
+
logger: {
|
|
17
|
+
child: () => ({
|
|
18
|
+
info: mockLogInfo,
|
|
19
|
+
warn: vi.fn(),
|
|
20
|
+
error: vi.fn(),
|
|
21
|
+
debug: vi.fn(),
|
|
22
|
+
}),
|
|
23
|
+
info: vi.fn(),
|
|
24
|
+
warn: vi.fn(),
|
|
25
|
+
error: vi.fn(),
|
|
26
|
+
debug: vi.fn(),
|
|
27
|
+
},
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// Mock other daemon dependencies to avoid side effects
|
|
31
|
+
vi.mock('chokidar', () => ({
|
|
32
|
+
default: { watch: vi.fn().mockReturnValue({ on: vi.fn().mockReturnThis(), close: vi.fn() }) },
|
|
33
|
+
}));
|
|
34
|
+
vi.mock('../src/config.js', () => ({
|
|
35
|
+
paths: { configDir: '/tmp', credentials: '/tmp/creds', pid: '/tmp/pid', logDir: '/tmp/logs', claudeProjects: '/tmp/projects', projectIdFile: '.claude/ultra/project-id' },
|
|
36
|
+
loadCredentials: vi.fn().mockResolvedValue(null),
|
|
37
|
+
loadRegistry: vi.fn().mockResolvedValue({ projects: [] }),
|
|
38
|
+
getProjectId: vi.fn(),
|
|
39
|
+
writeProjectId: vi.fn(),
|
|
40
|
+
writePid: vi.fn(),
|
|
41
|
+
removePid: vi.fn(),
|
|
42
|
+
}));
|
|
43
|
+
vi.mock('../src/watcher.js', () => ({ startProjectWatcher: vi.fn() }));
|
|
44
|
+
vi.mock('../src/sync.js', () => ({ createProjectOnServer: vi.fn(), initialSync: vi.fn(), stopSync: vi.fn() }));
|
|
45
|
+
vi.mock('../src/usage-sync.js', () => ({ startUsageWatcher: vi.fn().mockReturnValue({ close: vi.fn() }) }));
|
|
46
|
+
|
|
47
|
+
describe('checkForUpdate', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ version: '0.0.1' }));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('logs notice when local version is older than npm latest', async () => {
|
|
58
|
+
mockFetch.mockResolvedValue({
|
|
59
|
+
ok: true,
|
|
60
|
+
json: async () => ({ version: '0.1.0' }),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const { checkForUpdate } = await import('../src/daemon.js');
|
|
64
|
+
await checkForUpdate();
|
|
65
|
+
|
|
66
|
+
expect(mockLogInfo).toHaveBeenCalledWith(
|
|
67
|
+
{ current: '0.0.1', latest: '0.1.0' },
|
|
68
|
+
expect.stringContaining('Update available'),
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('does not log when local version equals npm latest', async () => {
|
|
73
|
+
mockFetch.mockResolvedValue({
|
|
74
|
+
ok: true,
|
|
75
|
+
json: async () => ({ version: '0.0.1' }),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
vi.resetModules();
|
|
79
|
+
const { checkForUpdate } = await import('../src/daemon.js');
|
|
80
|
+
await checkForUpdate();
|
|
81
|
+
|
|
82
|
+
expect(mockLogInfo).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('does not log when local version is newer than npm latest', async () => {
|
|
86
|
+
mockFetch.mockResolvedValue({
|
|
87
|
+
ok: true,
|
|
88
|
+
json: async () => ({ version: '0.0.1' }),
|
|
89
|
+
});
|
|
90
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ version: '0.1.0' }));
|
|
91
|
+
|
|
92
|
+
vi.resetModules();
|
|
93
|
+
const { checkForUpdate } = await import('../src/daemon.js');
|
|
94
|
+
await checkForUpdate();
|
|
95
|
+
|
|
96
|
+
expect(mockLogInfo).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('silently handles network errors', async () => {
|
|
100
|
+
mockFetch.mockRejectedValue(new Error('network error'));
|
|
101
|
+
|
|
102
|
+
vi.resetModules();
|
|
103
|
+
const { checkForUpdate } = await import('../src/daemon.js');
|
|
104
|
+
|
|
105
|
+
await expect(checkForUpdate()).resolves.toBeUndefined();
|
|
106
|
+
expect(mockLogInfo).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('silently handles non-ok responses', async () => {
|
|
110
|
+
mockFetch.mockResolvedValue({ ok: false, status: 404 });
|
|
111
|
+
|
|
112
|
+
vi.resetModules();
|
|
113
|
+
const { checkForUpdate } = await import('../src/daemon.js');
|
|
114
|
+
|
|
115
|
+
await expect(checkForUpdate()).resolves.toBeUndefined();
|
|
116
|
+
expect(mockLogInfo).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('silently handles fetch timeout (abort)', async () => {
|
|
120
|
+
mockFetch.mockRejectedValue(new DOMException('Aborted', 'AbortError'));
|
|
121
|
+
|
|
122
|
+
vi.resetModules();
|
|
123
|
+
const { checkForUpdate } = await import('../src/daemon.js');
|
|
124
|
+
|
|
125
|
+
await expect(checkForUpdate()).resolves.toBeUndefined();
|
|
126
|
+
expect(mockLogInfo).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AgentCredentials } from '@ultra-claude/shared';
|
|
2
|
+
/**
|
|
3
|
+
* Run the browser-based login flow.
|
|
4
|
+
* 1. Start a localhost HTTP server on a random port
|
|
5
|
+
* 2. Open browser to the server's daemon-login page with callback URL
|
|
6
|
+
* 3. Wait for the server to redirect back with the API key
|
|
7
|
+
* 4. Save credentials and close the server
|
|
8
|
+
*/
|
|
9
|
+
export declare function login(serverUrl?: string): Promise<AgentCredentials>;
|
|
10
|
+
//# sourceMappingURL=auth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAI7D;;;;;;GAMG;AACH,wBAAsB,KAAK,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA6GzE"}
|