agent-vault-cli 0.1.0

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 (87) hide show
  1. package/.cursor/skills/npm-publish/SKILL.md +58 -0
  2. package/.github/workflows/ci.yml +67 -0
  3. package/README.md +164 -0
  4. package/ROADMAP.md +986 -0
  5. package/dist/commands/config.d.ts +8 -0
  6. package/dist/commands/config.d.ts.map +1 -0
  7. package/dist/commands/config.js +67 -0
  8. package/dist/commands/config.js.map +1 -0
  9. package/dist/commands/delete.d.ts +7 -0
  10. package/dist/commands/delete.d.ts.map +1 -0
  11. package/dist/commands/delete.js +30 -0
  12. package/dist/commands/delete.js.map +1 -0
  13. package/dist/commands/login.d.ts +7 -0
  14. package/dist/commands/login.d.ts.map +1 -0
  15. package/dist/commands/login.js +37 -0
  16. package/dist/commands/login.js.map +1 -0
  17. package/dist/commands/register.d.ts +13 -0
  18. package/dist/commands/register.d.ts.map +1 -0
  19. package/dist/commands/register.js +160 -0
  20. package/dist/commands/register.js.map +1 -0
  21. package/dist/core/audit.d.ts +15 -0
  22. package/dist/core/audit.d.ts.map +1 -0
  23. package/dist/core/audit.js +36 -0
  24. package/dist/core/audit.js.map +1 -0
  25. package/dist/core/browser.d.ts +7 -0
  26. package/dist/core/browser.d.ts.map +1 -0
  27. package/dist/core/browser.js +104 -0
  28. package/dist/core/browser.js.map +1 -0
  29. package/dist/core/config.d.ts +9 -0
  30. package/dist/core/config.d.ts.map +1 -0
  31. package/dist/core/config.js +80 -0
  32. package/dist/core/config.js.map +1 -0
  33. package/dist/core/crypto.d.ts +17 -0
  34. package/dist/core/crypto.d.ts.map +1 -0
  35. package/dist/core/crypto.js +90 -0
  36. package/dist/core/crypto.js.map +1 -0
  37. package/dist/core/fields.d.ts +5 -0
  38. package/dist/core/fields.d.ts.map +1 -0
  39. package/dist/core/fields.js +54 -0
  40. package/dist/core/fields.js.map +1 -0
  41. package/dist/core/keychain.d.ts +5 -0
  42. package/dist/core/keychain.d.ts.map +1 -0
  43. package/dist/core/keychain.js +97 -0
  44. package/dist/core/keychain.js.map +1 -0
  45. package/dist/core/origin.d.ts +25 -0
  46. package/dist/core/origin.d.ts.map +1 -0
  47. package/dist/core/origin.js +73 -0
  48. package/dist/core/origin.js.map +1 -0
  49. package/dist/core/ratelimit.d.ts +10 -0
  50. package/dist/core/ratelimit.d.ts.map +1 -0
  51. package/dist/core/ratelimit.js +70 -0
  52. package/dist/core/ratelimit.js.map +1 -0
  53. package/dist/core/secure-memory.d.ts +39 -0
  54. package/dist/core/secure-memory.d.ts.map +1 -0
  55. package/dist/core/secure-memory.js +68 -0
  56. package/dist/core/secure-memory.js.map +1 -0
  57. package/dist/index.d.ts +3 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +129 -0
  60. package/dist/index.js.map +1 -0
  61. package/dist/types/index.d.ts +27 -0
  62. package/dist/types/index.d.ts.map +1 -0
  63. package/dist/types/index.js +2 -0
  64. package/dist/types/index.js.map +1 -0
  65. package/package.json +58 -0
  66. package/src/commands/config.ts +84 -0
  67. package/src/commands/delete.ts +39 -0
  68. package/src/commands/login.ts +49 -0
  69. package/src/commands/register.ts +188 -0
  70. package/src/core/audit.ts +59 -0
  71. package/src/core/browser.ts +131 -0
  72. package/src/core/config.ts +91 -0
  73. package/src/core/crypto.ts +106 -0
  74. package/src/core/fields.ts +59 -0
  75. package/src/core/keychain.ts +110 -0
  76. package/src/core/origin.ts +90 -0
  77. package/src/core/ratelimit.ts +89 -0
  78. package/src/core/secure-memory.ts +78 -0
  79. package/src/index.ts +133 -0
  80. package/src/types/index.ts +31 -0
  81. package/tests/browser-password-manager.test.ts +1023 -0
  82. package/tests/crypto.test.ts +140 -0
  83. package/tests/e2e.test.ts +565 -0
  84. package/tests/fixtures/server.ts +59 -0
  85. package/tests/security.test.ts +113 -0
  86. package/tsconfig.json +20 -0
  87. package/vitest.config.ts +17 -0
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generatePassword, generatePasswordNanoid } from '../src/core/crypto.js';
3
+
4
+ describe('generatePassword', () => {
5
+ it('generates password of default length (24)', () => {
6
+ const password = generatePassword();
7
+ expect(password).toHaveLength(24);
8
+ });
9
+
10
+ it('generates password of specified length', () => {
11
+ const password = generatePassword(32);
12
+ expect(password).toHaveLength(32);
13
+ });
14
+
15
+ it('throws error for length below minimum (12)', () => {
16
+ expect(() => generatePassword(8)).toThrow('Password length must be at least 12 characters');
17
+ expect(() => generatePassword(11)).toThrow('Password length must be at least 12 characters');
18
+ });
19
+
20
+ it('accepts minimum length of 12', () => {
21
+ const password = generatePassword(12);
22
+ expect(password).toHaveLength(12);
23
+ });
24
+
25
+ it('contains at least one lowercase letter', () => {
26
+ for (let i = 0; i < 10; i++) {
27
+ const password = generatePassword();
28
+ expect(password).toMatch(/[a-z]/);
29
+ }
30
+ });
31
+
32
+ it('contains at least one uppercase letter', () => {
33
+ for (let i = 0; i < 10; i++) {
34
+ const password = generatePassword();
35
+ expect(password).toMatch(/[A-Z]/);
36
+ }
37
+ });
38
+
39
+ it('contains at least one digit', () => {
40
+ for (let i = 0; i < 10; i++) {
41
+ const password = generatePassword();
42
+ expect(password).toMatch(/[0-9]/);
43
+ }
44
+ });
45
+
46
+ it('contains at least one special character', () => {
47
+ for (let i = 0; i < 10; i++) {
48
+ const password = generatePassword();
49
+ expect(password).toMatch(/[!@#$%^&*]/);
50
+ }
51
+ });
52
+
53
+ it('only contains allowed characters', () => {
54
+ const allowedChars = /^[a-zA-Z0-9!@#$%^&*]+$/;
55
+ for (let i = 0; i < 10; i++) {
56
+ const password = generatePassword();
57
+ expect(password).toMatch(allowedChars);
58
+ }
59
+ });
60
+
61
+ it('generates unique passwords', () => {
62
+ const passwords = new Set<string>();
63
+ for (let i = 0; i < 100; i++) {
64
+ passwords.add(generatePassword());
65
+ }
66
+ // All 100 passwords should be unique
67
+ expect(passwords.size).toBe(100);
68
+ });
69
+ });
70
+
71
+ describe('generatePasswordNanoid', () => {
72
+ it('generates password of default length (24)', () => {
73
+ const password = generatePasswordNanoid();
74
+ expect(password).toHaveLength(24);
75
+ });
76
+
77
+ it('generates password of specified length', () => {
78
+ const password = generatePasswordNanoid(32);
79
+ expect(password).toHaveLength(32);
80
+ });
81
+
82
+ it('throws error for length below minimum (12)', () => {
83
+ expect(() => generatePasswordNanoid(8)).toThrow(
84
+ 'Password length must be at least 12 characters'
85
+ );
86
+ expect(() => generatePasswordNanoid(11)).toThrow(
87
+ 'Password length must be at least 12 characters'
88
+ );
89
+ });
90
+
91
+ it('accepts minimum length of 12', () => {
92
+ const password = generatePasswordNanoid(12);
93
+ expect(password).toHaveLength(12);
94
+ });
95
+
96
+ it('contains at least one lowercase letter', () => {
97
+ for (let i = 0; i < 10; i++) {
98
+ const password = generatePasswordNanoid();
99
+ expect(password).toMatch(/[a-z]/);
100
+ }
101
+ });
102
+
103
+ it('contains at least one uppercase letter', () => {
104
+ for (let i = 0; i < 10; i++) {
105
+ const password = generatePasswordNanoid();
106
+ expect(password).toMatch(/[A-Z]/);
107
+ }
108
+ });
109
+
110
+ it('contains at least one digit', () => {
111
+ for (let i = 0; i < 10; i++) {
112
+ const password = generatePasswordNanoid();
113
+ expect(password).toMatch(/[0-9]/);
114
+ }
115
+ });
116
+
117
+ it('contains at least one special character', () => {
118
+ for (let i = 0; i < 10; i++) {
119
+ const password = generatePasswordNanoid();
120
+ expect(password).toMatch(/[!@#$%^&*]/);
121
+ }
122
+ });
123
+
124
+ it('only contains allowed characters', () => {
125
+ const allowedChars = /^[a-zA-Z0-9!@#$%^&*]+$/;
126
+ for (let i = 0; i < 10; i++) {
127
+ const password = generatePasswordNanoid();
128
+ expect(password).toMatch(allowedChars);
129
+ }
130
+ });
131
+
132
+ it('generates unique passwords', () => {
133
+ const passwords = new Set<string>();
134
+ for (let i = 0; i < 100; i++) {
135
+ passwords.add(generatePasswordNanoid());
136
+ }
137
+ // All 100 passwords should be unique
138
+ expect(passwords.size).toBe(100);
139
+ });
140
+ });
@@ -0,0 +1,565 @@
1
+ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
2
+ import { chromium, Browser } from 'playwright';
3
+ import { execSync, spawn, ChildProcess } from 'child_process';
4
+ import { startTestServer, TestServer } from './fixtures/server.js';
5
+ import { deleteRP, getRP } from '../src/core/keychain.js';
6
+ import { loadConfig, saveConfig } from '../src/core/config.js';
7
+ import { resetRateLimit } from '../src/core/ratelimit.js';
8
+ import http from 'http';
9
+
10
+ const CLI_PATH = './dist/index.js';
11
+
12
+ // Ports for different "origins"
13
+ const TEST_PORTS = [9501, 9502, 9503];
14
+ const CDP_PORT = 9333;
15
+
16
+ // Run with HEADFUL=1 npm test to see the browser
17
+ const HEADFUL = process.env.HEADFUL === '1' || process.env.HEADFUL === 'true';
18
+ const SLOW_MO = HEADFUL ? 500 : 0; // Slow down actions in headful mode
19
+
20
+ // Helper to add delay in headful mode so you can see what's happening
21
+ async function visualDelay(ms: number = 300): Promise<void> {
22
+ if (HEADFUL) {
23
+ await new Promise((r) => setTimeout(r, ms));
24
+ }
25
+ }
26
+
27
+ async function getCdpEndpoint(port: number): Promise<string> {
28
+ return new Promise((resolve, reject) => {
29
+ const req = http.get(`http://127.0.0.1:${port}/json/version`, (res) => {
30
+ let data = '';
31
+ res.on('data', (chunk) => (data += chunk));
32
+ res.on('end', () => {
33
+ try {
34
+ const json = JSON.parse(data);
35
+ resolve(json.webSocketDebuggerUrl);
36
+ } catch (e) {
37
+ reject(e);
38
+ }
39
+ });
40
+ });
41
+ req.on('error', reject);
42
+ req.setTimeout(5000, () => {
43
+ req.destroy();
44
+ reject(new Error('Timeout getting CDP endpoint'));
45
+ });
46
+ });
47
+ }
48
+
49
+ async function waitForCdp(port: number, maxRetries = 30): Promise<string> {
50
+ for (let i = 0; i < maxRetries; i++) {
51
+ try {
52
+ return await getCdpEndpoint(port);
53
+ } catch {
54
+ await new Promise((r) => setTimeout(r, 200));
55
+ }
56
+ }
57
+ throw new Error(`Could not connect to CDP on port ${port}`);
58
+ }
59
+
60
+ describe('E2E: Vault CLI', () => {
61
+ let servers: TestServer[] = [];
62
+ let browser: Browser;
63
+ let browserProcess: ChildProcess;
64
+ let wsEndpoint: string;
65
+
66
+ beforeAll(async () => {
67
+ // Reset rate limiter before tests
68
+ await resetRateLimit();
69
+
70
+ // Start test servers on multiple ports
71
+ for (const port of TEST_PORTS) {
72
+ const server = await startTestServer(port);
73
+ servers.push(server);
74
+ }
75
+
76
+ // Get the Chromium executable path from Playwright
77
+ const chromiumPath = chromium.executablePath();
78
+
79
+ // Build browser args
80
+ const browserArgs = [
81
+ `--remote-debugging-port=${CDP_PORT}`,
82
+ '--no-first-run',
83
+ '--no-default-browser-check',
84
+ '--disable-extensions',
85
+ '--disable-dev-shm-usage',
86
+ ];
87
+
88
+ // Add headless flag only if not in headful mode
89
+ if (!HEADFUL) {
90
+ browserArgs.push('--headless=new', '--disable-gpu');
91
+ } else {
92
+ // In headful mode, set a reasonable window size
93
+ browserArgs.push('--window-size=1280,800', '--window-position=100,100');
94
+ console.log('\n🖥️ Running in HEADFUL mode - watch the browser!\n');
95
+ }
96
+
97
+ browserArgs.push('about:blank');
98
+
99
+ // Launch Chromium with remote debugging enabled
100
+ browserProcess = spawn(chromiumPath, browserArgs, {
101
+ stdio: HEADFUL ? 'inherit' : 'pipe',
102
+ detached: false,
103
+ });
104
+
105
+ // Wait for CDP to be available and get the endpoint
106
+ wsEndpoint = await waitForCdp(CDP_PORT);
107
+
108
+ // Connect to the browser using CDP (same as CLI does)
109
+ browser = await chromium.connectOverCDP(wsEndpoint);
110
+ }, 60000);
111
+
112
+ afterAll(async () => {
113
+ // Close browser
114
+ if (browser) {
115
+ try {
116
+ await browser.close();
117
+ } catch {
118
+ // Ignore
119
+ }
120
+ }
121
+
122
+ // Kill browser process
123
+ if (browserProcess) {
124
+ browserProcess.kill('SIGTERM');
125
+ }
126
+
127
+ // Stop all test servers
128
+ for (const server of servers) {
129
+ await server.close();
130
+ }
131
+
132
+ // Clean up any test credentials
133
+ for (const port of TEST_PORTS) {
134
+ try {
135
+ await deleteRP(`http://127.0.0.1:${port}`);
136
+ } catch {
137
+ // Ignore errors during cleanup
138
+ }
139
+ }
140
+ });
141
+
142
+ describe('register command', () => {
143
+ const testPort = TEST_PORTS[0];
144
+ const testOrigin = `http://127.0.0.1:${testPort}`;
145
+
146
+ beforeEach(async () => {
147
+ // Reset rate limiter before each test
148
+ await resetRateLimit();
149
+ });
150
+
151
+ afterEach(async () => {
152
+ // Clean up credentials after each test
153
+ try {
154
+ await deleteRP(testOrigin);
155
+ } catch {
156
+ // Ignore
157
+ }
158
+ });
159
+
160
+ it('registers credentials for a site', async () => {
161
+ const context = browser.contexts()[0];
162
+ const page = context.pages()[0] || await context.newPage();
163
+ await page.goto(testOrigin);
164
+ await visualDelay(500);
165
+
166
+ // Run the CLI register command
167
+ const output = execSync(
168
+ `node ${CLI_PATH} register ` +
169
+ `--cdp "${wsEndpoint}" ` +
170
+ `--username-selector "#email" ` +
171
+ `--password-selector "#password" ` +
172
+ `--username "test@example.com" ` +
173
+ `--password "TestPassword123!" ` +
174
+ `--allow-http ` +
175
+ `--force`,
176
+ { encoding: 'utf-8', cwd: process.cwd() }
177
+ );
178
+
179
+ await visualDelay(800); // Show the filled form
180
+
181
+ expect(output).toContain('Credentials registered successfully');
182
+
183
+ // Verify credentials were stored
184
+ const stored = await getRP(testOrigin);
185
+ expect(stored).not.toBeNull();
186
+ expect(stored?.credentials.username).toBe('test@example.com');
187
+ expect(stored?.credentials.password).toBe('TestPassword123!');
188
+ expect(stored?.selectors.username).toBe('#email');
189
+ expect(stored?.selectors.password).toBe('#password');
190
+
191
+ // Verify form was filled
192
+ const emailValue = await page.inputValue('#email');
193
+ const passwordValue = await page.inputValue('#password');
194
+ expect(emailValue).toBe('test@example.com');
195
+ expect(passwordValue).toBe('TestPassword123!');
196
+ });
197
+
198
+ it('generates a secure password when --generate-password is used', async () => {
199
+ const context = browser.contexts()[0];
200
+ const page = context.pages()[0] || await context.newPage();
201
+ await page.goto(testOrigin);
202
+ await visualDelay(500);
203
+
204
+ const output = execSync(
205
+ `node ${CLI_PATH} register ` +
206
+ `--cdp "${wsEndpoint}" ` +
207
+ `--username-selector "#email" ` +
208
+ `--password-selector "#password" ` +
209
+ `--username "generated@example.com" ` +
210
+ `--generate-password ` +
211
+ `--allow-http ` +
212
+ `--force`,
213
+ { encoding: 'utf-8', cwd: process.cwd() }
214
+ );
215
+
216
+ await visualDelay(800);
217
+
218
+ expect(output).toContain('Secure password generated');
219
+ expect(output).toContain('Credentials registered successfully');
220
+
221
+ // Verify credentials were stored with a generated password
222
+ const stored = await getRP(testOrigin);
223
+ expect(stored).not.toBeNull();
224
+ expect(stored?.credentials.username).toBe('generated@example.com');
225
+ expect(stored?.credentials.password.length).toBeGreaterThanOrEqual(16);
226
+ });
227
+
228
+ it('fails with invalid selector', async () => {
229
+ const context = browser.contexts()[0];
230
+ const page = context.pages()[0] || await context.newPage();
231
+ await page.goto(testOrigin);
232
+
233
+ expect(() => {
234
+ execSync(
235
+ `node ${CLI_PATH} register ` +
236
+ `--cdp "${wsEndpoint}" ` +
237
+ `--username-selector "#nonexistent" ` +
238
+ `--password-selector "#password" ` +
239
+ `--username "test@example.com" ` +
240
+ `--password "TestPassword123!" ` +
241
+ `--allow-http ` +
242
+ `--force`,
243
+ { encoding: 'utf-8', cwd: process.cwd() }
244
+ );
245
+ }).toThrow(/Username.*not found/);
246
+ });
247
+ });
248
+
249
+ describe('login command', () => {
250
+ const testPort = TEST_PORTS[1];
251
+ const testOrigin = `http://127.0.0.1:${testPort}`;
252
+
253
+ beforeEach(async () => {
254
+ await resetRateLimit();
255
+ });
256
+
257
+ afterEach(async () => {
258
+ try {
259
+ await deleteRP(testOrigin);
260
+ } catch {
261
+ // Ignore
262
+ }
263
+ });
264
+
265
+ it('fills credentials for a registered site', async () => {
266
+ // First, register credentials
267
+ const context = browser.contexts()[0];
268
+ const page = context.pages()[0] || await context.newPage();
269
+ await page.goto(testOrigin);
270
+ await visualDelay(500);
271
+
272
+ execSync(
273
+ `node ${CLI_PATH} register ` +
274
+ `--cdp "${wsEndpoint}" ` +
275
+ `--username-selector "#email" ` +
276
+ `--password-selector "#password" ` +
277
+ `--submit-selector "#submit-btn" ` +
278
+ `--username "login@example.com" ` +
279
+ `--password "LoginPass456!" ` +
280
+ `--allow-http ` +
281
+ `--force`,
282
+ { encoding: 'utf-8', cwd: process.cwd() }
283
+ );
284
+
285
+ await visualDelay(600);
286
+
287
+ // Navigate to the page again to clear the form
288
+ await page.goto(testOrigin);
289
+ await visualDelay(500);
290
+
291
+ // Verify form is empty
292
+ expect(await page.inputValue('#email')).toBe('');
293
+ expect(await page.inputValue('#password')).toBe('');
294
+
295
+ // Run login command
296
+ const output = execSync(
297
+ `node ${CLI_PATH} login --cdp "${wsEndpoint}"`,
298
+ { encoding: 'utf-8', cwd: process.cwd() }
299
+ );
300
+
301
+ await visualDelay(800); // Show the auto-filled form
302
+
303
+ expect(output).toContain('Login filled successfully');
304
+
305
+ // Verify form was filled with stored credentials
306
+ const emailValue = await page.inputValue('#email');
307
+ const passwordValue = await page.inputValue('#password');
308
+ expect(emailValue).toBe('login@example.com');
309
+ expect(passwordValue).toBe('LoginPass456!');
310
+ });
311
+
312
+ it('fails for unknown origin', async () => {
313
+ // Use a different port that has no registered credentials
314
+ const unknownPort = TEST_PORTS[2];
315
+ const context = browser.contexts()[0];
316
+ const page = context.pages()[0] || await context.newPage();
317
+ await page.goto(`http://127.0.0.1:${unknownPort}`);
318
+
319
+ // Clean up any existing credentials for this origin
320
+ try {
321
+ await deleteRP(`http://127.0.0.1:${unknownPort}`);
322
+ } catch {
323
+ // Ignore
324
+ }
325
+
326
+ expect(() => {
327
+ execSync(
328
+ `node ${CLI_PATH} login --cdp "${wsEndpoint}"`,
329
+ { encoding: 'utf-8', cwd: process.cwd() }
330
+ );
331
+ }).toThrow(/No credentials found/);
332
+ });
333
+ });
334
+
335
+ describe('delete command', () => {
336
+ const testPort = TEST_PORTS[2];
337
+ const testOrigin = `http://127.0.0.1:${testPort}`;
338
+
339
+ beforeEach(async () => {
340
+ await resetRateLimit();
341
+ });
342
+
343
+ it('deletes credentials for a site', async () => {
344
+ // First, register credentials
345
+ const context = browser.contexts()[0];
346
+ const page = context.pages()[0] || await context.newPage();
347
+ await page.goto(testOrigin);
348
+
349
+ execSync(
350
+ `node ${CLI_PATH} register ` +
351
+ `--cdp "${wsEndpoint}" ` +
352
+ `--username-selector "#email" ` +
353
+ `--password-selector "#password" ` +
354
+ `--username "delete@example.com" ` +
355
+ `--password "DeletePass789!" ` +
356
+ `--allow-http ` +
357
+ `--force`,
358
+ { encoding: 'utf-8', cwd: process.cwd() }
359
+ );
360
+
361
+ // Verify credentials exist
362
+ let stored = await getRP(testOrigin);
363
+ expect(stored).not.toBeNull();
364
+
365
+ // Run delete command
366
+ const output = execSync(
367
+ `node ${CLI_PATH} delete --origin "${testOrigin}" --force`,
368
+ { encoding: 'utf-8', cwd: process.cwd() }
369
+ );
370
+
371
+ expect(output).toContain('Deleted credentials for');
372
+
373
+ // Verify credentials were deleted
374
+ stored = await getRP(testOrigin);
375
+ expect(stored).toBeNull();
376
+ });
377
+
378
+ it('fails for non-existent origin', async () => {
379
+ expect(() => {
380
+ execSync(
381
+ `node ${CLI_PATH} delete --origin "http://nonexistent.example.com" --force`,
382
+ { encoding: 'utf-8', cwd: process.cwd() }
383
+ );
384
+ }).toThrow(/No credentials found/);
385
+ });
386
+ });
387
+
388
+ describe('multiple origins', () => {
389
+ beforeEach(async () => {
390
+ await resetRateLimit();
391
+ });
392
+
393
+ afterEach(async () => {
394
+ // Clean up all test credentials
395
+ for (const port of TEST_PORTS) {
396
+ try {
397
+ await deleteRP(`http://127.0.0.1:${port}`);
398
+ } catch {
399
+ // Ignore
400
+ }
401
+ }
402
+ });
403
+
404
+ it('handles credentials for different origins independently', async () => {
405
+ const credentials = [
406
+ { port: TEST_PORTS[0], username: 'user1@site1.com', password: 'Pass1!' },
407
+ { port: TEST_PORTS[1], username: 'user2@site2.com', password: 'Pass2!' },
408
+ ];
409
+
410
+ const context = browser.contexts()[0];
411
+ const page = context.pages()[0] || await context.newPage();
412
+
413
+ // Register credentials for multiple sites
414
+ for (const cred of credentials) {
415
+ await resetRateLimit(); // Reset rate limit for each registration
416
+ await page.goto(`http://127.0.0.1:${cred.port}`);
417
+ await visualDelay(400);
418
+
419
+ execSync(
420
+ `node ${CLI_PATH} register ` +
421
+ `--cdp "${wsEndpoint}" ` +
422
+ `--username-selector "#email" ` +
423
+ `--password-selector "#password" ` +
424
+ `--username "${cred.username}" ` +
425
+ `--password "${cred.password}" ` +
426
+ `--allow-http ` +
427
+ `--force`,
428
+ { encoding: 'utf-8', cwd: process.cwd() }
429
+ );
430
+
431
+ await visualDelay(600);
432
+ }
433
+
434
+ // Verify each origin has correct credentials
435
+ for (const cred of credentials) {
436
+ const origin = `http://127.0.0.1:${cred.port}`;
437
+ const stored = await getRP(origin);
438
+ expect(stored).not.toBeNull();
439
+ expect(stored?.credentials.username).toBe(cred.username);
440
+ expect(stored?.credentials.password).toBe(cred.password);
441
+ }
442
+
443
+ // Test that login fills the correct credentials for each origin
444
+ for (const cred of credentials) {
445
+ await resetRateLimit(); // Reset rate limit for each login
446
+ await page.goto(`http://127.0.0.1:${cred.port}`);
447
+ await visualDelay(400);
448
+
449
+ // Verify form is empty
450
+ expect(await page.inputValue('#email')).toBe('');
451
+
452
+ execSync(
453
+ `node ${CLI_PATH} login --cdp "${wsEndpoint}"`,
454
+ { encoding: 'utf-8', cwd: process.cwd() }
455
+ );
456
+
457
+ await visualDelay(800); // Watch the credentials get filled
458
+
459
+ // Verify correct credentials were filled
460
+ expect(await page.inputValue('#email')).toBe(cred.username);
461
+ expect(await page.inputValue('#password')).toBe(cred.password);
462
+ }
463
+ });
464
+ });
465
+
466
+ describe('config command', () => {
467
+ let originalConfig: Awaited<ReturnType<typeof loadConfig>>;
468
+
469
+ beforeAll(async () => {
470
+ // Save original config to restore after tests
471
+ originalConfig = await loadConfig();
472
+ });
473
+
474
+ afterAll(async () => {
475
+ // Restore original config
476
+ await saveConfig(originalConfig);
477
+ });
478
+
479
+ afterEach(async () => {
480
+ // Reset config between tests
481
+ await saveConfig({});
482
+ });
483
+
484
+ it('sets and gets a config value', () => {
485
+ // Set a value
486
+ const setOutput = execSync(
487
+ `node ${CLI_PATH} config set defaultUsername config-test@example.com`,
488
+ { encoding: 'utf-8', cwd: process.cwd() }
489
+ );
490
+ expect(setOutput).toContain('Set defaultUsername=config-test@example.com');
491
+
492
+ // Get the value
493
+ const getOutput = execSync(
494
+ `node ${CLI_PATH} config get defaultUsername`,
495
+ { encoding: 'utf-8', cwd: process.cwd() }
496
+ );
497
+ expect(getOutput.trim()).toBe('config-test@example.com');
498
+ });
499
+
500
+ it('lists config values', async () => {
501
+ // Set a value first
502
+ execSync(
503
+ `node ${CLI_PATH} config set defaultUsername list-test@example.com`,
504
+ { encoding: 'utf-8', cwd: process.cwd() }
505
+ );
506
+
507
+ // List config
508
+ const output = execSync(
509
+ `node ${CLI_PATH} config list`,
510
+ { encoding: 'utf-8', cwd: process.cwd() }
511
+ );
512
+ expect(output).toContain('defaultUsername=list-test@example.com');
513
+ });
514
+
515
+ it('shows empty message when no config is set', async () => {
516
+ // Ensure config is empty
517
+ await saveConfig({});
518
+
519
+ const output = execSync(
520
+ `node ${CLI_PATH} config list`,
521
+ { encoding: 'utf-8', cwd: process.cwd() }
522
+ );
523
+ expect(output).toContain('No configuration values set');
524
+ expect(output).toContain('Available keys: defaultUsername');
525
+ });
526
+
527
+ it('unsets a config value', () => {
528
+ // Set a value first
529
+ execSync(
530
+ `node ${CLI_PATH} config set defaultUsername unset-test@example.com`,
531
+ { encoding: 'utf-8', cwd: process.cwd() }
532
+ );
533
+
534
+ // Unset it
535
+ const unsetOutput = execSync(
536
+ `node ${CLI_PATH} config unset defaultUsername`,
537
+ { encoding: 'utf-8', cwd: process.cwd() }
538
+ );
539
+ expect(unsetOutput).toContain('Unset defaultUsername');
540
+
541
+ // Verify it's gone
542
+ const getOutput = execSync(
543
+ `node ${CLI_PATH} config get defaultUsername`,
544
+ { encoding: 'utf-8', cwd: process.cwd() }
545
+ );
546
+ expect(getOutput.trim()).toBe('(not set)');
547
+ });
548
+
549
+ it('rejects invalid config keys', () => {
550
+ expect(() => {
551
+ execSync(
552
+ `node ${CLI_PATH} config set invalidKey someValue`,
553
+ { encoding: 'utf-8', cwd: process.cwd() }
554
+ );
555
+ }).toThrow(/Invalid config key/);
556
+
557
+ expect(() => {
558
+ execSync(
559
+ `node ${CLI_PATH} config get invalidKey`,
560
+ { encoding: 'utf-8', cwd: process.cwd() }
561
+ );
562
+ }).toThrow(/Invalid config key/);
563
+ });
564
+ });
565
+ });