latchkey 0.1.4 → 1.0.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 (99) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +93 -56
  3. package/dist/integrations/SKILL.md +77 -0
  4. package/dist/package.json +67 -0
  5. package/dist/scripts/encryptFile.d.ts +21 -0
  6. package/dist/scripts/encryptFile.d.ts.map +1 -0
  7. package/dist/scripts/encryptFile.js +101 -0
  8. package/dist/scripts/encryptFile.js.map +1 -0
  9. package/dist/src/apiCredentialStore.d.ts +1 -0
  10. package/dist/src/apiCredentialStore.d.ts.map +1 -1
  11. package/dist/src/apiCredentialStore.js +12 -0
  12. package/dist/src/apiCredentialStore.js.map +1 -1
  13. package/dist/src/apiCredentials.d.ts +116 -1
  14. package/dist/src/apiCredentials.d.ts.map +1 -1
  15. package/dist/src/apiCredentials.js +119 -1
  16. package/dist/src/apiCredentials.js.map +1 -1
  17. package/dist/src/browserState.d.ts +8 -0
  18. package/dist/src/browserState.d.ts.map +1 -0
  19. package/dist/src/browserState.js +21 -0
  20. package/dist/src/browserState.js.map +1 -0
  21. package/dist/src/cli.js +5 -3
  22. package/dist/src/cli.js.map +1 -1
  23. package/dist/src/cliCommands.d.ts.map +1 -1
  24. package/dist/src/cliCommands.js +218 -81
  25. package/dist/src/cliCommands.js.map +1 -1
  26. package/dist/src/config.d.ts +13 -0
  27. package/dist/src/config.d.ts.map +1 -1
  28. package/dist/src/config.js +50 -4
  29. package/dist/src/config.js.map +1 -1
  30. package/dist/src/index.d.ts +1 -1
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +2 -2
  33. package/dist/src/index.js.map +1 -1
  34. package/dist/src/oauthUtils.d.ts +49 -0
  35. package/dist/src/oauthUtils.d.ts.map +1 -0
  36. package/dist/src/oauthUtils.js +183 -0
  37. package/dist/src/oauthUtils.js.map +1 -0
  38. package/dist/src/playwrightUtils.d.ts +14 -1
  39. package/dist/src/playwrightUtils.d.ts.map +1 -1
  40. package/dist/src/playwrightUtils.js +37 -8
  41. package/dist/src/playwrightUtils.js.map +1 -1
  42. package/dist/src/registry.d.ts.map +1 -1
  43. package/dist/src/registry.js +20 -4
  44. package/dist/src/registry.js.map +1 -1
  45. package/dist/src/services/base.d.ts +43 -15
  46. package/dist/src/services/base.d.ts.map +1 -1
  47. package/dist/src/services/base.js +49 -9
  48. package/dist/src/services/base.js.map +1 -1
  49. package/dist/src/services/discord.d.ts +4 -3
  50. package/dist/src/services/discord.d.ts.map +1 -1
  51. package/dist/src/services/discord.js +6 -22
  52. package/dist/src/services/discord.js.map +1 -1
  53. package/dist/src/services/dropbox.d.ts +5 -4
  54. package/dist/src/services/dropbox.d.ts.map +1 -1
  55. package/dist/src/services/dropbox.js +10 -27
  56. package/dist/src/services/dropbox.js.map +1 -1
  57. package/dist/src/services/github.d.ts +5 -4
  58. package/dist/src/services/github.d.ts.map +1 -1
  59. package/dist/src/services/github.js +21 -30
  60. package/dist/src/services/github.js.map +1 -1
  61. package/dist/src/services/google.d.ts +34 -0
  62. package/dist/src/services/google.d.ts.map +1 -0
  63. package/dist/src/services/google.js +336 -0
  64. package/dist/src/services/google.js.map +1 -0
  65. package/dist/src/services/index.d.ts +4 -2
  66. package/dist/src/services/index.d.ts.map +1 -1
  67. package/dist/src/services/index.js +4 -1
  68. package/dist/src/services/index.js.map +1 -1
  69. package/dist/src/services/linear.d.ts +5 -4
  70. package/dist/src/services/linear.d.ts.map +1 -1
  71. package/dist/src/services/linear.js +10 -29
  72. package/dist/src/services/linear.js.map +1 -1
  73. package/dist/src/services/mailchimp.d.ts +11 -0
  74. package/dist/src/services/mailchimp.d.ts.map +1 -0
  75. package/dist/src/services/mailchimp.js +16 -0
  76. package/dist/src/services/mailchimp.js.map +1 -0
  77. package/dist/src/services/notion.d.ts +29 -0
  78. package/dist/src/services/notion.d.ts.map +1 -0
  79. package/dist/src/services/notion.js +102 -0
  80. package/dist/src/services/notion.js.map +1 -0
  81. package/dist/src/services/slack.d.ts +3 -1
  82. package/dist/src/services/slack.d.ts.map +1 -1
  83. package/dist/src/services/slack.js +5 -5
  84. package/dist/src/services/slack.js.map +1 -1
  85. package/dist/src/skillMd.d.ts +2 -0
  86. package/dist/src/skillMd.d.ts.map +1 -0
  87. package/dist/src/skillMd.js +19 -0
  88. package/dist/src/skillMd.js.map +1 -0
  89. package/dist/tests/apiCredentials.test.js +59 -1
  90. package/dist/tests/apiCredentials.test.js.map +1 -1
  91. package/dist/tests/cli.test.js +283 -104
  92. package/dist/tests/cli.test.js.map +1 -1
  93. package/dist/tests/playwrightDownload.test.js +2 -2
  94. package/dist/tests/playwrightDownload.test.js.map +1 -1
  95. package/dist/tests/registry.test.js +28 -3
  96. package/dist/tests/registry.test.js.map +1 -1
  97. package/dist/tests/servicesAgainstRecordings.test.js +3 -0
  98. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  99. package/package.json +6 -6
@@ -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,14 +123,18 @@ 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 = {}) {
129
132
  const mockSlackService = {
130
133
  name: 'slack',
134
+ displayName: 'Slack',
131
135
  baseApiUrls: ['https://slack.com/api/'],
132
136
  loginUrl: 'https://slack.com/signin',
137
+ info: 'Test info for Slack service.',
133
138
  credentialCheckCurlArguments: ['https://slack.com/api/auth.test'],
134
139
  checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
135
140
  getSession: vi.fn().mockReturnValue({
@@ -182,53 +187,60 @@ describe('CLI commands with dependency injection', () => {
182
187
  afterEach(() => {
183
188
  rmSync(tempDir, { recursive: true, force: true });
184
189
  });
185
- describe('services command', () => {
186
- it('should list all services as space-separated names', async () => {
190
+ describe('services list command', () => {
191
+ it('should list all services as JSON', async () => {
187
192
  const deps = createMockDependencies();
188
- await runCommand(['services'], deps);
193
+ await runCommand(['services', 'list'], deps);
189
194
  expect(logs).toHaveLength(1);
190
- const services = (logs[0] ?? '').split(' ');
195
+ const services = JSON.parse(logs[0] ?? '');
191
196
  expect(services).toContain('slack');
192
197
  });
193
198
  });
194
- describe('status command', () => {
195
- 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 () => {
196
201
  const storePath = join(tempDir, 'credentials.json');
197
202
  writeSecureFile(storePath, '{}');
198
203
  const deps = createMockDependencies({
199
204
  config: createMockConfig({ credentialStorePath: storePath }),
200
205
  });
201
- await runCommand(['status', 'slack'], deps);
202
- 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.');
203
212
  });
204
- it('should return valid when credentials are valid', async () => {
213
+ it('should show auth set only for services without browser login', async () => {
205
214
  const storePath = join(tempDir, 'credentials.json');
206
- writeSecureFile(storePath, JSON.stringify({
207
- slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
208
- }));
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
+ };
209
225
  const deps = createMockDependencies({
226
+ registry: new Registry([noLoginService]),
210
227
  config: createMockConfig({ credentialStorePath: storePath }),
211
228
  });
212
- await runCommand(['status', 'slack'], deps);
213
- expect(logs).toContain('valid');
229
+ await runCommand(['services', 'info', 'nologin'], deps);
230
+ const info = JSON.parse(logs[0] ?? '');
231
+ expect(info.authOptions).toEqual(['set']);
214
232
  });
215
- it('should return error for unknown service', async () => {
216
- const deps = createMockDependencies();
217
- await runCommand(['status', 'unknown-service'], deps);
218
- expect(exitCode).toBe(1);
219
- expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
220
- });
221
- 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 () => {
222
234
  const storePath = join(tempDir, 'credentials.json');
223
235
  writeSecureFile(storePath, '{}');
224
236
  const deps = createMockDependencies({
225
- config: createMockConfig({ credentialStorePath: storePath }),
237
+ config: createMockConfig({ credentialStorePath: storePath, browserDisabled: true }),
226
238
  });
227
- await runCommand(['status'], deps);
228
- expect(logs).toHaveLength(1);
229
- 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']);
230
242
  });
231
- it('should return status for all services with mixed statuses', async () => {
243
+ it('should show valid credentials status when credentials are valid', async () => {
232
244
  const storePath = join(tempDir, 'credentials.json');
233
245
  writeSecureFile(storePath, JSON.stringify({
234
246
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
@@ -236,9 +248,15 @@ describe('CLI commands with dependency injection', () => {
236
248
  const deps = createMockDependencies({
237
249
  config: createMockConfig({ credentialStorePath: storePath }),
238
250
  });
239
- await runCommand(['status'], deps);
240
- expect(logs).toHaveLength(1);
241
- 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);
242
260
  });
243
261
  });
244
262
  describe('clear command', () => {
@@ -250,7 +268,7 @@ describe('CLI commands with dependency injection', () => {
250
268
  const deps = createMockDependencies({
251
269
  config: createMockConfig({ credentialStorePath: storePath }),
252
270
  });
253
- await runCommand(['clear', 'slack'], deps);
271
+ await runCommand(['auth', 'clear', 'slack'], deps);
254
272
  expect(logs.some((log) => log.includes('have been cleared'))).toBe(true);
255
273
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
256
274
  expect(storedData.slack).toBeUndefined();
@@ -261,7 +279,7 @@ describe('CLI commands with dependency injection', () => {
261
279
  const deps = createMockDependencies({
262
280
  config: createMockConfig({ credentialStorePath: storePath }),
263
281
  });
264
- await runCommand(['clear', 'slack'], deps);
282
+ await runCommand(['auth', 'clear', 'slack'], deps);
265
283
  expect(logs.some((log) => log.includes('No API credentials found'))).toBe(true);
266
284
  });
267
285
  it('should return error for unknown service', async () => {
@@ -269,13 +287,13 @@ describe('CLI commands with dependency injection', () => {
269
287
  const deps = createMockDependencies({
270
288
  config: createMockConfig({ credentialStorePath: storePath }),
271
289
  });
272
- await runCommand(['clear', 'unknown-service'], deps);
290
+ await runCommand(['auth', 'clear', 'unknown-service'], deps);
273
291
  expect(exitCode).toBe(1);
274
292
  expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
275
293
  });
276
294
  it('should use default config paths', async () => {
277
295
  const deps = createMockDependencies();
278
- await runCommand(['clear', 'slack'], deps);
296
+ await runCommand(['auth', 'clear', 'slack'], deps);
279
297
  // With default paths, should report no credentials found (not error about missing env var)
280
298
  expect(logs.some((log) => log.includes('No API credentials found'))).toBe(true);
281
299
  });
@@ -288,7 +306,7 @@ describe('CLI commands with dependency injection', () => {
288
306
  const deps = createMockDependencies({
289
307
  config: createMockConfig({ credentialStorePath: storePath }),
290
308
  });
291
- await runCommand(['clear', 'slack'], deps);
309
+ await runCommand(['auth', 'clear', 'slack'], deps);
292
310
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
293
311
  expect(storedData.slack).toBeUndefined();
294
312
  expect(storedData.discord).toBeDefined();
@@ -302,7 +320,7 @@ describe('CLI commands with dependency injection', () => {
302
320
  const deps = createMockDependencies({
303
321
  config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
304
322
  });
305
- await runCommand(['clear', '-y'], deps);
323
+ await runCommand(['auth', 'clear', '-y'], deps);
306
324
  expect(existsSync(storePath)).toBe(false);
307
325
  expect(existsSync(browserStatePath)).toBe(false);
308
326
  expect(logs.some((log) => log.includes('Deleted credentials store'))).toBe(true);
@@ -314,10 +332,107 @@ describe('CLI commands with dependency injection', () => {
314
332
  const deps = createMockDependencies({
315
333
  config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
316
334
  });
317
- await runCommand(['clear', '-y'], deps);
335
+ await runCommand(['auth', 'clear', '-y'], deps);
318
336
  expect(logs.some((log) => log.includes('No files to delete'))).toBe(true);
319
337
  });
320
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
+ });
321
436
  describe('curl command', () => {
322
437
  it('should pass arguments to subprocess', async () => {
323
438
  const storePath = join(tempDir, 'credentials.json');
@@ -337,6 +452,18 @@ describe('CLI commands with dependency injection', () => {
337
452
  ]);
338
453
  expect(exitCode).toBe(0);
339
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
+ });
340
467
  it('should pass multiple arguments correctly', async () => {
341
468
  const storePath = join(tempDir, 'credentials.json');
342
469
  writeSecureFile(storePath, JSON.stringify({
@@ -406,8 +533,10 @@ describe('CLI commands with dependency injection', () => {
406
533
  const mockLogin = vi.fn();
407
534
  const mockSlackService = {
408
535
  name: 'slack',
536
+ displayName: 'Slack',
409
537
  baseApiUrls: ['https://slack.com/api/'],
410
538
  loginUrl: 'https://slack.com/signin',
539
+ info: 'Test info for Slack service.',
411
540
  credentialCheckCurlArguments: [],
412
541
  checkApiCredentials: vi.fn(),
413
542
  getSession: vi.fn().mockReturnValue({ login: mockLogin }),
@@ -420,40 +549,62 @@ describe('CLI commands with dependency injection', () => {
420
549
  expect(mockLogin).not.toHaveBeenCalled();
421
550
  expect(capturedArgs).toContain('Authorization: Bearer stored-token');
422
551
  });
423
- it('should call login when no credentials in store', async () => {
552
+ it('should return error when no credentials in store', async () => {
424
553
  const storePath = join(tempDir, 'credentials.json');
425
- const browserStatePath = join(tempDir, 'browser_state.json');
426
- const configPath = join(tempDir, 'config.json');
427
- const fakeBrowserPath = join(tempDir, 'fake-browser');
428
554
  writeSecureFile(storePath, '{}');
429
- // Create a fake browser executable so loadBrowserConfig validation passes
430
- writeFileSync(fakeBrowserPath, '#!/bin/sh\necho fake', { mode: 0o755 });
431
- // Create a config file so the command doesn't fail
432
- writeFileSync(configPath, JSON.stringify({
433
- browser: {
434
- executablePath: fakeBrowserPath,
435
- source: 'system',
436
- discoveredAt: new Date().toISOString(),
437
- },
438
- }), { mode: 0o600 });
439
- const mockLogin = vi
440
- .fn()
441
- .mockResolvedValue(new SlackApiCredentials('new-token', 'new-cookie'));
442
- const mockSlackService = {
443
- name: 'slack',
444
- baseApiUrls: ['https://slack.com/api/'],
445
- loginUrl: 'https://slack.com/signin',
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.',
446
597
  credentialCheckCurlArguments: [],
447
598
  checkApiCredentials: vi.fn(),
448
- getSession: vi.fn().mockReturnValue({ login: mockLogin }),
599
+ // No getSession - service doesn't support browser login
449
600
  };
450
601
  const deps = createMockDependencies({
451
- registry: new Registry([mockSlackService]),
452
- config: createMockConfig({ credentialStorePath: storePath, browserStatePath, configPath }),
602
+ registry: new Registry([noLoginService]),
453
603
  });
454
- await runCommand(['curl', 'https://slack.com/api/test'], deps);
455
- expect(mockLogin).toHaveBeenCalledWith(expect.any(EncryptedStorage), expect.any(Object));
456
- 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);
457
608
  });
458
609
  });
459
610
  });
@@ -488,38 +639,23 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
488
639
  expect(result.stderr).toContain('No service matches URL');
489
640
  expect(result.stderr).toContain('https://unknown-api.example.com');
490
641
  });
491
- });
492
- describe('status command', () => {
493
- it('should return missing when no credentials are stored', () => {
642
+ it('should return error when no credentials exist', () => {
494
643
  writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
495
- const result = runCli(['status', 'slack'], testEnv);
496
- expect(result.exitCode).toBe(0);
497
- expect(result.stdout.trim()).toBe('missing');
498
- });
499
- it('should return error for unknown service', () => {
500
- const result = runCli(['status', 'unknown-service'], testEnv);
644
+ const result = runCli(['curl', 'https://slack.com/api/test'], testEnv);
501
645
  expect(result.exitCode).toBe(1);
502
- expect(result.stderr).toContain('Unknown service');
503
- });
504
- it('should return status for all services when no service name provided', () => {
505
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
506
- const result = runCli(['status'], testEnv);
507
- expect(result.exitCode).toBe(0);
508
- const lines = result.stdout.trim().split('\n');
509
- expect(lines.length).toBeGreaterThan(0);
510
- expect(lines.some((line) => line.includes('slack: missing'))).toBe(true);
511
- expect(lines.some((line) => line.includes('discord: missing'))).toBe(true);
512
- 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');
513
649
  });
514
- it('should return status for all services with mixed statuses', () => {
515
- writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
516
- slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
517
- }));
518
- const result = runCli(['status'], testEnv);
519
- expect(result.exitCode).toBe(0);
520
- const lines = result.stdout.trim().split('\n');
521
- expect(lines.some((line) => line.includes('slack: invalid'))).toBe(true);
522
- 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');
523
659
  });
524
660
  });
525
661
  describe('clear command', () => {
@@ -527,7 +663,7 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
527
663
  writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
528
664
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
529
665
  }));
530
- const result = runCli(['clear', 'slack'], testEnv);
666
+ const result = runCli(['auth', 'clear', 'slack'], testEnv);
531
667
  expect(result.exitCode).toBe(0);
532
668
  expect(result.stdout).toContain('API credentials for slack have been cleared');
533
669
  const storedData = JSON.parse(readSecureFile(testEnv.LATCHKEY_STORE) ?? '{}');
@@ -535,12 +671,12 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
535
671
  });
536
672
  it('should report no credentials found when service has no stored credentials', () => {
537
673
  writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
538
- const result = runCli(['clear', 'slack'], testEnv);
674
+ const result = runCli(['auth', 'clear', 'slack'], testEnv);
539
675
  expect(result.exitCode).toBe(0);
540
676
  expect(result.stdout).toContain('No API credentials found for slack');
541
677
  });
542
678
  it('should return error for unknown service', () => {
543
- const result = runCli(['clear', 'unknown-service'], testEnv);
679
+ const result = runCli(['auth', 'clear', 'unknown-service'], testEnv);
544
680
  expect(result.exitCode).toBe(1);
545
681
  expect(result.stderr).toContain('Unknown service: unknown-service');
546
682
  });
@@ -549,7 +685,7 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
549
685
  slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
550
686
  discord: { objectType: 'authorizationBare', token: 'discord-token' },
551
687
  }));
552
- const result = runCli(['clear', 'slack'], testEnv);
688
+ const result = runCli(['auth', 'clear', 'slack'], testEnv);
553
689
  expect(result.exitCode).toBe(0);
554
690
  const storedData = JSON.parse(readSecureFile(testEnv.LATCHKEY_STORE) ?? '{}');
555
691
  expect(storedData.slack).toBeUndefined();
@@ -559,7 +695,7 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
559
695
  it('should delete both store and browser state with -y flag', () => {
560
696
  writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } }));
561
697
  writeSecureFile(testEnv.LATCHKEY_BROWSER_STATE, '{}');
562
- const result = runCli(['clear', '-y'], testEnv);
698
+ const result = runCli(['auth', 'clear', '-y'], testEnv);
563
699
  expect(result.exitCode).toBe(0);
564
700
  expect(existsSync(testEnv.LATCHKEY_STORE)).toBe(false);
565
701
  expect(existsSync(testEnv.LATCHKEY_BROWSER_STATE)).toBe(false);
@@ -569,23 +705,43 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
569
705
  it('should delete only existing files with -y flag', () => {
570
706
  writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
571
707
  // browser_state does not exist
572
- const result = runCli(['clear', '-y'], testEnv);
708
+ const result = runCli(['auth', 'clear', '-y'], testEnv);
573
709
  expect(result.exitCode).toBe(0);
574
710
  expect(existsSync(testEnv.LATCHKEY_STORE)).toBe(false);
575
711
  expect(result.stdout).toContain(`Deleted credentials store: ${testEnv.LATCHKEY_STORE}`);
576
712
  expect(result.stdout).not.toContain('browser state');
577
713
  });
578
714
  it('should report no files to delete when none exist', () => {
579
- const result = runCli(['clear', '-y'], testEnv);
715
+ const result = runCli(['auth', 'clear', '-y'], testEnv);
580
716
  expect(result.exitCode).toBe(0);
581
717
  expect(result.stdout).toContain('No files to delete');
582
718
  });
583
719
  });
584
- describe('services command', () => {
585
- it('should list all services as space-separated names', () => {
586
- 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);
587
743
  expect(result.exitCode).toBe(0);
588
- const services = result.stdout.trim().split(' ');
744
+ const services = JSON.parse(result.stdout.trim());
589
745
  expect(services).toContain('slack');
590
746
  expect(services).toContain('discord');
591
747
  expect(services).toContain('github');
@@ -593,5 +749,28 @@ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
593
749
  expect(services).toContain('linear');
594
750
  });
595
751
  });
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);
756
+ expect(result.exitCode).toBe(0);
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));
761
+ });
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);
765
+ expect(result.exitCode).toBe(0);
766
+ const info = JSON.parse(result.stdout);
767
+ expect(info.authOptions).toEqual(['set']);
768
+ });
769
+ it('should return error for unknown service', () => {
770
+ const result = runCli(['services', 'info', 'unknown-service'], testEnv);
771
+ expect(result.exitCode).toBe(1);
772
+ expect(result.stderr).toContain('Unknown service');
773
+ });
774
+ });
596
775
  });
597
776
  //# sourceMappingURL=cli.test.js.map