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,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for high-level claude-profiles operations:
|
|
3
|
+
* switchProfile, loginProfile, deleteProfileByName, saveCurrentAsProfile
|
|
4
|
+
*
|
|
5
|
+
* These cover success criteria that are NOT tested in claude-profiles.test.ts:
|
|
6
|
+
* - claude switch <name>: atomic write, active.json update, auth status verify
|
|
7
|
+
* - claude switch with expired token: refreshes and saves updated credentials
|
|
8
|
+
* - claude login <name> --email: pre-populates from existing profile when present
|
|
9
|
+
* - claude delete <name>: warns and clears active.json when deleting active profile
|
|
10
|
+
* - claude save <name>: saves current credentials + auth status metadata
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
// Mock node:os for consistent homedir
|
|
17
|
+
vi.mock('node:os', async (importOriginal) => {
|
|
18
|
+
const actual = await importOriginal<typeof import('node:os')>();
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
homedir: () => '/home/testuser',
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// Mock fs/promises
|
|
26
|
+
const mockReadFile = vi.fn();
|
|
27
|
+
const mockWriteFile = vi.fn().mockResolvedValue(undefined);
|
|
28
|
+
const mockRename = vi.fn().mockResolvedValue(undefined);
|
|
29
|
+
const mockMkdir = vi.fn().mockResolvedValue(undefined);
|
|
30
|
+
const mockUnlink = vi.fn().mockResolvedValue(undefined);
|
|
31
|
+
const mockReaddir = vi.fn();
|
|
32
|
+
const mockStat = vi.fn();
|
|
33
|
+
|
|
34
|
+
vi.mock('node:fs/promises', () => ({
|
|
35
|
+
readFile: (...args: unknown[]) => mockReadFile(...args),
|
|
36
|
+
writeFile: (...args: unknown[]) => mockWriteFile(...args),
|
|
37
|
+
rename: (...args: unknown[]) => mockRename(...args),
|
|
38
|
+
mkdir: (...args: unknown[]) => mockMkdir(...args),
|
|
39
|
+
unlink: (...args: unknown[]) => mockUnlink(...args),
|
|
40
|
+
readdir: (...args: unknown[]) => mockReaddir(...args),
|
|
41
|
+
stat: (...args: unknown[]) => mockStat(...args),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
// Mock logger
|
|
45
|
+
vi.mock('../src/logger.js', () => ({
|
|
46
|
+
logger: {
|
|
47
|
+
child: () => ({
|
|
48
|
+
info: vi.fn(),
|
|
49
|
+
warn: vi.fn(),
|
|
50
|
+
error: vi.fn(),
|
|
51
|
+
debug: vi.fn(),
|
|
52
|
+
}),
|
|
53
|
+
info: vi.fn(),
|
|
54
|
+
warn: vi.fn(),
|
|
55
|
+
error: vi.fn(),
|
|
56
|
+
debug: vi.fn(),
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
// Spawn mock infrastructure
|
|
61
|
+
const mockExecFileSync = vi.fn();
|
|
62
|
+
const mockSpawn = vi.fn();
|
|
63
|
+
|
|
64
|
+
vi.mock('node:child_process', () => ({
|
|
65
|
+
execFileSync: (...args: unknown[]) => mockExecFileSync(...args),
|
|
66
|
+
spawn: (...args: unknown[]) => mockSpawn(...args),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
type Handler = (...args: unknown[]) => void;
|
|
70
|
+
|
|
71
|
+
function createMockChild(output: string, exitCode = 0) {
|
|
72
|
+
const stdoutHandlers: Record<string, Handler[]> = {};
|
|
73
|
+
const childHandlers: Record<string, Handler[]> = {};
|
|
74
|
+
|
|
75
|
+
const mockStdout = {
|
|
76
|
+
on(event: string, handler: Handler) {
|
|
77
|
+
(stdoutHandlers[event] ??= []).push(handler);
|
|
78
|
+
return mockStdout;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const mockStderr = {
|
|
82
|
+
on(_event: string, _handler: Handler) { return mockStderr; },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const child = {
|
|
86
|
+
stdout: mockStdout,
|
|
87
|
+
stderr: mockStderr,
|
|
88
|
+
kill: vi.fn(),
|
|
89
|
+
on(event: string, handler: Handler) {
|
|
90
|
+
(childHandlers[event] ??= []).push(handler);
|
|
91
|
+
if (event === 'close') {
|
|
92
|
+
setTimeout(() => {
|
|
93
|
+
for (const h of stdoutHandlers['data'] ?? []) h(Buffer.from(output));
|
|
94
|
+
for (const h of childHandlers['close'] ?? []) h(exitCode);
|
|
95
|
+
}, 0);
|
|
96
|
+
}
|
|
97
|
+
return child;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return child;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const PROFILES_DIR = '/home/testuser/.claude/ultra/claude-profiles';
|
|
105
|
+
const CREDENTIALS_FILE = '/home/testuser/.claude/.credentials.json';
|
|
106
|
+
const ACTIVE_FILE = join(PROFILES_DIR, 'active.json');
|
|
107
|
+
|
|
108
|
+
const sampleCredentials = {
|
|
109
|
+
claudeAiOauth: {
|
|
110
|
+
accessToken: 'sk-ant-oat01-test',
|
|
111
|
+
refreshToken: 'sk-ant-ort01-test',
|
|
112
|
+
expiresAt: Date.now() + 3600_000,
|
|
113
|
+
scopes: ['user:profile', 'user:inference'],
|
|
114
|
+
subscriptionType: 'team',
|
|
115
|
+
rateLimitTier: 'default_claude_max_5x',
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const sampleProfile = {
|
|
120
|
+
name: 'work',
|
|
121
|
+
email: 'user@work.com',
|
|
122
|
+
orgName: 'WorkOrg',
|
|
123
|
+
subscriptionType: 'team',
|
|
124
|
+
savedAt: '2026-04-11T18:05:36Z',
|
|
125
|
+
credentials: sampleCredentials,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const authStatusOutput = JSON.stringify({
|
|
129
|
+
email: 'user@work.com',
|
|
130
|
+
orgName: 'WorkOrg',
|
|
131
|
+
subscriptionType: 'team',
|
|
132
|
+
loggedIn: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('switchProfile', () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
vi.resetAllMocks();
|
|
138
|
+
mockExecFileSync.mockReturnValue('/usr/bin/claude');
|
|
139
|
+
// Reset self-trigger flag
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns error when profile does not exist', async () => {
|
|
143
|
+
// Profile not found
|
|
144
|
+
mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
145
|
+
|
|
146
|
+
const { switchProfile, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
147
|
+
clearSelfTriggerFlag();
|
|
148
|
+
|
|
149
|
+
const result = await switchProfile('nonexistent');
|
|
150
|
+
expect(result.success).toBe(false);
|
|
151
|
+
expect(result.message).toContain('"nonexistent" not found');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('atomically writes credentials, updates active.json, and verifies via auth status', async () => {
|
|
155
|
+
// Profile load → work.json
|
|
156
|
+
// Then readCredentialsFile (freshCredentials) → sampleCredentials
|
|
157
|
+
// Then active file write
|
|
158
|
+
let readCallIdx = 0;
|
|
159
|
+
mockReadFile.mockImplementation(() => {
|
|
160
|
+
readCallIdx++;
|
|
161
|
+
if (readCallIdx === 1) {
|
|
162
|
+
// loadProfile('work')
|
|
163
|
+
return Promise.resolve(JSON.stringify(sampleProfile));
|
|
164
|
+
}
|
|
165
|
+
// readCredentialsFile() after auth status
|
|
166
|
+
return Promise.resolve(JSON.stringify(sampleCredentials));
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
mockSpawn.mockReturnValue(createMockChild(authStatusOutput, 0));
|
|
170
|
+
|
|
171
|
+
const { switchProfile, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
172
|
+
clearSelfTriggerFlag();
|
|
173
|
+
|
|
174
|
+
const result = await switchProfile('work');
|
|
175
|
+
expect(result.success).toBe(true);
|
|
176
|
+
expect(result.message).toContain('Switched to "work"');
|
|
177
|
+
expect(result.message).toContain('user@work.com');
|
|
178
|
+
|
|
179
|
+
// Should have written credentials file atomically (temp + rename)
|
|
180
|
+
const writeCallsToCredentials = mockWriteFile.mock.calls.filter(
|
|
181
|
+
(c) => (c[0] as string).startsWith(CREDENTIALS_FILE),
|
|
182
|
+
);
|
|
183
|
+
expect(writeCallsToCredentials.length).toBeGreaterThan(0);
|
|
184
|
+
|
|
185
|
+
// Atomic rename of credentials
|
|
186
|
+
const renameToCredentials = mockRename.mock.calls.filter(
|
|
187
|
+
(c) => (c[1] as string) === CREDENTIALS_FILE,
|
|
188
|
+
);
|
|
189
|
+
expect(renameToCredentials.length).toBeGreaterThan(0);
|
|
190
|
+
|
|
191
|
+
// Should have updated active.json
|
|
192
|
+
const writeCallsToActive = mockWriteFile.mock.calls.filter(
|
|
193
|
+
(c) => (c[0] as string).startsWith(ACTIVE_FILE),
|
|
194
|
+
);
|
|
195
|
+
expect(writeCallsToActive.length).toBeGreaterThan(0);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('sets self-trigger flag before writing credentials', async () => {
|
|
199
|
+
mockReadFile.mockResolvedValue(JSON.stringify(sampleProfile));
|
|
200
|
+
mockSpawn.mockReturnValue(createMockChild(authStatusOutput, 0));
|
|
201
|
+
|
|
202
|
+
const { switchProfile, isSelfTriggered, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
203
|
+
clearSelfTriggerFlag();
|
|
204
|
+
|
|
205
|
+
// Spy on writeFile to check flag state at write time
|
|
206
|
+
let flagStateAtWrite = false;
|
|
207
|
+
mockWriteFile.mockImplementationOnce((..._args: unknown[]) => {
|
|
208
|
+
flagStateAtWrite = isSelfTriggered();
|
|
209
|
+
return Promise.resolve();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await switchProfile('work');
|
|
213
|
+
expect(flagStateAtWrite).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('updates saved profile with refreshed credentials from auth status', async () => {
|
|
217
|
+
const refreshedCredentials = {
|
|
218
|
+
claudeAiOauth: {
|
|
219
|
+
...sampleCredentials.claudeAiOauth,
|
|
220
|
+
accessToken: 'sk-ant-refreshed-token',
|
|
221
|
+
expiresAt: Date.now() + 7200_000, // new expiry
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
let readCallIdx = 0;
|
|
226
|
+
mockReadFile.mockImplementation(() => {
|
|
227
|
+
readCallIdx++;
|
|
228
|
+
if (readCallIdx === 1) return Promise.resolve(JSON.stringify(sampleProfile));
|
|
229
|
+
// After auth status runs, credentials file has refreshed tokens
|
|
230
|
+
return Promise.resolve(JSON.stringify(refreshedCredentials));
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
mockSpawn.mockReturnValue(createMockChild(authStatusOutput, 0));
|
|
234
|
+
|
|
235
|
+
const { switchProfile, clearSelfTriggerFlag } = await import('../src/claude-profiles.js');
|
|
236
|
+
clearSelfTriggerFlag();
|
|
237
|
+
|
|
238
|
+
const result = await switchProfile('work');
|
|
239
|
+
expect(result.success).toBe(true);
|
|
240
|
+
|
|
241
|
+
// The profile should be saved with the refreshed credentials
|
|
242
|
+
const savedProfileCall = mockWriteFile.mock.calls.find((c) => {
|
|
243
|
+
try {
|
|
244
|
+
const content = JSON.parse(c[1] as string);
|
|
245
|
+
return content.credentials?.claudeAiOauth?.accessToken === 'sk-ant-refreshed-token';
|
|
246
|
+
} catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
expect(savedProfileCall).toBeDefined();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('deleteProfileByName', () => {
|
|
255
|
+
beforeEach(() => {
|
|
256
|
+
vi.resetAllMocks();
|
|
257
|
+
mockExecFileSync.mockReturnValue('/usr/bin/claude');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('returns error when profile does not exist', async () => {
|
|
261
|
+
mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
262
|
+
|
|
263
|
+
const { deleteProfileByName } = await import('../src/claude-profiles.js');
|
|
264
|
+
const result = await deleteProfileByName('nonexistent');
|
|
265
|
+
expect(result.success).toBe(false);
|
|
266
|
+
expect(result.wasActive).toBe(false);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('deletes profile file and returns wasActive=false when not active', async () => {
|
|
270
|
+
let readCallIdx = 0;
|
|
271
|
+
mockReadFile.mockImplementation(() => {
|
|
272
|
+
readCallIdx++;
|
|
273
|
+
if (readCallIdx === 1) return Promise.resolve(JSON.stringify(sampleProfile)); // loadProfile
|
|
274
|
+
// loadActiveProfile → different profile active
|
|
275
|
+
return Promise.resolve(JSON.stringify({ profile: 'personal', switchedAt: '2026-04-11T18:00:00Z' }));
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const { deleteProfileByName } = await import('../src/claude-profiles.js');
|
|
279
|
+
const result = await deleteProfileByName('work');
|
|
280
|
+
|
|
281
|
+
expect(result.success).toBe(true);
|
|
282
|
+
expect(result.wasActive).toBe(false);
|
|
283
|
+
expect(result.message).toBe('Deleted profile "work"');
|
|
284
|
+
|
|
285
|
+
// Should delete the profile file
|
|
286
|
+
expect(mockUnlink).toHaveBeenCalledWith(join(PROFILES_DIR, 'work.json'));
|
|
287
|
+
// Should NOT delete active.json
|
|
288
|
+
expect(mockUnlink).not.toHaveBeenCalledWith(ACTIVE_FILE);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('clears active.json and warns when deleting active profile', async () => {
|
|
292
|
+
let readCallIdx = 0;
|
|
293
|
+
mockReadFile.mockImplementation(() => {
|
|
294
|
+
readCallIdx++;
|
|
295
|
+
if (readCallIdx === 1) return Promise.resolve(JSON.stringify(sampleProfile)); // loadProfile
|
|
296
|
+
// loadActiveProfile → work is active
|
|
297
|
+
return Promise.resolve(JSON.stringify({ profile: 'work', switchedAt: '2026-04-11T18:00:00Z' }));
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const { deleteProfileByName } = await import('../src/claude-profiles.js');
|
|
301
|
+
const result = await deleteProfileByName('work');
|
|
302
|
+
|
|
303
|
+
expect(result.success).toBe(true);
|
|
304
|
+
expect(result.wasActive).toBe(true);
|
|
305
|
+
expect(result.message).toContain('was active');
|
|
306
|
+
|
|
307
|
+
// Should delete profile file AND active.json
|
|
308
|
+
expect(mockUnlink).toHaveBeenCalledWith(join(PROFILES_DIR, 'work.json'));
|
|
309
|
+
expect(mockUnlink).toHaveBeenCalledWith(ACTIVE_FILE);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('loginProfile', () => {
|
|
314
|
+
beforeEach(() => {
|
|
315
|
+
vi.resetAllMocks();
|
|
316
|
+
mockExecFileSync.mockReturnValue('/usr/bin/claude');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('pre-populates --email from existing profile when profile already exists', async () => {
|
|
320
|
+
const existingProfile = { ...sampleProfile, email: 'user@work.com' };
|
|
321
|
+
|
|
322
|
+
// loginProfile read order:
|
|
323
|
+
// 1. readCredentialsFile() for backup → sampleCredentials
|
|
324
|
+
// 2. loadProfile('work') → existingProfile (has stored email)
|
|
325
|
+
// 3. readCredentialsFile() after login → sampleCredentials
|
|
326
|
+
let readCallIdx = 0;
|
|
327
|
+
mockReadFile.mockImplementation(() => {
|
|
328
|
+
readCallIdx++;
|
|
329
|
+
if (readCallIdx === 1) {
|
|
330
|
+
// readCredentialsFile for backup
|
|
331
|
+
return Promise.resolve(JSON.stringify(sampleCredentials));
|
|
332
|
+
}
|
|
333
|
+
if (readCallIdx === 2) {
|
|
334
|
+
// loadProfile('work') — profile exists with stored email
|
|
335
|
+
return Promise.resolve(JSON.stringify(existingProfile));
|
|
336
|
+
}
|
|
337
|
+
// readCredentialsFile after login
|
|
338
|
+
return Promise.resolve(JSON.stringify(sampleCredentials));
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Mock the interactive login spawn (exits with code 0)
|
|
342
|
+
const loginChild = {
|
|
343
|
+
on: vi.fn().mockImplementation((event: string, handler: Handler) => {
|
|
344
|
+
if (event === 'close') setTimeout(() => handler(0), 0);
|
|
345
|
+
return loginChild;
|
|
346
|
+
}),
|
|
347
|
+
kill: vi.fn(),
|
|
348
|
+
stdout: null,
|
|
349
|
+
stderr: null,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Mock auth status spawn
|
|
353
|
+
mockSpawn.mockImplementationOnce(() => loginChild); // claude auth login
|
|
354
|
+
mockSpawn.mockImplementationOnce(() => createMockChild(authStatusOutput, 0)); // claude auth status
|
|
355
|
+
|
|
356
|
+
const { loginProfile } = await import('../src/claude-profiles.js');
|
|
357
|
+
const result = await loginProfile('work'); // no explicit email — should use profile's email
|
|
358
|
+
|
|
359
|
+
expect(result.success).toBe(true);
|
|
360
|
+
|
|
361
|
+
// claude auth login should have been called with --email from existing profile
|
|
362
|
+
const loginSpawnCall = mockSpawn.mock.calls[0]!;
|
|
363
|
+
expect(loginSpawnCall[0]).toBe('claude');
|
|
364
|
+
const loginArgs = loginSpawnCall[1] as string[];
|
|
365
|
+
expect(loginArgs).toContain('--email');
|
|
366
|
+
expect(loginArgs).toContain('user@work.com');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('uses provided --email even when profile does not exist', async () => {
|
|
370
|
+
// loginProfile read order:
|
|
371
|
+
// 1. readCredentialsFile() for backup → ENOENT (no credentials yet)
|
|
372
|
+
// 2. loadProfile('newaccount') → ENOENT (no profile)
|
|
373
|
+
// 3. readCredentialsFile() after login → sampleCredentials
|
|
374
|
+
let readCallIdx = 0;
|
|
375
|
+
mockReadFile.mockImplementation(() => {
|
|
376
|
+
readCallIdx++;
|
|
377
|
+
if (readCallIdx === 1 || readCallIdx === 2) {
|
|
378
|
+
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
379
|
+
}
|
|
380
|
+
return Promise.resolve(JSON.stringify(sampleCredentials));
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const loginChild = {
|
|
384
|
+
on: vi.fn().mockImplementation((event: string, handler: Handler) => {
|
|
385
|
+
if (event === 'close') setTimeout(() => handler(0), 0);
|
|
386
|
+
return loginChild;
|
|
387
|
+
}),
|
|
388
|
+
kill: vi.fn(),
|
|
389
|
+
stdout: null,
|
|
390
|
+
stderr: null,
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
mockSpawn.mockImplementationOnce(() => loginChild);
|
|
394
|
+
mockSpawn.mockImplementationOnce(() => createMockChild(authStatusOutput, 0));
|
|
395
|
+
|
|
396
|
+
const { loginProfile } = await import('../src/claude-profiles.js');
|
|
397
|
+
await loginProfile('newaccount', 'explicit@email.com');
|
|
398
|
+
|
|
399
|
+
const loginSpawnCall = mockSpawn.mock.calls[0]!;
|
|
400
|
+
const loginArgs = loginSpawnCall[1] as string[];
|
|
401
|
+
expect(loginArgs).toContain('--email');
|
|
402
|
+
expect(loginArgs).toContain('explicit@email.com');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe('saveCurrentAsProfile', () => {
|
|
407
|
+
beforeEach(() => {
|
|
408
|
+
vi.resetAllMocks();
|
|
409
|
+
mockExecFileSync.mockReturnValue('/usr/bin/claude');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('saves current credentials with auth status metadata', async () => {
|
|
413
|
+
mockReadFile.mockResolvedValue(JSON.stringify(sampleCredentials));
|
|
414
|
+
mockSpawn.mockReturnValue(createMockChild(authStatusOutput, 0));
|
|
415
|
+
|
|
416
|
+
const { saveCurrentAsProfile } = await import('../src/claude-profiles.js');
|
|
417
|
+
const result = await saveCurrentAsProfile('work');
|
|
418
|
+
|
|
419
|
+
expect(result.success).toBe(true);
|
|
420
|
+
expect(result.message).toContain('work');
|
|
421
|
+
expect(result.message).toContain('user@work.com');
|
|
422
|
+
|
|
423
|
+
// Should write the profile with metadata
|
|
424
|
+
const profileWrite = mockWriteFile.mock.calls.find((c) => {
|
|
425
|
+
try {
|
|
426
|
+
const parsed = JSON.parse(c[1] as string);
|
|
427
|
+
return parsed.name === 'work' && parsed.email === 'user@work.com';
|
|
428
|
+
} catch { return false; }
|
|
429
|
+
});
|
|
430
|
+
expect(profileWrite).toBeDefined();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it('returns error when no credentials file exists', async () => {
|
|
434
|
+
mockReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
|
435
|
+
|
|
436
|
+
const { saveCurrentAsProfile } = await import('../src/claude-profiles.js');
|
|
437
|
+
const result = await saveCurrentAsProfile('work');
|
|
438
|
+
expect(result.success).toBe(false);
|
|
439
|
+
expect(result.message).toContain('No credentials file');
|
|
440
|
+
});
|
|
441
|
+
});
|