latchkey 2.0.0 → 2.2.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 (151) hide show
  1. package/README.md +51 -12
  2. package/dist/integrations/SKILL.md +15 -8
  3. package/dist/package.json +3 -3
  4. package/dist/scripts/codegen.js +1 -1
  5. package/dist/scripts/codegen.js.map +1 -1
  6. package/dist/src/apiCredentials.d.ts +2 -1
  7. package/dist/src/apiCredentials.d.ts.map +1 -1
  8. package/dist/src/apiCredentials.js +1 -0
  9. package/dist/src/apiCredentials.js.map +1 -1
  10. package/dist/src/atomicWrite.d.ts +18 -0
  11. package/dist/src/atomicWrite.d.ts.map +1 -0
  12. package/dist/src/atomicWrite.js +42 -0
  13. package/dist/src/atomicWrite.js.map +1 -0
  14. package/dist/src/browserConfig.d.ts +1 -28
  15. package/dist/src/browserConfig.d.ts.map +1 -1
  16. package/dist/src/browserConfig.js +4 -73
  17. package/dist/src/browserConfig.js.map +1 -1
  18. package/dist/src/cli.js +2 -0
  19. package/dist/src/cli.js.map +1 -1
  20. package/dist/src/cliCommands.d.ts.map +1 -1
  21. package/dist/src/cliCommands.js +112 -4
  22. package/dist/src/cliCommands.js.map +1 -1
  23. package/dist/src/configDataStore.d.ts +43 -0
  24. package/dist/src/configDataStore.d.ts.map +1 -0
  25. package/dist/src/configDataStore.js +109 -0
  26. package/dist/src/configDataStore.js.map +1 -0
  27. package/dist/src/encryptedStorage.d.ts.map +1 -1
  28. package/dist/src/encryptedStorage.js +3 -2
  29. package/dist/src/encryptedStorage.js.map +1 -1
  30. package/dist/src/migrations.d.ts.map +1 -1
  31. package/dist/src/migrations.js +3 -2
  32. package/dist/src/migrations.js.map +1 -1
  33. package/dist/src/oauthUtils.js +1 -1
  34. package/dist/src/oauthUtils.js.map +1 -1
  35. package/dist/src/registeredService.d.ts +20 -0
  36. package/dist/src/registeredService.d.ts.map +1 -0
  37. package/dist/src/registeredService.js +34 -0
  38. package/dist/src/registeredService.js.map +1 -0
  39. package/dist/src/registeredServiceStore.d.ts +24 -0
  40. package/dist/src/registeredServiceStore.d.ts.map +1 -0
  41. package/dist/src/registeredServiceStore.js +70 -0
  42. package/dist/src/registeredServiceStore.js.map +1 -0
  43. package/dist/src/registry.d.ts +11 -1
  44. package/dist/src/registry.d.ts.map +1 -1
  45. package/dist/src/registry.js +55 -5
  46. package/dist/src/registry.js.map +1 -1
  47. package/dist/src/services/aws.d.ts +1 -1
  48. package/dist/src/services/aws.d.ts.map +1 -1
  49. package/dist/src/services/aws.js +1 -1
  50. package/dist/src/services/aws.js.map +1 -1
  51. package/dist/src/services/calendly.d.ts +1 -1
  52. package/dist/src/services/calendly.d.ts.map +1 -1
  53. package/dist/src/services/calendly.js +1 -1
  54. package/dist/src/services/calendly.js.map +1 -1
  55. package/dist/src/services/coolify.d.ts +12 -0
  56. package/dist/src/services/coolify.d.ts.map +1 -0
  57. package/dist/src/services/coolify.js +14 -0
  58. package/dist/src/services/coolify.js.map +1 -0
  59. package/dist/src/services/core/base.d.ts +141 -0
  60. package/dist/src/services/core/base.d.ts.map +1 -0
  61. package/dist/src/services/core/base.js +189 -0
  62. package/dist/src/services/core/base.js.map +1 -0
  63. package/dist/src/services/core/registered.d.ts +24 -0
  64. package/dist/src/services/core/registered.d.ts.map +1 -0
  65. package/dist/src/services/core/registered.js +53 -0
  66. package/dist/src/services/core/registered.js.map +1 -0
  67. package/dist/src/services/discord.d.ts +1 -1
  68. package/dist/src/services/discord.d.ts.map +1 -1
  69. package/dist/src/services/discord.js +1 -1
  70. package/dist/src/services/discord.js.map +1 -1
  71. package/dist/src/services/dropbox.d.ts +1 -1
  72. package/dist/src/services/dropbox.d.ts.map +1 -1
  73. package/dist/src/services/dropbox.js +1 -1
  74. package/dist/src/services/dropbox.js.map +1 -1
  75. package/dist/src/services/figma.d.ts +1 -1
  76. package/dist/src/services/figma.d.ts.map +1 -1
  77. package/dist/src/services/figma.js +1 -1
  78. package/dist/src/services/figma.js.map +1 -1
  79. package/dist/src/services/github.d.ts +1 -1
  80. package/dist/src/services/github.d.ts.map +1 -1
  81. package/dist/src/services/github.js +1 -1
  82. package/dist/src/services/github.js.map +1 -1
  83. package/dist/src/services/gitlab.d.ts +1 -1
  84. package/dist/src/services/gitlab.d.ts.map +1 -1
  85. package/dist/src/services/gitlab.js +1 -1
  86. package/dist/src/services/gitlab.js.map +1 -1
  87. package/dist/src/services/google/base.d.ts +1 -1
  88. package/dist/src/services/google/base.d.ts.map +1 -1
  89. package/dist/src/services/google/base.js +1 -1
  90. package/dist/src/services/google/base.js.map +1 -1
  91. package/dist/src/services/google/directions.d.ts +1 -1
  92. package/dist/src/services/google/directions.d.ts.map +1 -1
  93. package/dist/src/services/google/directions.js +1 -1
  94. package/dist/src/services/google/directions.js.map +1 -1
  95. package/dist/src/services/index.d.ts +5 -2
  96. package/dist/src/services/index.d.ts.map +1 -1
  97. package/dist/src/services/index.js +5 -2
  98. package/dist/src/services/index.js.map +1 -1
  99. package/dist/src/services/linear.d.ts +1 -1
  100. package/dist/src/services/linear.d.ts.map +1 -1
  101. package/dist/src/services/linear.js +1 -1
  102. package/dist/src/services/linear.js.map +1 -1
  103. package/dist/src/services/mailchimp.d.ts +1 -1
  104. package/dist/src/services/mailchimp.d.ts.map +1 -1
  105. package/dist/src/services/mailchimp.js +1 -1
  106. package/dist/src/services/mailchimp.js.map +1 -1
  107. package/dist/src/services/notion.d.ts +1 -1
  108. package/dist/src/services/notion.d.ts.map +1 -1
  109. package/dist/src/services/notion.js +1 -1
  110. package/dist/src/services/notion.js.map +1 -1
  111. package/dist/src/services/sentry.d.ts +1 -1
  112. package/dist/src/services/sentry.d.ts.map +1 -1
  113. package/dist/src/services/sentry.js +1 -1
  114. package/dist/src/services/sentry.js.map +1 -1
  115. package/dist/src/services/slack.d.ts +1 -1
  116. package/dist/src/services/slack.d.ts.map +1 -1
  117. package/dist/src/services/slack.js +1 -1
  118. package/dist/src/services/slack.js.map +1 -1
  119. package/dist/src/services/stripe.d.ts +1 -1
  120. package/dist/src/services/stripe.d.ts.map +1 -1
  121. package/dist/src/services/stripe.js +1 -1
  122. package/dist/src/services/stripe.js.map +1 -1
  123. package/dist/src/services/telegram.d.ts +1 -1
  124. package/dist/src/services/telegram.d.ts.map +1 -1
  125. package/dist/src/services/telegram.js +1 -1
  126. package/dist/src/services/telegram.js.map +1 -1
  127. package/dist/src/services/umami.d.ts +12 -0
  128. package/dist/src/services/umami.d.ts.map +1 -0
  129. package/dist/src/services/umami.js +14 -0
  130. package/dist/src/services/umami.js.map +1 -0
  131. package/dist/src/services/yelp.d.ts +1 -1
  132. package/dist/src/services/yelp.d.ts.map +1 -1
  133. package/dist/src/services/yelp.js +1 -1
  134. package/dist/src/services/yelp.js.map +1 -1
  135. package/dist/src/services/zoom.d.ts +1 -1
  136. package/dist/src/services/zoom.d.ts.map +1 -1
  137. package/dist/src/services/zoom.js +1 -1
  138. package/dist/src/services/zoom.js.map +1 -1
  139. package/dist/tests/atomicWrite.test.d.ts +2 -0
  140. package/dist/tests/atomicWrite.test.d.ts.map +1 -0
  141. package/dist/tests/atomicWrite.test.js +95 -0
  142. package/dist/tests/atomicWrite.test.js.map +1 -0
  143. package/dist/tests/cli.test.js +693 -2
  144. package/dist/tests/cli.test.js.map +1 -1
  145. package/dist/tests/lint.test.js +1 -1
  146. package/dist/tests/lint.test.js.map +1 -1
  147. package/dist/tests/registry.test.js +100 -2
  148. package/dist/tests/registry.test.js.map +1 -1
  149. package/dist/tests/servicesAgainstRecordings.test.js +1 -1
  150. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  151. package/package.json +3 -3
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { mkdtempSync, rmSync, existsSync } from 'node:fs';
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync, 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';
@@ -11,8 +11,13 @@ import { Config } from '../src/config.js';
11
11
  import { Registry } from '../src/registry.js';
12
12
  import { ApiCredentialStatus } from '../src/apiCredentials.js';
13
13
  import { SlackApiCredentials } from '../src/services/slack.js';
14
- import { NoCurlCredentialsNotSupportedError, Service } from '../src/services/base.js';
14
+ import { NoCurlCredentialsNotSupportedError, Service } from '../src/services/core/base.js';
15
+ import { RegisteredService } from '../src/services/core/registered.js';
16
+ import { GITLAB } from '../src/services/gitlab.js';
17
+ import { GITHUB } from '../src/services/github.js';
15
18
  import { TELEGRAM } from '../src/services/telegram.js';
19
+ import { deleteRegisteredService, loadRegisteredServices, saveRegisteredService, } from '../src/configDataStore.js';
20
+ import { loadRegisteredServicesIntoRegistry } from '../src/registry.js';
16
21
  // Use a fixed test key for deterministic test behavior (32 bytes = 256 bits, base64 encoded)
17
22
  const TEST_ENCRYPTION_KEY = 'dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=';
18
23
  function writeSecureFile(path, content) {
@@ -212,6 +217,116 @@ describe('CLI commands with dependency injection', () => {
212
217
  const services = JSON.parse(logs[0] ?? '');
213
218
  expect(services).toContain('slack');
214
219
  });
220
+ it('should include registered services by default', async () => {
221
+ const registeredService = new RegisteredService('my-gitlab', 'https://gitlab.example.com');
222
+ const deps = createMockDependencies();
223
+ deps.registry.addService(registeredService);
224
+ await runCommand(['services', 'list'], deps);
225
+ expect(logs).toHaveLength(1);
226
+ const services = JSON.parse(logs[0] ?? '');
227
+ expect(services).toContain('slack');
228
+ expect(services).toContain('my-gitlab');
229
+ });
230
+ it('should exclude registered services with --builtin', async () => {
231
+ const registeredService = new RegisteredService('my-gitlab', 'https://gitlab.example.com');
232
+ const deps = createMockDependencies();
233
+ deps.registry.addService(registeredService);
234
+ await runCommand(['services', 'list', '--builtin'], deps);
235
+ expect(logs).toHaveLength(1);
236
+ const services = JSON.parse(logs[0] ?? '');
237
+ expect(services).toContain('slack');
238
+ expect(services).not.toContain('my-gitlab');
239
+ });
240
+ it('should include services with stored credentials when using --viable', async () => {
241
+ const storePath = join(tempDir, 'credentials.json');
242
+ writeSecureFile(storePath, JSON.stringify({
243
+ slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
244
+ }));
245
+ const deps = createMockDependencies();
246
+ await runCommand(['services', 'list', '--viable'], deps);
247
+ expect(logs).toHaveLength(1);
248
+ const services = JSON.parse(logs[0] ?? '');
249
+ expect(services).toContain('slack');
250
+ });
251
+ it('should include services with browser auth when using --viable', async () => {
252
+ const storePath = join(tempDir, 'credentials.json');
253
+ writeSecureFile(storePath, '{}');
254
+ // The default mock slack service has getSession defined, so it supports browser auth
255
+ const deps = createMockDependencies();
256
+ await runCommand(['services', 'list', '--viable'], deps);
257
+ expect(logs).toHaveLength(1);
258
+ const services = JSON.parse(logs[0] ?? '');
259
+ expect(services).toContain('slack');
260
+ });
261
+ it('should exclude services without credentials or browser auth when using --viable', async () => {
262
+ const storePath = join(tempDir, 'credentials.json');
263
+ writeSecureFile(storePath, '{}');
264
+ const noLoginService = {
265
+ name: 'nologin',
266
+ displayName: 'No Login Service',
267
+ baseApiUrls: ['https://nologin.example.com/api/'],
268
+ loginUrl: 'https://nologin.example.com',
269
+ info: 'A service without browser login support.',
270
+ credentialCheckCurlArguments: [],
271
+ checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Missing),
272
+ setCredentialsExample(serviceName) {
273
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
274
+ },
275
+ getCredentialsNoCurl() {
276
+ throw new NoCurlCredentialsNotSupportedError('nologin');
277
+ },
278
+ };
279
+ const deps = createMockDependencies({
280
+ registry: new Registry([noLoginService]),
281
+ });
282
+ await runCommand(['services', 'list', '--viable'], deps);
283
+ expect(logs).toHaveLength(1);
284
+ const services = JSON.parse(logs[0] ?? '');
285
+ expect(services).not.toContain('nologin');
286
+ });
287
+ it('should exclude browser-capable services when browser is disabled and no credentials with --viable', async () => {
288
+ const storePath = join(tempDir, 'credentials.json');
289
+ writeSecureFile(storePath, '{}');
290
+ const deps = createMockDependencies({
291
+ config: createMockConfig({ browserDisabled: true }),
292
+ });
293
+ await runCommand(['services', 'list', '--viable'], deps);
294
+ expect(logs).toHaveLength(1);
295
+ const services = JSON.parse(logs[0] ?? '');
296
+ expect(services).not.toContain('slack');
297
+ });
298
+ it('should include services with credentials even when browser is disabled with --viable', async () => {
299
+ const storePath = join(tempDir, 'credentials.json');
300
+ writeSecureFile(storePath, JSON.stringify({
301
+ slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
302
+ }));
303
+ const deps = createMockDependencies({
304
+ config: createMockConfig({ browserDisabled: true }),
305
+ });
306
+ await runCommand(['services', 'list', '--viable'], deps);
307
+ expect(logs).toHaveLength(1);
308
+ const services = JSON.parse(logs[0] ?? '');
309
+ expect(services).toContain('slack');
310
+ });
311
+ it('should combine --builtin and --viable filters', async () => {
312
+ const storePath = join(tempDir, 'credentials.json');
313
+ writeSecureFile(storePath, JSON.stringify({
314
+ 'my-gitlab': {
315
+ objectType: 'rawCurl',
316
+ curlArguments: ['-H', 'PRIVATE-TOKEN: token'],
317
+ },
318
+ }));
319
+ const registeredService = new RegisteredService('my-gitlab', 'https://gitlab.example.com');
320
+ const deps = createMockDependencies();
321
+ deps.registry.addService(registeredService);
322
+ await runCommand(['services', 'list', '--builtin', '--viable'], deps);
323
+ expect(logs).toHaveLength(1);
324
+ const services = JSON.parse(logs[0] ?? '');
325
+ // slack is built-in and has browser auth, so it's viable
326
+ expect(services).toContain('slack');
327
+ // my-gitlab has credentials but is not built-in, so it's excluded by --builtin
328
+ expect(services).not.toContain('my-gitlab');
329
+ });
215
330
  });
216
331
  describe('services info command', () => {
217
332
  it('should show login options, credentials status, and developer notes', async () => {
@@ -221,6 +336,8 @@ describe('CLI commands with dependency injection', () => {
221
336
  await runCommand(['services', 'info', 'slack'], deps);
222
337
  expect(logs).toHaveLength(1);
223
338
  const info = JSON.parse(logs[0] ?? '');
339
+ expect(info.type).toBe('built-in');
340
+ expect(info.baseApiUrls).toEqual(['https://slack.com/api/']);
224
341
  expect(info.authOptions).toEqual(['browser', 'set']);
225
342
  expect(info.credentialStatus).toBe('missing');
226
343
  expect(info.setCredentialsExample).toBe('latchkey auth set slack -H "Authorization: Bearer xoxb-your-token"');
@@ -277,6 +394,16 @@ describe('CLI commands with dependency injection', () => {
277
394
  expect(exitCode).toBe(1);
278
395
  expect(errorLogs.length).toBeGreaterThan(0);
279
396
  });
397
+ it('should show type as registered for registered services', async () => {
398
+ const storePath = join(tempDir, 'credentials.json');
399
+ writeSecureFile(storePath, '{}');
400
+ const registeredService = new RegisteredService('my-gitlab', 'https://gitlab.example.com');
401
+ const deps = createMockDependencies();
402
+ deps.registry.addService(registeredService);
403
+ await runCommand(['services', 'info', 'my-gitlab'], deps);
404
+ const info = JSON.parse(logs[0] ?? '');
405
+ expect(info.type).toBe('user-registered');
406
+ });
280
407
  });
281
408
  describe('clear command', () => {
282
409
  it('should delete credentials for a service', async () => {
@@ -639,6 +766,570 @@ describe('CLI commands with dependency injection', () => {
639
766
  expect(exitCode).toBe(1);
640
767
  });
641
768
  });
769
+ describe('services register command', () => {
770
+ it('should register a new service', async () => {
771
+ const deps = createMockDependencies({
772
+ registry: new Registry([GITLAB]),
773
+ });
774
+ await runCommand([
775
+ 'services',
776
+ 'register',
777
+ 'my-gitlab',
778
+ '--base-api-url',
779
+ 'https://gitlab.mycompany.com/api/',
780
+ '--service-family',
781
+ 'gitlab',
782
+ ], deps);
783
+ expect(exitCode).toBeNull();
784
+ expect(logs).toContain("Service 'my-gitlab' registered.");
785
+ // Should be findable by name
786
+ expect(deps.registry.getByName('my-gitlab')).not.toBeNull();
787
+ // Should be findable by URL
788
+ expect(deps.registry.getByUrl('https://gitlab.mycompany.com/api/v4/user')).not.toBeNull();
789
+ });
790
+ it('should persist registration to config.json', async () => {
791
+ const deps = createMockDependencies({
792
+ registry: new Registry([GITLAB]),
793
+ });
794
+ await runCommand([
795
+ 'services',
796
+ 'register',
797
+ 'my-gitlab',
798
+ '--base-api-url',
799
+ 'https://gitlab.mycompany.com/api/',
800
+ '--service-family',
801
+ 'gitlab',
802
+ ], deps);
803
+ const configPath = deps.config.configPath;
804
+ const entries = loadRegisteredServices(configPath);
805
+ expect(entries.get('my-gitlab')).toEqual({
806
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
807
+ serviceFamily: 'gitlab',
808
+ });
809
+ });
810
+ it('should reject unknown service family', async () => {
811
+ const deps = createMockDependencies({
812
+ registry: new Registry([GITLAB]),
813
+ });
814
+ await runCommand([
815
+ 'services',
816
+ 'register',
817
+ 'my-service',
818
+ '--base-api-url',
819
+ 'https://example.com/api/',
820
+ '--service-family',
821
+ 'nonexistent',
822
+ ], deps);
823
+ expect(exitCode).toBe(1);
824
+ expect(errorLogs[0]).toContain('Unknown service family');
825
+ });
826
+ it('should reject duplicate service name', async () => {
827
+ const deps = createMockDependencies({
828
+ registry: new Registry([GITLAB]),
829
+ });
830
+ await runCommand([
831
+ 'services',
832
+ 'register',
833
+ 'gitlab',
834
+ '--base-api-url',
835
+ 'https://gitlab.mycompany.com/api/',
836
+ '--service-family',
837
+ 'gitlab',
838
+ ], deps);
839
+ expect(exitCode).toBe(1);
840
+ expect(errorLogs[0]).toContain('already exists');
841
+ });
842
+ it('should canonicalize service name to lowercase', async () => {
843
+ const deps = createMockDependencies({
844
+ registry: new Registry([GITLAB]),
845
+ });
846
+ await runCommand([
847
+ 'services',
848
+ 'register',
849
+ 'My-GitLab',
850
+ '--base-api-url',
851
+ 'https://gitlab.mycompany.com/api/',
852
+ '--service-family',
853
+ 'gitlab',
854
+ ], deps);
855
+ expect(exitCode).toBeNull();
856
+ expect(logs).toContain("Service 'my-gitlab' registered.");
857
+ expect(deps.registry.getByName('my-gitlab')).not.toBeNull();
858
+ });
859
+ it('should convert spaces to hyphens in service name', async () => {
860
+ const deps = createMockDependencies({
861
+ registry: new Registry([GITLAB]),
862
+ });
863
+ await runCommand(['services', 'register', 'my api', '--base-api-url', 'https://api.example.com/'], deps);
864
+ expect(exitCode).toBeNull();
865
+ expect(logs).toContain("Service 'my-api' registered.");
866
+ expect(deps.registry.getByName('my-api')).not.toBeNull();
867
+ });
868
+ it('should reject service name with invalid characters', async () => {
869
+ const deps = createMockDependencies({
870
+ registry: new Registry([GITLAB]),
871
+ });
872
+ await runCommand(['services', 'register', 'my@service!', '--base-api-url', 'https://api.example.com/'], deps);
873
+ expect(exitCode).toBe(1);
874
+ expect(errorLogs[0]).toContain('Invalid service name');
875
+ });
876
+ it('should not expose browser auth without --login-url', async () => {
877
+ const storePath = join(tempDir, 'credentials.json');
878
+ writeSecureFile(storePath, '{}');
879
+ const deps = createMockDependencies({
880
+ registry: new Registry([GITLAB]),
881
+ });
882
+ await runCommand([
883
+ 'services',
884
+ 'register',
885
+ 'my-gitlab',
886
+ '--base-api-url',
887
+ 'https://gitlab.mycompany.com/api/',
888
+ '--service-family',
889
+ 'gitlab',
890
+ ], deps);
891
+ logs = [];
892
+ exitCode = null;
893
+ await runCommand(['services', 'info', 'my-gitlab'], deps);
894
+ const info = JSON.parse(logs[0] ?? '');
895
+ expect(info.authOptions).toEqual(['set']);
896
+ });
897
+ it('should persist and restore loginUrl', async () => {
898
+ const deps = createMockDependencies({
899
+ registry: new Registry([GITHUB]),
900
+ });
901
+ await runCommand([
902
+ 'services',
903
+ 'register',
904
+ 'my-github',
905
+ '--base-api-url',
906
+ 'https://github.mycompany.com/api/',
907
+ '--service-family',
908
+ 'github',
909
+ '--login-url',
910
+ 'https://github.mycompany.com/login',
911
+ ], deps);
912
+ const entries = loadRegisteredServices(deps.config.configPath);
913
+ expect(entries.get('my-github')?.loginUrl).toBe('https://github.mycompany.com/login');
914
+ });
915
+ it('should reject --login-url without --service-family', async () => {
916
+ const deps = createMockDependencies({
917
+ registry: new Registry([]),
918
+ });
919
+ await runCommand([
920
+ 'services',
921
+ 'register',
922
+ 'my-service',
923
+ '--base-api-url',
924
+ 'https://example.com/api/',
925
+ '--login-url',
926
+ 'https://example.com/login',
927
+ ], deps);
928
+ expect(exitCode).toBe(1);
929
+ expect(errorLogs[0]).toContain('--login-url requires a --service-family');
930
+ });
931
+ it('should reject --login-url when service family does not support browser login', async () => {
932
+ const deps = createMockDependencies({
933
+ registry: new Registry([GITLAB]),
934
+ });
935
+ await runCommand([
936
+ 'services',
937
+ 'register',
938
+ 'my-gitlab',
939
+ '--base-api-url',
940
+ 'https://gitlab.mycompany.com/api/',
941
+ '--service-family',
942
+ 'gitlab',
943
+ '--login-url',
944
+ 'https://gitlab.mycompany.com/users/sign_in',
945
+ ], deps);
946
+ expect(exitCode).toBe(1);
947
+ expect(errorLogs[0]).toContain('does not support browser login');
948
+ });
949
+ it('should require --login-url when service family supports browser login', async () => {
950
+ const deps = createMockDependencies({
951
+ registry: new Registry([GITHUB]),
952
+ });
953
+ await runCommand([
954
+ 'services',
955
+ 'register',
956
+ 'my-github',
957
+ '--base-api-url',
958
+ 'https://github.mycompany.com/api/',
959
+ '--service-family',
960
+ 'github',
961
+ ], deps);
962
+ expect(exitCode).toBe(1);
963
+ expect(errorLogs[0]).toContain('--login-url is required');
964
+ });
965
+ it('should make registered service usable with auth set', async () => {
966
+ const deps = createMockDependencies({
967
+ registry: new Registry([GITLAB]),
968
+ });
969
+ // Register the service
970
+ await runCommand([
971
+ 'services',
972
+ 'register',
973
+ 'my-gitlab',
974
+ '--base-api-url',
975
+ 'https://gitlab.mycompany.com/api/',
976
+ '--service-family',
977
+ 'gitlab',
978
+ ], deps);
979
+ // Now store credentials for it
980
+ const storePath = join(tempDir, 'credentials.json');
981
+ writeSecureFile(storePath, '{}');
982
+ logs = [];
983
+ exitCode = null;
984
+ await runCommand(['auth', 'set', 'my-gitlab', '-H', 'PRIVATE-TOKEN: my-secret-token'], deps);
985
+ expect(exitCode).toBeNull();
986
+ expect(logs).toContain('Credentials stored.');
987
+ });
988
+ it('should register a service without --service-family', async () => {
989
+ const deps = createMockDependencies({
990
+ registry: new Registry([GITLAB]),
991
+ });
992
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
993
+ expect(exitCode).toBeNull();
994
+ expect(logs).toContain("Service 'my-api' registered.");
995
+ // Should be findable by name
996
+ expect(deps.registry.getByName('my-api')).not.toBeNull();
997
+ // Should be findable by URL
998
+ expect(deps.registry.getByUrl('https://api.example.com/v1/users')).not.toBeNull();
999
+ });
1000
+ it('should persist registration without service family to config.json', async () => {
1001
+ const deps = createMockDependencies({
1002
+ registry: new Registry([GITLAB]),
1003
+ });
1004
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1005
+ const configPath = deps.config.configPath;
1006
+ const entries = loadRegisteredServices(configPath);
1007
+ expect(entries.get('my-api')).toEqual({
1008
+ baseApiUrl: 'https://api.example.com/',
1009
+ });
1010
+ });
1011
+ it('should not expose browser auth for service without family', async () => {
1012
+ const storePath = join(tempDir, 'credentials.json');
1013
+ writeSecureFile(storePath, '{}');
1014
+ const deps = createMockDependencies({
1015
+ registry: new Registry([GITLAB]),
1016
+ });
1017
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1018
+ logs = [];
1019
+ exitCode = null;
1020
+ await runCommand(['services', 'info', 'my-api'], deps);
1021
+ const info = JSON.parse(logs[0] ?? '');
1022
+ expect(info.authOptions).toEqual(['set']);
1023
+ });
1024
+ it('should make service without family usable with auth set and curl', async () => {
1025
+ const deps = createMockDependencies({
1026
+ registry: new Registry([GITLAB]),
1027
+ });
1028
+ // Register the service without family
1029
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1030
+ // Store credentials
1031
+ const storePath = join(tempDir, 'credentials.json');
1032
+ writeSecureFile(storePath, JSON.stringify({
1033
+ 'my-api': {
1034
+ objectType: 'rawCurl',
1035
+ curlArguments: ['-H', 'Authorization: Bearer my-token'],
1036
+ },
1037
+ }));
1038
+ logs = [];
1039
+ exitCode = null;
1040
+ capturedArgs = [];
1041
+ await runCommand(['curl', 'https://api.example.com/v1/users'], deps);
1042
+ expect(exitCode).toBe(0);
1043
+ expect(capturedArgs).toContain('-H');
1044
+ expect(capturedArgs).toContain('Authorization: Bearer my-token');
1045
+ });
1046
+ it('should reject browser login for service without family', async () => {
1047
+ const deps = createMockDependencies({
1048
+ registry: new Registry([GITLAB]),
1049
+ });
1050
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1051
+ logs = [];
1052
+ errorLogs = [];
1053
+ exitCode = null;
1054
+ await runCommand(['auth', 'browser', 'my-api'], deps);
1055
+ expect(exitCode).toBe(1);
1056
+ expect(errorLogs[0]).toContain('does not support browser flows');
1057
+ });
1058
+ it('should reject set-nocurl for service without family', async () => {
1059
+ const deps = createMockDependencies({
1060
+ registry: new Registry([GITLAB]),
1061
+ });
1062
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1063
+ logs = [];
1064
+ errorLogs = [];
1065
+ exitCode = null;
1066
+ await runCommand(['auth', 'set-nocurl', 'my-api', 'some-token'], deps);
1067
+ expect(exitCode).toBe(1);
1068
+ expect(errorLogs[0]).toContain('does not support set-nocurl');
1069
+ });
1070
+ it('should make registered service usable with curl', async () => {
1071
+ const deps = createMockDependencies({
1072
+ registry: new Registry([GITLAB]),
1073
+ });
1074
+ // Register the service
1075
+ await runCommand([
1076
+ 'services',
1077
+ 'register',
1078
+ 'my-gitlab',
1079
+ '--base-api-url',
1080
+ 'https://gitlab.mycompany.com/api/',
1081
+ '--service-family',
1082
+ 'gitlab',
1083
+ ], deps);
1084
+ // Store credentials
1085
+ const storePath = join(tempDir, 'credentials.json');
1086
+ writeSecureFile(storePath, JSON.stringify({
1087
+ 'my-gitlab': {
1088
+ objectType: 'rawCurl',
1089
+ curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
1090
+ },
1091
+ }));
1092
+ logs = [];
1093
+ exitCode = null;
1094
+ capturedArgs = [];
1095
+ await runCommand(['curl', 'https://gitlab.mycompany.com/api/v4/user'], deps);
1096
+ expect(exitCode).toBe(0);
1097
+ expect(capturedArgs).toContain('-H');
1098
+ expect(capturedArgs).toContain('PRIVATE-TOKEN: my-secret-token');
1099
+ });
1100
+ });
1101
+ describe('services deregister command', () => {
1102
+ it('should deregister a registered service', async () => {
1103
+ const deps = createMockDependencies({
1104
+ registry: new Registry([GITLAB]),
1105
+ });
1106
+ // Register a service first
1107
+ await runCommand([
1108
+ 'services',
1109
+ 'register',
1110
+ 'my-gitlab',
1111
+ '--base-api-url',
1112
+ 'https://gitlab.mycompany.com/api/',
1113
+ '--service-family',
1114
+ 'gitlab',
1115
+ ], deps);
1116
+ logs = [];
1117
+ exitCode = null;
1118
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1119
+ expect(exitCode).toBeNull();
1120
+ expect(logs).toContain("Service 'my-gitlab' deregistered.");
1121
+ });
1122
+ it('should remove service from config.json', async () => {
1123
+ const deps = createMockDependencies({
1124
+ registry: new Registry([GITLAB]),
1125
+ });
1126
+ await runCommand([
1127
+ 'services',
1128
+ 'register',
1129
+ 'my-gitlab',
1130
+ '--base-api-url',
1131
+ 'https://gitlab.mycompany.com/api/',
1132
+ '--service-family',
1133
+ 'gitlab',
1134
+ ], deps);
1135
+ logs = [];
1136
+ exitCode = null;
1137
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1138
+ const entries = loadRegisteredServices(deps.config.configPath);
1139
+ expect(entries.get('my-gitlab')).toBeUndefined();
1140
+ });
1141
+ it('should reject deregistering an unknown service', async () => {
1142
+ const deps = createMockDependencies();
1143
+ await runCommand(['services', 'deregister', 'nonexistent'], deps);
1144
+ expect(exitCode).toBe(1);
1145
+ expect(errorLogs[0]).toContain('Unknown service');
1146
+ });
1147
+ it('should reject deregistering a built-in service', async () => {
1148
+ const deps = createMockDependencies();
1149
+ await runCommand(['services', 'deregister', 'slack'], deps);
1150
+ expect(exitCode).toBe(1);
1151
+ expect(errorLogs[0]).toContain('built-in service');
1152
+ });
1153
+ it('should reject deregistering when credentials still exist', async () => {
1154
+ const deps = createMockDependencies({
1155
+ registry: new Registry([GITLAB]),
1156
+ });
1157
+ // Register a service
1158
+ await runCommand([
1159
+ 'services',
1160
+ 'register',
1161
+ 'my-gitlab',
1162
+ '--base-api-url',
1163
+ 'https://gitlab.mycompany.com/api/',
1164
+ '--service-family',
1165
+ 'gitlab',
1166
+ ], deps);
1167
+ // Store credentials for it
1168
+ const storePath = join(tempDir, 'credentials.json');
1169
+ writeSecureFile(storePath, JSON.stringify({
1170
+ 'my-gitlab': {
1171
+ objectType: 'rawCurl',
1172
+ curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
1173
+ },
1174
+ }));
1175
+ logs = [];
1176
+ errorLogs = [];
1177
+ exitCode = null;
1178
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1179
+ expect(exitCode).toBe(1);
1180
+ expect(errorLogs[0]).toContain('Credentials still exist');
1181
+ expect(errorLogs[0]).toContain('latchkey auth clear my-gitlab');
1182
+ // Service should still be in config
1183
+ const entries = loadRegisteredServices(deps.config.configPath);
1184
+ expect(entries.get('my-gitlab')).toBeDefined();
1185
+ });
1186
+ it('should allow deregistering after credentials are cleared', async () => {
1187
+ const deps = createMockDependencies({
1188
+ registry: new Registry([GITLAB]),
1189
+ });
1190
+ // Register
1191
+ await runCommand([
1192
+ 'services',
1193
+ 'register',
1194
+ 'my-gitlab',
1195
+ '--base-api-url',
1196
+ 'https://gitlab.mycompany.com/api/',
1197
+ '--service-family',
1198
+ 'gitlab',
1199
+ ], deps);
1200
+ // Store and then clear credentials
1201
+ const storePath = join(tempDir, 'credentials.json');
1202
+ writeSecureFile(storePath, JSON.stringify({
1203
+ 'my-gitlab': {
1204
+ objectType: 'rawCurl',
1205
+ curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
1206
+ },
1207
+ }));
1208
+ logs = [];
1209
+ errorLogs = [];
1210
+ exitCode = null;
1211
+ await runCommand(['auth', 'clear', 'my-gitlab'], deps);
1212
+ expect(exitCode).toBeNull();
1213
+ logs = [];
1214
+ errorLogs = [];
1215
+ exitCode = null;
1216
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1217
+ expect(exitCode).toBeNull();
1218
+ expect(logs).toContain("Service 'my-gitlab' deregistered.");
1219
+ });
1220
+ });
1221
+ });
1222
+ describe('registeredServiceStore', () => {
1223
+ let tempDir;
1224
+ beforeEach(() => {
1225
+ tempDir = mkdtempSync(join(tmpdir(), 'latchkey-store-test-'));
1226
+ });
1227
+ afterEach(() => {
1228
+ rmSync(tempDir, { recursive: true, force: true });
1229
+ });
1230
+ it('should save and load registered services', () => {
1231
+ const configPath = join(tempDir, 'config.json');
1232
+ saveRegisteredService(configPath, 'my-gitlab', {
1233
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1234
+ serviceFamily: 'gitlab',
1235
+ });
1236
+ const entries = loadRegisteredServices(configPath);
1237
+ expect(entries.size).toBe(1);
1238
+ expect(entries.get('my-gitlab')).toEqual({
1239
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1240
+ serviceFamily: 'gitlab',
1241
+ });
1242
+ });
1243
+ it('should return empty map for nonexistent config file', () => {
1244
+ const configPath = join(tempDir, 'nonexistent.json');
1245
+ const entries = loadRegisteredServices(configPath);
1246
+ expect(entries.size).toBe(0);
1247
+ });
1248
+ it('should preserve existing config data when saving', () => {
1249
+ const configPath = join(tempDir, 'config.json');
1250
+ writeFileSync(configPath, JSON.stringify({ browser: { executablePath: '/usr/bin/chrome' } }));
1251
+ saveRegisteredService(configPath, 'my-gitlab', {
1252
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1253
+ serviceFamily: 'gitlab',
1254
+ });
1255
+ const content = JSON.parse(readFileSync(configPath, 'utf-8'));
1256
+ expect(content.browser).toEqual({ executablePath: '/usr/bin/chrome' });
1257
+ expect(content.registeredServices).toBeDefined();
1258
+ });
1259
+ it('should load registered services into registry', () => {
1260
+ const configPath = join(tempDir, 'config.json');
1261
+ saveRegisteredService(configPath, 'my-gitlab', {
1262
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1263
+ serviceFamily: 'gitlab',
1264
+ });
1265
+ const registry = new Registry([GITLAB]);
1266
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1267
+ const service = registry.getByName('my-gitlab');
1268
+ expect(service).not.toBeNull();
1269
+ expect(service.baseApiUrls).toEqual(['https://gitlab.mycompany.com/api/']);
1270
+ });
1271
+ it('should load registered service with loginUrl into registry', () => {
1272
+ const configPath = join(tempDir, 'config.json');
1273
+ saveRegisteredService(configPath, 'my-gitlab', {
1274
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1275
+ serviceFamily: 'gitlab',
1276
+ loginUrl: 'https://gitlab.mycompany.com/users/sign_in',
1277
+ });
1278
+ const registry = new Registry([GITLAB]);
1279
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1280
+ const service = registry.getByName('my-gitlab');
1281
+ expect(service).not.toBeNull();
1282
+ expect(service.loginUrl).toBe('https://gitlab.mycompany.com/users/sign_in');
1283
+ });
1284
+ it('should load registered service without family into registry', () => {
1285
+ const configPath = join(tempDir, 'config.json');
1286
+ saveRegisteredService(configPath, 'my-api', {
1287
+ baseApiUrl: 'https://api.example.com/',
1288
+ });
1289
+ const registry = new Registry([GITLAB]);
1290
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1291
+ const service = registry.getByName('my-api');
1292
+ expect(service).not.toBeNull();
1293
+ expect(service.baseApiUrls).toEqual(['https://api.example.com/']);
1294
+ expect(service.getSession).toBeUndefined(); // eslint-disable-line @typescript-eslint/unbound-method
1295
+ expect(service.loginUrl).toBe('');
1296
+ });
1297
+ it('should skip registered services with unknown family', () => {
1298
+ const configPath = join(tempDir, 'config.json');
1299
+ saveRegisteredService(configPath, 'my-unknown', {
1300
+ baseApiUrl: 'https://unknown.example.com/api/',
1301
+ serviceFamily: 'nonexistent',
1302
+ });
1303
+ const registry = new Registry([GITLAB]);
1304
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1305
+ expect(registry.getByName('my-unknown')).toBeNull();
1306
+ });
1307
+ it('should delete a registered service from config', () => {
1308
+ const configPath = join(tempDir, 'config.json');
1309
+ saveRegisteredService(configPath, 'my-gitlab', {
1310
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1311
+ serviceFamily: 'gitlab',
1312
+ });
1313
+ saveRegisteredService(configPath, 'my-github', {
1314
+ baseApiUrl: 'https://github.mycompany.com/api/',
1315
+ serviceFamily: 'github',
1316
+ });
1317
+ deleteRegisteredService(configPath, 'my-gitlab');
1318
+ const entries = loadRegisteredServices(configPath);
1319
+ expect(entries.get('my-gitlab')).toBeUndefined();
1320
+ expect(entries.get('my-github')).toBeDefined();
1321
+ });
1322
+ it('should preserve other config data when deleting', () => {
1323
+ const configPath = join(tempDir, 'config.json');
1324
+ writeFileSync(configPath, JSON.stringify({ browser: { executablePath: '/usr/bin/chrome' } }));
1325
+ saveRegisteredService(configPath, 'my-gitlab', {
1326
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1327
+ serviceFamily: 'gitlab',
1328
+ });
1329
+ deleteRegisteredService(configPath, 'my-gitlab');
1330
+ const content = JSON.parse(readFileSync(configPath, 'utf-8'));
1331
+ expect(content.browser).toEqual({ executablePath: '/usr/bin/chrome' });
1332
+ });
642
1333
  });
643
1334
  // Integration tests that run the actual CLI binary.
644
1335
  // Only tests that exercise behavior not covered by the DI unit tests above.