ultraclaude-agent 0.0.20 → 0.0.21
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__/claude-profiles-ops.test.ts +441 -0
- package/__tests__/claude-profiles.test.ts +407 -0
- package/__tests__/credential-watcher.test.ts +229 -0
- package/dist/claude-profiles.d.ts +83 -0
- package/dist/claude-profiles.d.ts.map +1 -0
- package/dist/claude-profiles.js +499 -0
- package/dist/claude-profiles.js.map +1 -0
- package/dist/cli.js +93 -0
- package/dist/cli.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +68 -0
- package/dist/daemon.js.map +1 -1
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +141 -0
- package/dist/repl.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.d.ts +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.d.ts.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/index.js.map +1 -1
- package/node_modules/@ultra-claude/shared/dist/types.d.ts +32 -0
- package/node_modules/@ultra-claude/shared/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/claude-profiles.ts +597 -0
- package/src/cli.ts +117 -0
- package/src/daemon.ts +80 -0
- package/src/repl.ts +164 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Mock node:os for consistent homedir
|
|
5
|
+
vi.mock('node:os', async (importOriginal) => {
|
|
6
|
+
const actual = await importOriginal<typeof import('node:os')>();
|
|
7
|
+
return {
|
|
8
|
+
...actual,
|
|
9
|
+
homedir: () => '/home/testuser',
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Mock fs/promises
|
|
14
|
+
const mockReadFile = vi.fn();
|
|
15
|
+
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
|
|
16
|
+
const mockRename = vi.fn().mockResolvedValue(undefined);
|
|
17
|
+
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
const mockUnlink = vi.fn().mockResolvedValue(undefined);
|
|
19
|
+
const mockReaddir = vi.fn();
|
|
20
|
+
const mockStat = vi.fn();
|
|
21
|
+
|
|
22
|
+
vi.mock('node:fs/promises', () => ({
|
|
23
|
+
readFile: (...args: unknown[]) => mockReadFile(...args),
|
|
24
|
+
writeFile: (...args: unknown[]) => mockWriteFile(...args),
|
|
25
|
+
rename: (...args: unknown[]) => mockRename(...args),
|
|
26
|
+
mkdir: (...args: unknown[]) => mockMkdir(...args),
|
|
27
|
+
unlink: (...args: unknown[]) => mockUnlink(...args),
|
|
28
|
+
readdir: (...args: unknown[]) => mockReaddir(...args),
|
|
29
|
+
stat: (...args: unknown[]) => mockStat(...args),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// Mock child_process
|
|
33
|
+
const mockExecFileSync = vi.fn();
|
|
34
|
+
const mockSpawn = vi.fn();
|
|
35
|
+
|
|
36
|
+
vi.mock('node:child_process', () => ({
|
|
37
|
+
execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
|
|
38
|
+
spawn: (...args: unknown[]) => mockSpawn(...args),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Mock logger
|
|
42
|
+
vi.mock('../src/logger.js', () => ({
|
|
43
|
+
logger: {
|
|
44
|
+
child: () => ({
|
|
45
|
+
info: vi.fn(),
|
|
46
|
+
warn: vi.fn(),
|
|
47
|
+
error: vi.fn(),
|
|
48
|
+
debug: vi.fn(),
|
|
49
|
+
}),
|
|
50
|
+
info: vi.fn(),
|
|
51
|
+
warn: vi.fn(),
|
|
52
|
+
error: vi.fn(),
|
|
53
|
+
debug: vi.fn(),
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const PROFILES_DIR = '/home/testuser/.claude/ultra/claude-profiles';
|
|
58
|
+
const BACKUPS_DIR = join(PROFILES_DIR, 'backups');
|
|
59
|
+
|
|
60
|
+
const sampleProfile = {
|
|
61
|
+
name: 'work',
|
|
62
|
+
email: 'user@work.com',
|
|
63
|
+
orgName: 'WorkOrg',
|
|
64
|
+
subscriptionType: 'team',
|
|
65
|
+
savedAt: '2026-04-11T18:05:36Z',
|
|
66
|
+
credentials: {
|
|
67
|
+
claudeAiOauth: {
|
|
68
|
+
accessToken: 'sk-ant-oat01-test',
|
|
69
|
+
refreshToken: 'sk-ant-ort01-test',
|
|
70
|
+
expiresAt: Date.now() + 3600_000, // 1 hour from now
|
|
71
|
+
scopes: ['user:profile', 'user:inference'],
|
|
72
|
+
subscriptionType: 'team',
|
|
73
|
+
rateLimitTier: 'default_claude_max_5x',
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
describe('claude-profiles', () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.resetAllMocks();
|
|
81
|
+
// Default: claude binary exists
|
|
82
|
+
mockExecFileSync.mockReturnValue('/usr/bin/claude');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('loadProfile', () => {
|
|
86
|
+
it('returns profile when file exists', async () => {
|
|
87
|
+
mockReadFile.mockResolvedValue(JSON.stringify(sampleProfile));
|
|
88
|
+
const { loadProfile } = await import('../src/claude-profiles.js');
|
|
89
|
+
const profile = await loadProfile('work');
|
|
90
|
+
expect(profile).toEqual(sampleProfile);
|
|
91
|
+
expect(mockReadFile).toHaveBeenCalledWith(
|
|
92
|
+
join(PROFILES_DIR, 'work.json'),
|
|
93
|
+
'utf8',
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns null when file does not exist', async () => {
|
|
98
|
+
mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
99
|
+
const { loadProfile } = await import('../src/claude-profiles.js');
|
|
100
|
+
const profile = await loadProfile('nonexistent');
|
|
101
|
+
expect(profile).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('saveProfile', () => {
|
|
106
|
+
it('writes profile with atomic temp+rename pattern', async () => {
|
|
107
|
+
const { saveProfile } = await import('../src/claude-profiles.js');
|
|
108
|
+
await saveProfile(sampleProfile);
|
|
109
|
+
|
|
110
|
+
// Should mkdir for profiles dir
|
|
111
|
+
expect(mockMkdir).toHaveBeenCalled();
|
|
112
|
+
|
|
113
|
+
// Should write to .tmp file with mode 0o600
|
|
114
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
115
|
+
expect.stringMatching(/\.tmp$/),
|
|
116
|
+
expect.stringContaining('"name": "work"'),
|
|
117
|
+
expect.objectContaining({ mode: 0o600 }),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Should rename .tmp to final path
|
|
121
|
+
expect(mockRename).toHaveBeenCalledWith(
|
|
122
|
+
expect.stringMatching(/\.tmp$/),
|
|
123
|
+
join(PROFILES_DIR, 'work.json'),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('deleteProfileFile', () => {
|
|
129
|
+
it('deletes profile file', async () => {
|
|
130
|
+
const { deleteProfileFile } = await import('../src/claude-profiles.js');
|
|
131
|
+
const result = await deleteProfileFile('work');
|
|
132
|
+
expect(result).toBe(true);
|
|
133
|
+
expect(mockUnlink).toHaveBeenCalledWith(join(PROFILES_DIR, 'work.json'));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns false when file does not exist', async () => {
|
|
137
|
+
mockUnlink.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
138
|
+
const { deleteProfileFile } = await import('../src/claude-profiles.js');
|
|
139
|
+
const result = await deleteProfileFile('nonexistent');
|
|
140
|
+
expect(result).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('listProfiles', () => {
|
|
145
|
+
it('returns all profiles sorted by name', async () => {
|
|
146
|
+
const personalProfile = { ...sampleProfile, name: 'personal', email: 'me@home.com' };
|
|
147
|
+
mockReaddir.mockResolvedValue(['work.json', 'personal.json', 'active.json']);
|
|
148
|
+
mockReadFile
|
|
149
|
+
.mockResolvedValueOnce(JSON.stringify(sampleProfile))
|
|
150
|
+
.mockResolvedValueOnce(JSON.stringify(personalProfile));
|
|
151
|
+
|
|
152
|
+
const { listProfiles } = await import('../src/claude-profiles.js');
|
|
153
|
+
const profiles = await listProfiles();
|
|
154
|
+
|
|
155
|
+
expect(profiles).toHaveLength(2);
|
|
156
|
+
expect(profiles[0]!.name).toBe('personal');
|
|
157
|
+
expect(profiles[1]!.name).toBe('work');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('skips active.json', async () => {
|
|
161
|
+
mockReaddir.mockResolvedValue(['active.json']);
|
|
162
|
+
const { listProfiles } = await import('../src/claude-profiles.js');
|
|
163
|
+
const profiles = await listProfiles();
|
|
164
|
+
expect(profiles).toHaveLength(0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('returns empty array when directory does not exist', async () => {
|
|
168
|
+
mockReaddir.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
169
|
+
const { listProfiles } = await import('../src/claude-profiles.js');
|
|
170
|
+
const profiles = await listProfiles();
|
|
171
|
+
expect(profiles).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('active profile tracking', () => {
|
|
176
|
+
it('loads active profile', async () => {
|
|
177
|
+
const active = { profile: 'work', switchedAt: '2026-04-11T18:05:36Z' };
|
|
178
|
+
mockReadFile.mockResolvedValue(JSON.stringify(active));
|
|
179
|
+
const { loadActiveProfile } = await import('../src/claude-profiles.js');
|
|
180
|
+
const result = await loadActiveProfile();
|
|
181
|
+
expect(result).toEqual(active);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('sets active profile with atomic write', async () => {
|
|
185
|
+
const { setActiveProfile } = await import('../src/claude-profiles.js');
|
|
186
|
+
await setActiveProfile('work');
|
|
187
|
+
|
|
188
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
189
|
+
expect.stringMatching(/\.tmp$/),
|
|
190
|
+
expect.stringContaining('"profile": "work"'),
|
|
191
|
+
expect.objectContaining({ mode: 0o600 }),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('getExpiryStatus', () => {
|
|
197
|
+
it('returns valid for tokens expiring in >2 hours', async () => {
|
|
198
|
+
const { getExpiryStatus } = await import('../src/claude-profiles.js');
|
|
199
|
+
const profile = {
|
|
200
|
+
...sampleProfile,
|
|
201
|
+
credentials: {
|
|
202
|
+
claudeAiOauth: { ...sampleProfile.credentials.claudeAiOauth, expiresAt: Date.now() + 5 * 3600_000 },
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const status = getExpiryStatus(profile);
|
|
206
|
+
expect(status.status).toBe('valid');
|
|
207
|
+
expect(status.label).toContain('\u2713');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns expiring for tokens expiring in <2 hours', async () => {
|
|
211
|
+
const { getExpiryStatus } = await import('../src/claude-profiles.js');
|
|
212
|
+
const profile = {
|
|
213
|
+
...sampleProfile,
|
|
214
|
+
credentials: {
|
|
215
|
+
claudeAiOauth: { ...sampleProfile.credentials.claudeAiOauth, expiresAt: Date.now() + 30 * 60_000 },
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
const status = getExpiryStatus(profile);
|
|
219
|
+
expect(status.status).toBe('expiring');
|
|
220
|
+
expect(status.label).toContain('\u26a0');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('returns expired for past timestamps', async () => {
|
|
224
|
+
const { getExpiryStatus } = await import('../src/claude-profiles.js');
|
|
225
|
+
const profile = {
|
|
226
|
+
...sampleProfile,
|
|
227
|
+
credentials: {
|
|
228
|
+
claudeAiOauth: { ...sampleProfile.credentials.claudeAiOauth, expiresAt: Date.now() - 60_000 },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const status = getExpiryStatus(profile);
|
|
232
|
+
expect(status.status).toBe('expired');
|
|
233
|
+
expect(status.label).toContain('\u2717');
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('pruneBackups', () => {
|
|
238
|
+
it('removes backups older than maxAgeDays', async () => {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
const oldTime = now - 8 * 24 * 3600_000; // 8 days ago
|
|
241
|
+
const recentTime = now - 1 * 24 * 3600_000; // 1 day ago
|
|
242
|
+
|
|
243
|
+
mockReaddir.mockResolvedValue(['old.json', 'recent.json']);
|
|
244
|
+
mockStat
|
|
245
|
+
.mockResolvedValueOnce({ mtimeMs: oldTime })
|
|
246
|
+
.mockResolvedValueOnce({ mtimeMs: recentTime });
|
|
247
|
+
|
|
248
|
+
const { pruneBackups } = await import('../src/claude-profiles.js');
|
|
249
|
+
const pruned = await pruneBackups(7);
|
|
250
|
+
|
|
251
|
+
expect(pruned).toBe(1);
|
|
252
|
+
expect(mockUnlink).toHaveBeenCalledWith(join(BACKUPS_DIR, 'old.json'));
|
|
253
|
+
expect(mockUnlink).not.toHaveBeenCalledWith(join(BACKUPS_DIR, 'recent.json'));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('returns 0 when no old backups exist', async () => {
|
|
257
|
+
mockReaddir.mockResolvedValue(['recent.json']);
|
|
258
|
+
mockStat.mockResolvedValue({ mtimeMs: Date.now() });
|
|
259
|
+
|
|
260
|
+
const { pruneBackups } = await import('../src/claude-profiles.js');
|
|
261
|
+
const pruned = await pruneBackups(7);
|
|
262
|
+
expect(pruned).toBe(0);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe('createBackup', () => {
|
|
267
|
+
it('writes backup with timestamp filename', async () => {
|
|
268
|
+
const { createBackup } = await import('../src/claude-profiles.js');
|
|
269
|
+
const creds = sampleProfile.credentials;
|
|
270
|
+
const path = await createBackup(creds);
|
|
271
|
+
|
|
272
|
+
expect(path).toMatch(/backups\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z\.json$/);
|
|
273
|
+
expect(mockMkdir).toHaveBeenCalledWith(
|
|
274
|
+
BACKUPS_DIR,
|
|
275
|
+
expect.objectContaining({ mode: 0o700 }),
|
|
276
|
+
);
|
|
277
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
278
|
+
expect.stringContaining('backups/'),
|
|
279
|
+
expect.any(String),
|
|
280
|
+
expect.objectContaining({ mode: 0o600 }),
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe('self-trigger flag', () => {
|
|
286
|
+
it('starts as false', async () => {
|
|
287
|
+
const { isSelfTriggered, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
288
|
+
clearSelfTriggerFlag(); // reset state
|
|
289
|
+
expect(isSelfTriggered()).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('setSelfTriggerFlag sets to true', async () => {
|
|
293
|
+
const { setSelfTriggerFlag, isSelfTriggered, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
294
|
+
clearSelfTriggerFlag();
|
|
295
|
+
setSelfTriggerFlag();
|
|
296
|
+
expect(isSelfTriggered()).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('clearSelfTriggerFlag resets to false', async () => {
|
|
300
|
+
const { setSelfTriggerFlag, clearSelfTriggerFlag, isSelfTriggered } = await import('../src/claude-profiles.js');
|
|
301
|
+
setSelfTriggerFlag();
|
|
302
|
+
clearSelfTriggerFlag();
|
|
303
|
+
expect(isSelfTriggered()).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('writeCredentialsFile', () => {
|
|
308
|
+
it('performs atomic write without touching self-trigger flag', async () => {
|
|
309
|
+
const { writeCredentialsFile, isSelfTriggered, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
310
|
+
clearSelfTriggerFlag();
|
|
311
|
+
|
|
312
|
+
await writeCredentialsFile(sampleProfile.credentials);
|
|
313
|
+
|
|
314
|
+
// Flag should NOT be set — writeCredentialsFile is a pure write
|
|
315
|
+
expect(isSelfTriggered()).toBe(false);
|
|
316
|
+
|
|
317
|
+
// Should use atomic write pattern
|
|
318
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
319
|
+
expect.stringMatching(/\.tmp$/),
|
|
320
|
+
expect.any(String),
|
|
321
|
+
expect.objectContaining({ mode: 0o600 }),
|
|
322
|
+
);
|
|
323
|
+
expect(mockRename).toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('runClaudeAuthStatus', () => {
|
|
328
|
+
it('returns null when claude binary not found', async () => {
|
|
329
|
+
mockExecFileSync.mockImplementation(() => {
|
|
330
|
+
throw new Error('not found');
|
|
331
|
+
});
|
|
332
|
+
const { runClaudeAuthStatus } = await import('../src/claude-profiles.js');
|
|
333
|
+
const result = await runClaudeAuthStatus();
|
|
334
|
+
expect(result).toBeNull();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('parses JSON output from claude auth status', async () => {
|
|
338
|
+
// Mock spawn to emit JSON
|
|
339
|
+
const mockChild = {
|
|
340
|
+
stdout: { on: vi.fn() },
|
|
341
|
+
stderr: { on: vi.fn() },
|
|
342
|
+
on: vi.fn(),
|
|
343
|
+
kill: vi.fn(),
|
|
344
|
+
};
|
|
345
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
346
|
+
|
|
347
|
+
const { runClaudeAuthStatus } = await import('../src/claude-profiles.js');
|
|
348
|
+
const promise = runClaudeAuthStatus();
|
|
349
|
+
|
|
350
|
+
// Simulate stdout data
|
|
351
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
|
352
|
+
(c: unknown[]) => c[0] === 'data',
|
|
353
|
+
)![1] as (data: Buffer) => void;
|
|
354
|
+
stdoutHandler(Buffer.from(JSON.stringify({
|
|
355
|
+
email: 'user@work.com',
|
|
356
|
+
orgName: 'WorkOrg',
|
|
357
|
+
subscriptionType: 'team',
|
|
358
|
+
loggedIn: true,
|
|
359
|
+
})));
|
|
360
|
+
|
|
361
|
+
// Simulate close
|
|
362
|
+
const closeHandler = mockChild.on.mock.calls.find(
|
|
363
|
+
(c: unknown[]) => c[0] === 'close',
|
|
364
|
+
)![1] as (code: number) => void;
|
|
365
|
+
closeHandler(0);
|
|
366
|
+
|
|
367
|
+
const result = await promise;
|
|
368
|
+
expect(result).toEqual({
|
|
369
|
+
email: 'user@work.com',
|
|
370
|
+
orgName: 'WorkOrg',
|
|
371
|
+
subscriptionType: 'team',
|
|
372
|
+
loggedIn: true,
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('parses text output as fallback', async () => {
|
|
377
|
+
const mockChild = {
|
|
378
|
+
stdout: { on: vi.fn() },
|
|
379
|
+
stderr: { on: vi.fn() },
|
|
380
|
+
on: vi.fn(),
|
|
381
|
+
kill: vi.fn(),
|
|
382
|
+
};
|
|
383
|
+
mockSpawn.mockReturnValue(mockChild);
|
|
384
|
+
|
|
385
|
+
const { runClaudeAuthStatus } = await import('../src/claude-profiles.js');
|
|
386
|
+
const promise = runClaudeAuthStatus();
|
|
387
|
+
|
|
388
|
+
const stdoutHandler = mockChild.stdout.on.mock.calls.find(
|
|
389
|
+
(c: unknown[]) => c[0] === 'data',
|
|
390
|
+
)![1] as (data: Buffer) => void;
|
|
391
|
+
stdoutHandler(Buffer.from('Logged in as: user@work.com\nOrg: WorkOrg\nSubscription: team\n'));
|
|
392
|
+
|
|
393
|
+
const closeHandler = mockChild.on.mock.calls.find(
|
|
394
|
+
(c: unknown[]) => c[0] === 'close',
|
|
395
|
+
)![1] as (code: number) => void;
|
|
396
|
+
closeHandler(0);
|
|
397
|
+
|
|
398
|
+
const result = await promise;
|
|
399
|
+
expect(result).toEqual({
|
|
400
|
+
email: 'user@work.com',
|
|
401
|
+
orgName: 'WorkOrg',
|
|
402
|
+
subscriptionType: 'team',
|
|
403
|
+
loggedIn: true,
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
});
|
|
407
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Mock node:os for consistent homedir
|
|
4
|
+
vi.mock('node:os', async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import('node:os')>();
|
|
6
|
+
return {
|
|
7
|
+
...actual,
|
|
8
|
+
homedir: () => '/home/testuser',
|
|
9
|
+
};
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Mock fs/promises
|
|
13
|
+
const mockReadFile = vi.fn();
|
|
14
|
+
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
|
|
15
|
+
const mockRename = vi.fn().mockResolvedValue(undefined);
|
|
16
|
+
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
|
17
|
+
const mockUnlink = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
const mockReaddir = vi.fn();
|
|
19
|
+
const mockStat = vi.fn();
|
|
20
|
+
|
|
21
|
+
vi.mock('node:fs/promises', () => ({
|
|
22
|
+
readFile: (...args: unknown[]) => mockReadFile(...args),
|
|
23
|
+
writeFile: (...args: unknown[]) => mockWriteFile(...args),
|
|
24
|
+
rename: (...args: unknown[]) => mockRename(...args),
|
|
25
|
+
mkdir: (...args: unknown[]) => mockMkdir(...args),
|
|
26
|
+
unlink: (...args: unknown[]) => mockUnlink(...args),
|
|
27
|
+
readdir: (...args: unknown[]) => mockReaddir(...args),
|
|
28
|
+
stat: (...args: unknown[]) => mockStat(...args),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
// Mock child_process with callback-storage spawn mock
|
|
32
|
+
const mockExecFileSync = vi.fn().mockReturnValue('/usr/bin/claude');
|
|
33
|
+
|
|
34
|
+
type Handler = (...args: unknown[]) => void;
|
|
35
|
+
|
|
36
|
+
function createMockChild(output: string, exitCode = 0) {
|
|
37
|
+
const stdoutHandlers: Record<string, Handler[]> = {};
|
|
38
|
+
const stderrHandlers: Record<string, Handler[]> = {};
|
|
39
|
+
const childHandlers: Record<string, Handler[]> = {};
|
|
40
|
+
|
|
41
|
+
const mockStdout = {
|
|
42
|
+
on(event: string, handler: Handler) {
|
|
43
|
+
(stdoutHandlers[event] ??= []).push(handler);
|
|
44
|
+
return mockStdout;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const mockStderr = {
|
|
48
|
+
on(event: string, handler: Handler) {
|
|
49
|
+
(stderrHandlers[event] ??= []).push(handler);
|
|
50
|
+
return mockStderr;
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const child = {
|
|
54
|
+
stdout: mockStdout,
|
|
55
|
+
stderr: mockStderr,
|
|
56
|
+
kill: vi.fn(),
|
|
57
|
+
on(event: string, handler: Handler) {
|
|
58
|
+
(childHandlers[event] ??= []).push(handler);
|
|
59
|
+
// Auto-fire data + close after handlers are registered
|
|
60
|
+
// Use setTimeout to ensure all handlers are registered first
|
|
61
|
+
if (event === 'close') {
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
for (const h of stdoutHandlers['data'] ?? []) h(Buffer.from(output));
|
|
64
|
+
for (const h of childHandlers['close'] ?? []) h(exitCode);
|
|
65
|
+
}, 0);
|
|
66
|
+
}
|
|
67
|
+
return child;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return child;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const mockSpawn = vi.fn();
|
|
75
|
+
|
|
76
|
+
vi.mock('node:child_process', () => ({
|
|
77
|
+
execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
|
|
78
|
+
spawn: (...args: unknown[]) => mockSpawn(...args),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
// Mock logger
|
|
82
|
+
vi.mock('../src/logger.js', () => ({
|
|
83
|
+
logger: {
|
|
84
|
+
child: () => ({
|
|
85
|
+
info: vi.fn(),
|
|
86
|
+
warn: vi.fn(),
|
|
87
|
+
error: vi.fn(),
|
|
88
|
+
debug: vi.fn(),
|
|
89
|
+
}),
|
|
90
|
+
info: vi.fn(),
|
|
91
|
+
warn: vi.fn(),
|
|
92
|
+
error: vi.fn(),
|
|
93
|
+
debug: vi.fn(),
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
const sampleCredentials = {
|
|
98
|
+
claudeAiOauth: {
|
|
99
|
+
accessToken: 'sk-ant-oat01-test',
|
|
100
|
+
refreshToken: 'sk-ant-ort01-test',
|
|
101
|
+
expiresAt: Date.now() + 3600_000,
|
|
102
|
+
scopes: ['user:profile', 'user:inference'],
|
|
103
|
+
subscriptionType: 'team',
|
|
104
|
+
rateLimitTier: 'default_claude_max_5x',
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const sampleProfile = {
|
|
109
|
+
name: 'work',
|
|
110
|
+
email: 'user@work.com',
|
|
111
|
+
orgName: 'WorkOrg',
|
|
112
|
+
subscriptionType: 'team',
|
|
113
|
+
savedAt: '2026-04-11T18:05:36Z',
|
|
114
|
+
credentials: sampleCredentials,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
describe('handleCredentialChange', () => {
|
|
118
|
+
beforeEach(() => {
|
|
119
|
+
vi.resetAllMocks();
|
|
120
|
+
mockExecFileSync.mockReturnValue('/usr/bin/claude');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('skips processing when self-triggered', async () => {
|
|
124
|
+
const {
|
|
125
|
+
setSelfTriggerFlag,
|
|
126
|
+
isSelfTriggered,
|
|
127
|
+
handleCredentialChange,
|
|
128
|
+
} = await import('../src/claude-profiles.js');
|
|
129
|
+
|
|
130
|
+
setSelfTriggerFlag();
|
|
131
|
+
await handleCredentialChange();
|
|
132
|
+
|
|
133
|
+
// Self-trigger flag should be cleared
|
|
134
|
+
expect(isSelfTriggered()).toBe(false);
|
|
135
|
+
|
|
136
|
+
// Should NOT have read credentials or spawned auth status
|
|
137
|
+
expect(mockReadFile).not.toHaveBeenCalled();
|
|
138
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('creates backup on external change', async () => {
|
|
142
|
+
const { handleCredentialChange, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
143
|
+
clearSelfTriggerFlag();
|
|
144
|
+
|
|
145
|
+
mockReadFile.mockResolvedValue(JSON.stringify(sampleCredentials));
|
|
146
|
+
|
|
147
|
+
mockSpawn.mockReturnValue(createMockChild(JSON.stringify({
|
|
148
|
+
email: 'unknown@other.com',
|
|
149
|
+
orgName: 'OtherOrg',
|
|
150
|
+
subscriptionType: 'free',
|
|
151
|
+
loggedIn: true,
|
|
152
|
+
})));
|
|
153
|
+
|
|
154
|
+
// No saved profiles
|
|
155
|
+
mockReaddir.mockResolvedValue([]);
|
|
156
|
+
|
|
157
|
+
await handleCredentialChange();
|
|
158
|
+
|
|
159
|
+
// Should have written a backup file
|
|
160
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
161
|
+
expect.stringContaining('backups/'),
|
|
162
|
+
expect.stringContaining('claudeAiOauth'),
|
|
163
|
+
expect.objectContaining({ mode: 0o600 }),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('updates matching profile when email matches', async () => {
|
|
168
|
+
const { handleCredentialChange, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
169
|
+
clearSelfTriggerFlag();
|
|
170
|
+
|
|
171
|
+
// readFile calls: readCredentialsFile, then listProfiles reads profile
|
|
172
|
+
let readFileCallCount = 0;
|
|
173
|
+
mockReadFile.mockImplementation(() => {
|
|
174
|
+
readFileCallCount++;
|
|
175
|
+
if (readFileCallCount <= 1) {
|
|
176
|
+
return Promise.resolve(JSON.stringify(sampleCredentials));
|
|
177
|
+
}
|
|
178
|
+
return Promise.resolve(JSON.stringify(sampleProfile));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
mockSpawn.mockReturnValue(createMockChild(JSON.stringify({
|
|
182
|
+
email: 'user@work.com',
|
|
183
|
+
orgName: 'WorkOrg',
|
|
184
|
+
subscriptionType: 'team',
|
|
185
|
+
loggedIn: true,
|
|
186
|
+
})));
|
|
187
|
+
|
|
188
|
+
mockReaddir.mockResolvedValue(['work.json']);
|
|
189
|
+
|
|
190
|
+
await handleCredentialChange();
|
|
191
|
+
|
|
192
|
+
// Should have written: backup + updated profile (2 writeFile calls)
|
|
193
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(2);
|
|
194
|
+
|
|
195
|
+
// Second write should be the profile update (tmp file for atomic write)
|
|
196
|
+
const secondWrite = mockWriteFile.mock.calls[1]!;
|
|
197
|
+
expect(secondWrite[0]).toMatch(/\.tmp$/);
|
|
198
|
+
const writtenProfile = JSON.parse(secondWrite[1] as string);
|
|
199
|
+
expect(writtenProfile.name).toBe('work');
|
|
200
|
+
expect(writtenProfile.email).toBe('user@work.com');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('logs suggestion when email matches no profile', async () => {
|
|
204
|
+
const { handleCredentialChange, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
205
|
+
clearSelfTriggerFlag();
|
|
206
|
+
|
|
207
|
+
mockReadFile.mockResolvedValue(JSON.stringify(sampleCredentials));
|
|
208
|
+
|
|
209
|
+
mockSpawn.mockReturnValue(createMockChild(JSON.stringify({
|
|
210
|
+
email: 'unknown@other.com',
|
|
211
|
+
orgName: 'OtherOrg',
|
|
212
|
+
subscriptionType: 'free',
|
|
213
|
+
loggedIn: true,
|
|
214
|
+
})));
|
|
215
|
+
|
|
216
|
+
// No profiles
|
|
217
|
+
mockReaddir.mockResolvedValue([]);
|
|
218
|
+
|
|
219
|
+
await handleCredentialChange();
|
|
220
|
+
|
|
221
|
+
// Should have written backup only (1 writeFile call)
|
|
222
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
224
|
+
expect.stringContaining('backups/'),
|
|
225
|
+
expect.any(String),
|
|
226
|
+
expect.any(Object),
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|