latchkey 0.2.0 → 1.0.1

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 (118) hide show
  1. package/README.md +98 -57
  2. package/dist/integrations/SKILL.md +25 -19
  3. package/dist/package.json +2 -2
  4. package/dist/scripts/codegen/codeGenerator.d.ts +27 -0
  5. package/dist/scripts/codegen/codeGenerator.d.ts.map +1 -0
  6. package/dist/scripts/codegen/codeGenerator.js +220 -0
  7. package/dist/scripts/codegen/codeGenerator.js.map +1 -0
  8. package/dist/scripts/codegen/index.d.ts +27 -0
  9. package/dist/scripts/codegen/index.d.ts.map +1 -0
  10. package/dist/scripts/codegen/index.js +189 -0
  11. package/dist/scripts/codegen/index.js.map +1 -0
  12. package/dist/scripts/codegen/injectedScript.d.ts +6 -0
  13. package/dist/scripts/codegen/injectedScript.d.ts.map +1 -0
  14. package/dist/scripts/codegen/injectedScript.js +657 -0
  15. package/dist/scripts/codegen/injectedScript.js.map +1 -0
  16. package/dist/scripts/codegen/requestMetadataCollector.d.ts +15 -0
  17. package/dist/scripts/codegen/requestMetadataCollector.d.ts.map +1 -0
  18. package/dist/scripts/codegen/requestMetadataCollector.js +48 -0
  19. package/dist/scripts/codegen/requestMetadataCollector.js.map +1 -0
  20. package/dist/scripts/codegen/types.d.ts +77 -0
  21. package/dist/scripts/codegen/types.d.ts.map +1 -0
  22. package/dist/scripts/codegen/types.js +10 -0
  23. package/dist/scripts/codegen/types.js.map +1 -0
  24. package/dist/scripts/codegen.d.ts +24 -0
  25. package/dist/scripts/codegen.d.ts.map +1 -0
  26. package/dist/scripts/codegen.js +95 -0
  27. package/dist/scripts/codegen.js.map +1 -0
  28. package/dist/scripts/cryptFile.js +7 -2
  29. package/dist/scripts/cryptFile.js.map +1 -1
  30. package/dist/src/apiCredentialStore.d.ts +1 -0
  31. package/dist/src/apiCredentialStore.d.ts.map +1 -1
  32. package/dist/src/apiCredentialStore.js +12 -0
  33. package/dist/src/apiCredentialStore.js.map +1 -1
  34. package/dist/src/apiCredentials.d.ts +33 -0
  35. package/dist/src/apiCredentials.d.ts.map +1 -1
  36. package/dist/src/apiCredentials.js +36 -0
  37. package/dist/src/apiCredentials.js.map +1 -1
  38. package/dist/src/cli.js +3 -2
  39. package/dist/src/cli.js.map +1 -1
  40. package/dist/src/cliCommands.d.ts.map +1 -1
  41. package/dist/src/cliCommands.js +158 -126
  42. package/dist/src/cliCommands.js.map +1 -1
  43. package/dist/src/config.d.ts +13 -0
  44. package/dist/src/config.d.ts.map +1 -1
  45. package/dist/src/config.js +56 -16
  46. package/dist/src/config.js.map +1 -1
  47. package/dist/src/encryptedStorage.d.ts +1 -2
  48. package/dist/src/encryptedStorage.d.ts.map +1 -1
  49. package/dist/src/encryptedStorage.js +18 -38
  50. package/dist/src/encryptedStorage.js.map +1 -1
  51. package/dist/src/index.d.ts +2 -2
  52. package/dist/src/index.d.ts.map +1 -1
  53. package/dist/src/index.js +3 -3
  54. package/dist/src/index.js.map +1 -1
  55. package/dist/src/keychain.d.ts +0 -4
  56. package/dist/src/keychain.d.ts.map +1 -1
  57. package/dist/src/keychain.js +0 -13
  58. package/dist/src/keychain.js.map +1 -1
  59. package/dist/src/oauthUtils.d.ts +1 -1
  60. package/dist/src/oauthUtils.d.ts.map +1 -1
  61. package/dist/src/playwrightUtils.d.ts +6 -0
  62. package/dist/src/playwrightUtils.d.ts.map +1 -1
  63. package/dist/src/playwrightUtils.js +12 -0
  64. package/dist/src/playwrightUtils.js.map +1 -1
  65. package/dist/src/registry.d.ts.map +1 -1
  66. package/dist/src/registry.js +20 -4
  67. package/dist/src/registry.js.map +1 -1
  68. package/dist/src/services/base.d.ts +20 -18
  69. package/dist/src/services/base.d.ts.map +1 -1
  70. package/dist/src/services/base.js +37 -1
  71. package/dist/src/services/base.js.map +1 -1
  72. package/dist/src/services/discord.d.ts +2 -3
  73. package/dist/src/services/discord.d.ts.map +1 -1
  74. package/dist/src/services/discord.js +3 -22
  75. package/dist/src/services/discord.js.map +1 -1
  76. package/dist/src/services/dropbox.d.ts +2 -3
  77. package/dist/src/services/dropbox.d.ts.map +1 -1
  78. package/dist/src/services/dropbox.js +3 -22
  79. package/dist/src/services/dropbox.js.map +1 -1
  80. package/dist/src/services/github.d.ts +2 -3
  81. package/dist/src/services/github.d.ts.map +1 -1
  82. package/dist/src/services/github.js +3 -22
  83. package/dist/src/services/github.js.map +1 -1
  84. package/dist/src/services/google.d.ts +3 -4
  85. package/dist/src/services/google.d.ts.map +1 -1
  86. package/dist/src/services/google.js +21 -43
  87. package/dist/src/services/google.js.map +1 -1
  88. package/dist/src/services/index.d.ts +2 -2
  89. package/dist/src/services/index.d.ts.map +1 -1
  90. package/dist/src/services/index.js +2 -1
  91. package/dist/src/services/index.js.map +1 -1
  92. package/dist/src/services/linear.d.ts +2 -3
  93. package/dist/src/services/linear.d.ts.map +1 -1
  94. package/dist/src/services/linear.js +3 -23
  95. package/dist/src/services/linear.js.map +1 -1
  96. package/dist/src/services/mailchimp.d.ts +11 -0
  97. package/dist/src/services/mailchimp.d.ts.map +1 -0
  98. package/dist/src/services/mailchimp.js +16 -0
  99. package/dist/src/services/mailchimp.js.map +1 -0
  100. package/dist/src/services/notion.d.ts +2 -3
  101. package/dist/src/services/notion.d.ts.map +1 -1
  102. package/dist/src/services/notion.js +3 -22
  103. package/dist/src/services/notion.js.map +1 -1
  104. package/dist/src/services/slack.d.ts +1 -1
  105. package/dist/src/services/slack.d.ts.map +1 -1
  106. package/dist/src/services/slack.js +2 -5
  107. package/dist/src/services/slack.js.map +1 -1
  108. package/dist/tests/apiCredentials.test.js +59 -1
  109. package/dist/tests/apiCredentials.test.js.map +1 -1
  110. package/dist/tests/cli.test.js +270 -128
  111. package/dist/tests/cli.test.js.map +1 -1
  112. package/dist/tests/playwrightDownload.test.js +2 -2
  113. package/dist/tests/playwrightDownload.test.js.map +1 -1
  114. package/dist/tests/registry.test.js +14 -2
  115. package/dist/tests/registry.test.js.map +1 -1
  116. package/dist/tests/servicesAgainstRecordings.test.js +3 -0
  117. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  118. package/package.json +2 -2
@@ -1,10 +1,11 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
2
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { execSync } from 'node:child_process';
6
6
  import { Command } from 'commander';
7
7
  import { extractUrlFromCurlArguments, registerCommands, } from '../src/cliCommands.js';
8
+ import { BrowserFlowsNotSupportedError } from '../src/playwrightUtils.js';
8
9
  import { EncryptedStorage } from '../src/encryptedStorage.js';
9
10
  import { Config } from '../src/config.js';
10
11
  import { Registry } from '../src/registry.js';
@@ -122,7 +123,9 @@ describe('CLI commands with dependency injection', () => {
122
123
  encryptionKeyOverride: overrides.encryptionKeyOverride ?? TEST_ENCRYPTION_KEY,
123
124
  serviceName: overrides.serviceName ?? defaultConfig.serviceName,
124
125
  accountName: overrides.accountName ?? defaultConfig.accountName,
126
+ browserDisabled: overrides.browserDisabled ?? false,
125
127
  checkSensitiveFilePermissions: () => undefined,
128
+ checkSystemPrerequisites: () => undefined,
126
129
  };
127
130
  }
128
131
  function createMockDependencies(overrides = {}) {
@@ -184,67 +187,60 @@ describe('CLI commands with dependency injection', () => {
184
187
  afterEach(() => {
185
188
  rmSync(tempDir, { recursive: true, force: true });
186
189
  });
187
- describe('services command', () => {
188
- it('should list all services as space-separated names', async () => {
190
+ describe('services list command', () => {
191
+ it('should list all services as JSON', async () => {
189
192
  const deps = createMockDependencies();
190
- await runCommand(['services'], deps);
193
+ await runCommand(['services', 'list'], deps);
191
194
  expect(logs).toHaveLength(1);
192
- const services = (logs[0] ?? '').split(' ');
195
+ const services = JSON.parse(logs[0] ?? '');
193
196
  expect(services).toContain('slack');
194
197
  });
195
198
  });
196
- describe('info command', () => {
197
- it('should show info for a known service', async () => {
198
- const deps = createMockDependencies();
199
- await runCommand(['info', 'slack'], deps);
200
- expect(logs).toHaveLength(1);
201
- expect(logs[0]).toBe('Test info for Slack service.');
202
- });
203
- it('should return error for unknown service', async () => {
204
- const deps = createMockDependencies();
205
- await runCommand(['info', 'unknown-service'], deps);
206
- expect(exitCode).toBe(1);
207
- expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
208
- });
209
- });
210
- describe('status command', () => {
211
- it('should return missing when no credentials are stored', async () => {
199
+ describe('services info command', () => {
200
+ it('should show login options, credentials status, and developer notes', async () => {
212
201
  const storePath = join(tempDir, 'credentials.json');
213
202
  writeSecureFile(storePath, '{}');
214
203
  const deps = createMockDependencies({
215
204
  config: createMockConfig({ credentialStorePath: storePath }),
216
205
  });
217
- await runCommand(['status', 'slack'], deps);
218
- expect(logs).toContain('missing');
206
+ await runCommand(['services', 'info', 'slack'], deps);
207
+ expect(logs).toHaveLength(1);
208
+ const info = JSON.parse(logs[0] ?? '');
209
+ expect(info.authOptions).toEqual(['browser', 'set']);
210
+ expect(info.credentialStatus).toBe('missing');
211
+ expect(info.developerNotes).toBe('Test info for Slack service.');
219
212
  });
220
- it('should return valid when credentials are valid', async () => {
213
+ it('should show auth set only for services without browser login', async () => {
221
214
  const storePath = join(tempDir, 'credentials.json');
222
- writeSecureFile(storePath, JSON.stringify({
223
- slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
224
- }));
215
+ writeSecureFile(storePath, '{}');
216
+ const noLoginService = {
217
+ name: 'nologin',
218
+ displayName: 'No Login Service',
219
+ baseApiUrls: ['https://nologin.example.com/api/'],
220
+ loginUrl: 'https://nologin.example.com',
221
+ info: 'A service without browser login support.',
222
+ credentialCheckCurlArguments: [],
223
+ checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Missing),
224
+ };
225
225
  const deps = createMockDependencies({
226
+ registry: new Registry([noLoginService]),
226
227
  config: createMockConfig({ credentialStorePath: storePath }),
227
228
  });
228
- await runCommand(['status', 'slack'], deps);
229
- expect(logs).toContain('valid');
230
- });
231
- it('should return error for unknown service', async () => {
232
- const deps = createMockDependencies();
233
- await runCommand(['status', 'unknown-service'], deps);
234
- expect(exitCode).toBe(1);
235
- expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
229
+ await runCommand(['services', 'info', 'nologin'], deps);
230
+ const info = JSON.parse(logs[0] ?? '');
231
+ expect(info.authOptions).toEqual(['set']);
236
232
  });
237
- it('should return status for all services when no service name provided', async () => {
233
+ it('should not list browser in authOptions when LATCHKEY_DISABLE_BROWSER is in effect', async () => {
238
234
  const storePath = join(tempDir, 'credentials.json');
239
235
  writeSecureFile(storePath, '{}');
240
236
  const deps = createMockDependencies({
241
- config: createMockConfig({ credentialStorePath: storePath }),
237
+ config: createMockConfig({ credentialStorePath: storePath, browserDisabled: true }),
242
238
  });
243
- await runCommand(['status'], deps);
244
- expect(logs).toHaveLength(1);
245
- expect(logs[0]).toBe('slack: missing');
239
+ await runCommand(['services', 'info', 'slack'], deps);
240
+ const info = JSON.parse(logs[0] ?? '');
241
+ expect(info.authOptions).toEqual(['set']);
246
242
  });
247
- it('should return status for all services with mixed statuses', async () => {
243
+ it('should show valid credentials status when credentials are valid', async () => {
248
244
  const storePath = join(tempDir, 'credentials.json');
249
245
  writeSecureFile(storePath, JSON.stringify({
250
246
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
@@ -252,9 +248,15 @@ describe('CLI commands with dependency injection', () => {
252
248
  const deps = createMockDependencies({
253
249
  config: createMockConfig({ credentialStorePath: storePath }),
254
250
  });
255
- await runCommand(['status'], deps);
256
- expect(logs).toHaveLength(1);
257
- expect(logs[0]).toBe('slack: valid');
251
+ await runCommand(['services', 'info', 'slack'], deps);
252
+ const info = JSON.parse(logs[0] ?? '');
253
+ expect(info.credentialStatus).toBe('valid');
254
+ });
255
+ it('should return error for unknown service', async () => {
256
+ const deps = createMockDependencies();
257
+ await runCommand(['services', 'info', 'unknown-service'], deps);
258
+ expect(exitCode).toBe(1);
259
+ expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
258
260
  });
259
261
  });
260
262
  describe('clear command', () => {
@@ -266,7 +268,7 @@ describe('CLI commands with dependency injection', () => {
266
268
  const deps = createMockDependencies({
267
269
  config: createMockConfig({ credentialStorePath: storePath }),
268
270
  });
269
- await runCommand(['clear', 'slack'], deps);
271
+ await runCommand(['auth', 'clear', 'slack'], deps);
270
272
  expect(logs.some((log) => log.includes('have been cleared'))).toBe(true);
271
273
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
272
274
  expect(storedData.slack).toBeUndefined();
@@ -277,7 +279,7 @@ describe('CLI commands with dependency injection', () => {
277
279
  const deps = createMockDependencies({
278
280
  config: createMockConfig({ credentialStorePath: storePath }),
279
281
  });
280
- await runCommand(['clear', 'slack'], deps);
282
+ await runCommand(['auth', 'clear', 'slack'], deps);
281
283
  expect(logs.some((log) => log.includes('No API credentials found'))).toBe(true);
282
284
  });
283
285
  it('should return error for unknown service', async () => {
@@ -285,13 +287,13 @@ describe('CLI commands with dependency injection', () => {
285
287
  const deps = createMockDependencies({
286
288
  config: createMockConfig({ credentialStorePath: storePath }),
287
289
  });
288
- await runCommand(['clear', 'unknown-service'], deps);
290
+ await runCommand(['auth', 'clear', 'unknown-service'], deps);
289
291
  expect(exitCode).toBe(1);
290
292
  expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
291
293
  });
292
294
  it('should use default config paths', async () => {
293
295
  const deps = createMockDependencies();
294
- await runCommand(['clear', 'slack'], deps);
296
+ await runCommand(['auth', 'clear', 'slack'], deps);
295
297
  // With default paths, should report no credentials found (not error about missing env var)
296
298
  expect(logs.some((log) => log.includes('No API credentials found'))).toBe(true);
297
299
  });
@@ -304,7 +306,7 @@ describe('CLI commands with dependency injection', () => {
304
306
  const deps = createMockDependencies({
305
307
  config: createMockConfig({ credentialStorePath: storePath }),
306
308
  });
307
- await runCommand(['clear', 'slack'], deps);
309
+ await runCommand(['auth', 'clear', 'slack'], deps);
308
310
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
309
311
  expect(storedData.slack).toBeUndefined();
310
312
  expect(storedData.discord).toBeDefined();
@@ -318,7 +320,7 @@ describe('CLI commands with dependency injection', () => {
318
320
  const deps = createMockDependencies({
319
321
  config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
320
322
  });
321
- await runCommand(['clear', '-y'], deps);
323
+ await runCommand(['auth', 'clear', '-y'], deps);
322
324
  expect(existsSync(storePath)).toBe(false);
323
325
  expect(existsSync(browserStatePath)).toBe(false);
324
326
  expect(logs.some((log) => log.includes('Deleted credentials store'))).toBe(true);
@@ -330,10 +332,107 @@ describe('CLI commands with dependency injection', () => {
330
332
  const deps = createMockDependencies({
331
333
  config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
332
334
  });
333
- await runCommand(['clear', '-y'], deps);
335
+ await runCommand(['auth', 'clear', '-y'], deps);
334
336
  expect(logs.some((log) => log.includes('No files to delete'))).toBe(true);
335
337
  });
336
338
  });
339
+ describe('auth list command', () => {
340
+ it('should list stored credentials with their status', async () => {
341
+ const storePath = join(tempDir, 'credentials.json');
342
+ writeSecureFile(storePath, JSON.stringify({
343
+ slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
344
+ }));
345
+ const deps = createMockDependencies({
346
+ config: createMockConfig({ credentialStorePath: storePath }),
347
+ });
348
+ await runCommand(['auth', 'list'], deps);
349
+ expect(logs).toHaveLength(1);
350
+ const entries = JSON.parse(logs[0] ?? '');
351
+ expect(entries.slack).toEqual({
352
+ credentialType: 'slack',
353
+ credentialStatus: 'valid',
354
+ });
355
+ });
356
+ it('should output empty object when no credentials are stored', async () => {
357
+ const storePath = join(tempDir, 'credentials.json');
358
+ writeSecureFile(storePath, '{}');
359
+ const deps = createMockDependencies({
360
+ config: createMockConfig({ credentialStorePath: storePath }),
361
+ });
362
+ await runCommand(['auth', 'list'], deps);
363
+ expect(logs).toHaveLength(1);
364
+ const entries = JSON.parse(logs[0] ?? '');
365
+ expect(Object.keys(entries)).toHaveLength(0);
366
+ });
367
+ it('should treat unknown services as valid', async () => {
368
+ const storePath = join(tempDir, 'credentials.json');
369
+ writeSecureFile(storePath, JSON.stringify({
370
+ unknown: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: secret'] },
371
+ }));
372
+ const deps = createMockDependencies({
373
+ config: createMockConfig({ credentialStorePath: storePath }),
374
+ });
375
+ await runCommand(['auth', 'list'], deps);
376
+ expect(logs).toHaveLength(1);
377
+ const entries = JSON.parse(logs[0] ?? '');
378
+ expect(entries.unknown).toEqual({
379
+ credentialType: 'rawCurl',
380
+ credentialStatus: 'valid',
381
+ });
382
+ });
383
+ });
384
+ describe('auth set command', () => {
385
+ it('should store raw curl credentials', async () => {
386
+ const storePath = join(tempDir, 'credentials.json');
387
+ writeSecureFile(storePath, '{}');
388
+ const deps = createMockDependencies({
389
+ config: createMockConfig({ credentialStorePath: storePath }),
390
+ });
391
+ await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: secret', '-H', 'X-Other: value'], deps);
392
+ expect(logs).toContain('Credentials stored.');
393
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
394
+ expect(storedData.slack).toEqual({
395
+ objectType: 'rawCurl',
396
+ curlArguments: ['-H', 'X-Token: secret', '-H', 'X-Other: value'],
397
+ });
398
+ });
399
+ it('should return error for empty curl arguments', async () => {
400
+ const deps = createMockDependencies();
401
+ await runCommand(['auth', 'set', 'slack'], deps);
402
+ expect(exitCode).toBe(1);
403
+ expect(errorLogs.some((log) => log.includes("don't look like valid curl options"))).toBe(true);
404
+ expect(errorLogs.some((log) => log.includes('Authorization: Bearer'))).toBe(true);
405
+ });
406
+ it('should return error when arguments lack curl switches', async () => {
407
+ const deps = createMockDependencies();
408
+ await runCommand(['auth', 'set', 'slack', 'my-raw-token-value'], deps);
409
+ expect(exitCode).toBe(1);
410
+ expect(errorLogs.some((log) => log.includes("don't look like valid curl options"))).toBe(true);
411
+ expect(errorLogs.some((log) => log.includes('Authorization: Bearer'))).toBe(true);
412
+ });
413
+ it('should return error for unknown service', async () => {
414
+ const deps = createMockDependencies();
415
+ await runCommand(['auth', 'set', 'unknown-service', '-H', 'X-Token: secret'], deps);
416
+ expect(exitCode).toBe(1);
417
+ expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
418
+ });
419
+ it('should overwrite existing credentials', async () => {
420
+ const storePath = join(tempDir, 'credentials.json');
421
+ writeSecureFile(storePath, JSON.stringify({
422
+ slack: { objectType: 'slack', token: 'old-token', dCookie: 'old-cookie' },
423
+ }));
424
+ const deps = createMockDependencies({
425
+ config: createMockConfig({ credentialStorePath: storePath }),
426
+ });
427
+ await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: new-secret'], deps);
428
+ expect(logs).toContain('Credentials stored.');
429
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
430
+ expect(storedData.slack).toEqual({
431
+ objectType: 'rawCurl',
432
+ curlArguments: ['-H', 'X-Token: new-secret'],
433
+ });
434
+ });
435
+ });
337
436
  describe('curl command', () => {
338
437
  it('should pass arguments to subprocess', async () => {
339
438
  const storePath = join(tempDir, 'credentials.json');
@@ -353,6 +452,18 @@ describe('CLI commands with dependency injection', () => {
353
452
  ]);
354
453
  expect(exitCode).toBe(0);
355
454
  });
455
+ it('should pass raw curl credentials to subprocess', async () => {
456
+ const storePath = join(tempDir, 'credentials.json');
457
+ writeSecureFile(storePath, JSON.stringify({
458
+ slack: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Custom: header'] },
459
+ }));
460
+ const deps = createMockDependencies({
461
+ config: createMockConfig({ credentialStorePath: storePath }),
462
+ });
463
+ await runCommand(['curl', 'https://slack.com/api/test'], deps);
464
+ expect(capturedArgs).toEqual(['-H', 'X-Custom: header', 'https://slack.com/api/test']);
465
+ expect(exitCode).toBe(0);
466
+ });
356
467
  it('should pass multiple arguments correctly', async () => {
357
468
  const storePath = join(tempDir, 'credentials.json');
358
469
  writeSecureFile(storePath, JSON.stringify({
@@ -438,42 +549,62 @@ describe('CLI commands with dependency injection', () => {
438
549
  expect(mockLogin).not.toHaveBeenCalled();
439
550
  expect(capturedArgs).toContain('Authorization: Bearer stored-token');
440
551
  });
441
- it('should call login when no credentials in store', async () => {
552
+ it('should return error when no credentials in store', async () => {
442
553
  const storePath = join(tempDir, 'credentials.json');
443
- const browserStatePath = join(tempDir, 'browser_state.json');
444
- const configPath = join(tempDir, 'config.json');
445
- const fakeBrowserPath = join(tempDir, 'fake-browser');
446
554
  writeSecureFile(storePath, '{}');
447
- // Create a fake browser executable so loadBrowserConfig validation passes
448
- writeFileSync(fakeBrowserPath, '#!/bin/sh\necho fake', { mode: 0o755 });
449
- // Create a config file so the command doesn't fail
450
- writeFileSync(configPath, JSON.stringify({
451
- browser: {
452
- executablePath: fakeBrowserPath,
453
- source: 'system',
454
- discoveredAt: new Date().toISOString(),
455
- },
456
- }), { mode: 0o600 });
457
- const mockLogin = vi
458
- .fn()
459
- .mockResolvedValue(new SlackApiCredentials('new-token', 'new-cookie'));
460
- const mockSlackService = {
461
- name: 'slack',
462
- displayName: 'Slack',
463
- baseApiUrls: ['https://slack.com/api/'],
464
- loginUrl: 'https://slack.com/signin',
465
- info: 'Test info for Slack service.',
555
+ const deps = createMockDependencies({
556
+ config: createMockConfig({ credentialStorePath: storePath }),
557
+ });
558
+ await runCommand(['curl', 'https://slack.com/api/test'], deps);
559
+ expect(exitCode).toBe(1);
560
+ expect(errorLogs.some((log) => log.includes('No credentials found for slack'))).toBe(true);
561
+ expect(errorLogs.some((log) => log.includes('auth browser'))).toBe(true);
562
+ expect(errorLogs.some((log) => log.includes('auth set'))).toBe(true);
563
+ });
564
+ it('should work when service does not have getSession but credentials exist', async () => {
565
+ const storePath = join(tempDir, 'credentials.json');
566
+ writeSecureFile(storePath, JSON.stringify({
567
+ nologin: { objectType: 'rawCurl', curlArguments: ['-H', 'X-API-Key: secret'] },
568
+ }));
569
+ const noLoginService = {
570
+ name: 'nologin',
571
+ displayName: 'No Login Service',
572
+ baseApiUrls: ['https://nologin.example.com/api/'],
573
+ loginUrl: 'https://nologin.example.com',
574
+ info: 'A service without browser login support.',
575
+ credentialCheckCurlArguments: [],
576
+ checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
577
+ // No getSession - service doesn't support browser login
578
+ };
579
+ const deps = createMockDependencies({
580
+ registry: new Registry([noLoginService]),
581
+ config: createMockConfig({ credentialStorePath: storePath }),
582
+ });
583
+ await runCommand(['curl', 'https://nologin.example.com/api/test'], deps);
584
+ expect(exitCode).toBe(0);
585
+ expect(capturedArgs).toContain('-H');
586
+ expect(capturedArgs).toContain('X-API-Key: secret');
587
+ });
588
+ });
589
+ describe('auth browser command', () => {
590
+ it('should return error when service does not support browser login', async () => {
591
+ const noLoginService = {
592
+ name: 'nologin',
593
+ displayName: 'No Login Service',
594
+ baseApiUrls: ['https://nologin.example.com/api/'],
595
+ loginUrl: 'https://nologin.example.com',
596
+ info: 'A service without browser login support.',
466
597
  credentialCheckCurlArguments: [],
467
598
  checkApiCredentials: vi.fn(),
468
- getSession: vi.fn().mockReturnValue({ login: mockLogin }),
599
+ // No getSession - service doesn't support browser login
469
600
  };
470
601
  const deps = createMockDependencies({
471
- registry: new Registry([mockSlackService]),
472
- config: createMockConfig({ credentialStorePath: storePath, browserStatePath, configPath }),
602
+ registry: new Registry([noLoginService]),
473
603
  });
474
- await runCommand(['curl', 'https://slack.com/api/test'], deps);
475
- expect(mockLogin).toHaveBeenCalledWith(expect.any(EncryptedStorage), expect.any(Object), undefined);
476
- expect(capturedArgs).toContain('Authorization: Bearer new-token');
604
+ await runCommand(['auth', 'browser', 'nologin'], deps);
605
+ expect(exitCode).toBe(1);
606
+ const expectedMessage = new BrowserFlowsNotSupportedError('nologin').message;
607
+ expect(errorLogs.some((log) => log.includes(expectedMessage))).toBe(true);
477
608
  });
478
609
  });
479
610
  });
@@ -508,38 +639,23 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
508
639
  expect(result.stderr).toContain('No service matches URL');
509
640
  expect(result.stderr).toContain('https://unknown-api.example.com');
510
641
  });
511
- });
512
- describe('status command', () => {
513
- it('should return missing when no credentials are stored', () => {
642
+ it('should return error when no credentials exist', () => {
514
643
  writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
515
- const result = runCli(['status', 'slack'], testEnv);
516
- expect(result.exitCode).toBe(0);
517
- expect(result.stdout.trim()).toBe('missing');
518
- });
519
- it('should return error for unknown service', () => {
520
- const result = runCli(['status', 'unknown-service'], testEnv);
644
+ const result = runCli(['curl', 'https://slack.com/api/test'], testEnv);
521
645
  expect(result.exitCode).toBe(1);
522
- expect(result.stderr).toContain('Unknown service');
523
- });
524
- it('should return status for all services when no service name provided', () => {
525
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
526
- const result = runCli(['status'], testEnv);
527
- expect(result.exitCode).toBe(0);
528
- const lines = result.stdout.trim().split('\n');
529
- expect(lines.length).toBeGreaterThan(0);
530
- expect(lines.some((line) => line.includes('slack: missing'))).toBe(true);
531
- expect(lines.some((line) => line.includes('discord: missing'))).toBe(true);
532
- expect(lines.some((line) => line.includes('github: missing'))).toBe(true);
646
+ expect(result.stderr).toContain('No credentials found for slack');
647
+ expect(result.stderr).toContain('auth browser');
648
+ expect(result.stderr).toContain('auth set');
533
649
  });
534
- it('should return status for all services with mixed statuses', () => {
535
- writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
536
- slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
537
- }));
538
- const result = runCli(['status'], testEnv);
539
- expect(result.exitCode).toBe(0);
540
- const lines = result.stdout.trim().split('\n');
541
- expect(lines.some((line) => line.includes('slack: invalid'))).toBe(true);
542
- expect(lines.some((line) => line.includes('discord: missing'))).toBe(true);
650
+ });
651
+ describe('auth browser command', () => {
652
+ it('should return error when browser is disabled via LATCHKEY_DISABLE_BROWSER', () => {
653
+ const result = runCli(['auth', 'browser', 'slack'], {
654
+ ...testEnv,
655
+ LATCHKEY_DISABLE_BROWSER: '1',
656
+ });
657
+ expect(result.exitCode).toBe(1);
658
+ expect(result.stderr).toContain('Browser is disabled');
543
659
  });
544
660
  });
545
661
  describe('clear command', () => {
@@ -547,7 +663,7 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
547
663
  writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
548
664
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
549
665
  }));
550
- const result = runCli(['clear', 'slack'], testEnv);
666
+ const result = runCli(['auth', 'clear', 'slack'], testEnv);
551
667
  expect(result.exitCode).toBe(0);
552
668
  expect(result.stdout).toContain('API credentials for slack have been cleared');
553
669
  const storedData = JSON.parse(readSecureFile(testEnv.LATCHKEY_STORE) ?? '{}');
@@ -555,12 +671,12 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
555
671
  });
556
672
  it('should report no credentials found when service has no stored credentials', () => {
557
673
  writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
558
- const result = runCli(['clear', 'slack'], testEnv);
674
+ const result = runCli(['auth', 'clear', 'slack'], testEnv);
559
675
  expect(result.exitCode).toBe(0);
560
676
  expect(result.stdout).toContain('No API credentials found for slack');
561
677
  });
562
678
  it('should return error for unknown service', () => {
563
- const result = runCli(['clear', 'unknown-service'], testEnv);
679
+ const result = runCli(['auth', 'clear', 'unknown-service'], testEnv);
564
680
  expect(result.exitCode).toBe(1);
565
681
  expect(result.stderr).toContain('Unknown service: unknown-service');
566
682
  });
@@ -569,7 +685,7 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
569
685
  slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
570
686
  discord: { objectType: 'authorizationBare', token: 'discord-token' },
571
687
  }));
572
- const result = runCli(['clear', 'slack'], testEnv);
688
+ const result = runCli(['auth', 'clear', 'slack'], testEnv);
573
689
  expect(result.exitCode).toBe(0);
574
690
  const storedData = JSON.parse(readSecureFile(testEnv.LATCHKEY_STORE) ?? '{}');
575
691
  expect(storedData.slack).toBeUndefined();
@@ -579,7 +695,7 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
579
695
  it('should delete both store and browser state with -y flag', () => {
580
696
  writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } }));
581
697
  writeSecureFile(testEnv.LATCHKEY_BROWSER_STATE, '{}');
582
- const result = runCli(['clear', '-y'], testEnv);
698
+ const result = runCli(['auth', 'clear', '-y'], testEnv);
583
699
  expect(result.exitCode).toBe(0);
584
700
  expect(existsSync(testEnv.LATCHKEY_STORE)).toBe(false);
585
701
  expect(existsSync(testEnv.LATCHKEY_BROWSER_STATE)).toBe(false);
@@ -589,23 +705,43 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
589
705
  it('should delete only existing files with -y flag', () => {
590
706
  writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
591
707
  // browser_state does not exist
592
- const result = runCli(['clear', '-y'], testEnv);
708
+ const result = runCli(['auth', 'clear', '-y'], testEnv);
593
709
  expect(result.exitCode).toBe(0);
594
710
  expect(existsSync(testEnv.LATCHKEY_STORE)).toBe(false);
595
711
  expect(result.stdout).toContain(`Deleted credentials store: ${testEnv.LATCHKEY_STORE}`);
596
712
  expect(result.stdout).not.toContain('browser state');
597
713
  });
598
714
  it('should report no files to delete when none exist', () => {
599
- const result = runCli(['clear', '-y'], testEnv);
715
+ const result = runCli(['auth', 'clear', '-y'], testEnv);
600
716
  expect(result.exitCode).toBe(0);
601
717
  expect(result.stdout).toContain('No files to delete');
602
718
  });
603
719
  });
604
- describe('services command', () => {
605
- it('should list all services as space-separated names', () => {
606
- const result = runCli(['services'], testEnv);
720
+ describe('auth list command', () => {
721
+ it('should list stored credentials as beautified JSON', () => {
722
+ writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
723
+ slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
724
+ }));
725
+ const result = runCli(['auth', 'list'], testEnv);
726
+ expect(result.exitCode).toBe(0);
727
+ const entries = JSON.parse(result.stdout);
728
+ expect(entries.slack).toBeDefined();
729
+ expect(entries.slack?.credentialType).toBe('slack');
730
+ expect(entries.slack?.credentialStatus).toEqual(expect.any(String));
731
+ });
732
+ it('should output empty object when no credentials are stored', () => {
733
+ writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
734
+ const result = runCli(['auth', 'list'], testEnv);
735
+ expect(result.exitCode).toBe(0);
736
+ const entries = JSON.parse(result.stdout);
737
+ expect(Object.keys(entries)).toHaveLength(0);
738
+ });
739
+ });
740
+ describe('services list command', () => {
741
+ it('should list all services as JSON', () => {
742
+ const result = runCli(['services', 'list'], testEnv);
607
743
  expect(result.exitCode).toBe(0);
608
- const services = result.stdout.trim().split(' ');
744
+ const services = JSON.parse(result.stdout.trim());
609
745
  expect(services).toContain('slack');
610
746
  expect(services).toContain('discord');
611
747
  expect(services).toContain('github');
@@ -613,19 +749,25 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
613
749
  expect(services).toContain('linear');
614
750
  });
615
751
  });
616
- describe('info command', () => {
617
- it('should show info for a known service', () => {
618
- const result = runCli(['info', 'slack'], testEnv);
752
+ describe('services info command', () => {
753
+ it('should show login options, credentials status, and developer notes as JSON', () => {
754
+ writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
755
+ const result = runCli(['services', 'info', 'slack'], testEnv);
619
756
  expect(result.exitCode).toBe(0);
620
- expect(result.stdout).toContain('Slack Web API');
757
+ const info = JSON.parse(result.stdout);
758
+ expect(info.authOptions).toEqual(['browser', 'set']);
759
+ expect(info.credentialStatus).toBe('missing');
760
+ expect(info.developerNotes).toEqual(expect.any(String));
621
761
  });
622
- it('should show info for google service mentioning prepare', () => {
623
- const result = runCli(['info', 'google'], testEnv);
762
+ it('should show auth set only for services without browser login', () => {
763
+ writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
764
+ const result = runCli(['services', 'info', 'mailchimp'], testEnv);
624
765
  expect(result.exitCode).toBe(0);
625
- expect(result.stdout).toContain('prepare');
766
+ const info = JSON.parse(result.stdout);
767
+ expect(info.authOptions).toEqual(['set']);
626
768
  });
627
769
  it('should return error for unknown service', () => {
628
- const result = runCli(['info', 'unknown-service'], testEnv);
770
+ const result = runCli(['services', 'info', 'unknown-service'], testEnv);
629
771
  expect(result.exitCode).toBe(1);
630
772
  expect(result.stderr).toContain('Unknown service');
631
773
  });