latchkey 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +39 -1
  2. package/dist/integrations/SKILL.md +6 -1
  3. package/dist/package.json +2 -2
  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/browserConfig.d.ts +1 -28
  11. package/dist/src/browserConfig.d.ts.map +1 -1
  12. package/dist/src/browserConfig.js +4 -73
  13. package/dist/src/browserConfig.js.map +1 -1
  14. package/dist/src/cli.js +2 -0
  15. package/dist/src/cli.js.map +1 -1
  16. package/dist/src/cliCommands.d.ts.map +1 -1
  17. package/dist/src/cliCommands.js +98 -4
  18. package/dist/src/cliCommands.js.map +1 -1
  19. package/dist/src/configDataStore.d.ts +43 -0
  20. package/dist/src/configDataStore.d.ts.map +1 -0
  21. package/dist/src/configDataStore.js +108 -0
  22. package/dist/src/configDataStore.js.map +1 -0
  23. package/dist/src/oauthUtils.js +1 -1
  24. package/dist/src/oauthUtils.js.map +1 -1
  25. package/dist/src/registeredService.d.ts +20 -0
  26. package/dist/src/registeredService.d.ts.map +1 -0
  27. package/dist/src/registeredService.js +34 -0
  28. package/dist/src/registeredService.js.map +1 -0
  29. package/dist/src/registeredServiceStore.d.ts +24 -0
  30. package/dist/src/registeredServiceStore.d.ts.map +1 -0
  31. package/dist/src/registeredServiceStore.js +70 -0
  32. package/dist/src/registeredServiceStore.js.map +1 -0
  33. package/dist/src/registry.d.ts +11 -1
  34. package/dist/src/registry.d.ts.map +1 -1
  35. package/dist/src/registry.js +52 -4
  36. package/dist/src/registry.js.map +1 -1
  37. package/dist/src/services/aws.d.ts +1 -1
  38. package/dist/src/services/aws.d.ts.map +1 -1
  39. package/dist/src/services/aws.js +1 -1
  40. package/dist/src/services/aws.js.map +1 -1
  41. package/dist/src/services/calendly.d.ts +1 -1
  42. package/dist/src/services/calendly.d.ts.map +1 -1
  43. package/dist/src/services/calendly.js +1 -1
  44. package/dist/src/services/calendly.js.map +1 -1
  45. package/dist/src/services/core/base.d.ts +141 -0
  46. package/dist/src/services/core/base.d.ts.map +1 -0
  47. package/dist/src/services/core/base.js +189 -0
  48. package/dist/src/services/core/base.js.map +1 -0
  49. package/dist/src/services/core/registered.d.ts +24 -0
  50. package/dist/src/services/core/registered.d.ts.map +1 -0
  51. package/dist/src/services/core/registered.js +53 -0
  52. package/dist/src/services/core/registered.js.map +1 -0
  53. package/dist/src/services/discord.d.ts +1 -1
  54. package/dist/src/services/discord.d.ts.map +1 -1
  55. package/dist/src/services/discord.js +1 -1
  56. package/dist/src/services/discord.js.map +1 -1
  57. package/dist/src/services/dropbox.d.ts +1 -1
  58. package/dist/src/services/dropbox.d.ts.map +1 -1
  59. package/dist/src/services/dropbox.js +1 -1
  60. package/dist/src/services/dropbox.js.map +1 -1
  61. package/dist/src/services/figma.d.ts +1 -1
  62. package/dist/src/services/figma.d.ts.map +1 -1
  63. package/dist/src/services/figma.js +1 -1
  64. package/dist/src/services/figma.js.map +1 -1
  65. package/dist/src/services/github.d.ts +1 -1
  66. package/dist/src/services/github.d.ts.map +1 -1
  67. package/dist/src/services/github.js +1 -1
  68. package/dist/src/services/github.js.map +1 -1
  69. package/dist/src/services/gitlab.d.ts +1 -1
  70. package/dist/src/services/gitlab.d.ts.map +1 -1
  71. package/dist/src/services/gitlab.js +1 -1
  72. package/dist/src/services/gitlab.js.map +1 -1
  73. package/dist/src/services/google/base.d.ts +1 -1
  74. package/dist/src/services/google/base.d.ts.map +1 -1
  75. package/dist/src/services/google/base.js +1 -1
  76. package/dist/src/services/google/base.js.map +1 -1
  77. package/dist/src/services/google/directions.d.ts +1 -1
  78. package/dist/src/services/google/directions.d.ts.map +1 -1
  79. package/dist/src/services/google/directions.js +1 -1
  80. package/dist/src/services/google/directions.js.map +1 -1
  81. package/dist/src/services/index.d.ts +3 -2
  82. package/dist/src/services/index.d.ts.map +1 -1
  83. package/dist/src/services/index.js +3 -2
  84. package/dist/src/services/index.js.map +1 -1
  85. package/dist/src/services/linear.d.ts +1 -1
  86. package/dist/src/services/linear.d.ts.map +1 -1
  87. package/dist/src/services/linear.js +1 -1
  88. package/dist/src/services/linear.js.map +1 -1
  89. package/dist/src/services/mailchimp.d.ts +1 -1
  90. package/dist/src/services/mailchimp.d.ts.map +1 -1
  91. package/dist/src/services/mailchimp.js +1 -1
  92. package/dist/src/services/mailchimp.js.map +1 -1
  93. package/dist/src/services/notion.d.ts +1 -1
  94. package/dist/src/services/notion.d.ts.map +1 -1
  95. package/dist/src/services/notion.js +1 -1
  96. package/dist/src/services/notion.js.map +1 -1
  97. package/dist/src/services/sentry.d.ts +1 -1
  98. package/dist/src/services/sentry.d.ts.map +1 -1
  99. package/dist/src/services/sentry.js +1 -1
  100. package/dist/src/services/sentry.js.map +1 -1
  101. package/dist/src/services/slack.d.ts +1 -1
  102. package/dist/src/services/slack.d.ts.map +1 -1
  103. package/dist/src/services/slack.js +1 -1
  104. package/dist/src/services/slack.js.map +1 -1
  105. package/dist/src/services/stripe.d.ts +1 -1
  106. package/dist/src/services/stripe.d.ts.map +1 -1
  107. package/dist/src/services/stripe.js +1 -1
  108. package/dist/src/services/stripe.js.map +1 -1
  109. package/dist/src/services/telegram.d.ts +1 -1
  110. package/dist/src/services/telegram.d.ts.map +1 -1
  111. package/dist/src/services/telegram.js +1 -1
  112. package/dist/src/services/telegram.js.map +1 -1
  113. package/dist/src/services/yelp.d.ts +1 -1
  114. package/dist/src/services/yelp.d.ts.map +1 -1
  115. package/dist/src/services/yelp.js +1 -1
  116. package/dist/src/services/yelp.js.map +1 -1
  117. package/dist/src/services/zoom.d.ts +1 -1
  118. package/dist/src/services/zoom.d.ts.map +1 -1
  119. package/dist/src/services/zoom.js +1 -1
  120. package/dist/src/services/zoom.js.map +1 -1
  121. package/dist/tests/cli.test.js +602 -2
  122. package/dist/tests/cli.test.js.map +1 -1
  123. package/dist/tests/registry.test.js +100 -2
  124. package/dist/tests/registry.test.js.map +1 -1
  125. package/dist/tests/servicesAgainstRecordings.test.js +1 -1
  126. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  127. package/package.json +2 -2
@@ -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,26 @@ 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 --built-in-only', 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', '--built-in-only'], 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
+ });
215
240
  });
216
241
  describe('services info command', () => {
217
242
  it('should show login options, credentials status, and developer notes', async () => {
@@ -221,6 +246,7 @@ describe('CLI commands with dependency injection', () => {
221
246
  await runCommand(['services', 'info', 'slack'], deps);
222
247
  expect(logs).toHaveLength(1);
223
248
  const info = JSON.parse(logs[0] ?? '');
249
+ expect(info.type).toBe('built-in');
224
250
  expect(info.authOptions).toEqual(['browser', 'set']);
225
251
  expect(info.credentialStatus).toBe('missing');
226
252
  expect(info.setCredentialsExample).toBe('latchkey auth set slack -H "Authorization: Bearer xoxb-your-token"');
@@ -277,6 +303,16 @@ describe('CLI commands with dependency injection', () => {
277
303
  expect(exitCode).toBe(1);
278
304
  expect(errorLogs.length).toBeGreaterThan(0);
279
305
  });
306
+ it('should show type as registered for registered services', async () => {
307
+ const storePath = join(tempDir, 'credentials.json');
308
+ writeSecureFile(storePath, '{}');
309
+ const registeredService = new RegisteredService('my-gitlab', 'https://gitlab.example.com');
310
+ const deps = createMockDependencies();
311
+ deps.registry.addService(registeredService);
312
+ await runCommand(['services', 'info', 'my-gitlab'], deps);
313
+ const info = JSON.parse(logs[0] ?? '');
314
+ expect(info.type).toBe('user-registered');
315
+ });
280
316
  });
281
317
  describe('clear command', () => {
282
318
  it('should delete credentials for a service', async () => {
@@ -639,6 +675,570 @@ describe('CLI commands with dependency injection', () => {
639
675
  expect(exitCode).toBe(1);
640
676
  });
641
677
  });
678
+ describe('services register command', () => {
679
+ it('should register a new service', async () => {
680
+ const deps = createMockDependencies({
681
+ registry: new Registry([GITLAB]),
682
+ });
683
+ await runCommand([
684
+ 'services',
685
+ 'register',
686
+ 'my-gitlab',
687
+ '--base-api-url',
688
+ 'https://gitlab.mycompany.com/api/',
689
+ '--service-family',
690
+ 'gitlab',
691
+ ], deps);
692
+ expect(exitCode).toBeNull();
693
+ expect(logs).toContain("Service 'my-gitlab' registered.");
694
+ // Should be findable by name
695
+ expect(deps.registry.getByName('my-gitlab')).not.toBeNull();
696
+ // Should be findable by URL
697
+ expect(deps.registry.getByUrl('https://gitlab.mycompany.com/api/v4/user')).not.toBeNull();
698
+ });
699
+ it('should persist registration to config.json', async () => {
700
+ const deps = createMockDependencies({
701
+ registry: new Registry([GITLAB]),
702
+ });
703
+ await runCommand([
704
+ 'services',
705
+ 'register',
706
+ 'my-gitlab',
707
+ '--base-api-url',
708
+ 'https://gitlab.mycompany.com/api/',
709
+ '--service-family',
710
+ 'gitlab',
711
+ ], deps);
712
+ const configPath = deps.config.configPath;
713
+ const entries = loadRegisteredServices(configPath);
714
+ expect(entries.get('my-gitlab')).toEqual({
715
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
716
+ serviceFamily: 'gitlab',
717
+ });
718
+ });
719
+ it('should reject unknown service family', async () => {
720
+ const deps = createMockDependencies({
721
+ registry: new Registry([GITLAB]),
722
+ });
723
+ await runCommand([
724
+ 'services',
725
+ 'register',
726
+ 'my-service',
727
+ '--base-api-url',
728
+ 'https://example.com/api/',
729
+ '--service-family',
730
+ 'nonexistent',
731
+ ], deps);
732
+ expect(exitCode).toBe(1);
733
+ expect(errorLogs[0]).toContain('Unknown service family');
734
+ });
735
+ it('should reject duplicate service name', async () => {
736
+ const deps = createMockDependencies({
737
+ registry: new Registry([GITLAB]),
738
+ });
739
+ await runCommand([
740
+ 'services',
741
+ 'register',
742
+ 'gitlab',
743
+ '--base-api-url',
744
+ 'https://gitlab.mycompany.com/api/',
745
+ '--service-family',
746
+ 'gitlab',
747
+ ], deps);
748
+ expect(exitCode).toBe(1);
749
+ expect(errorLogs[0]).toContain('already exists');
750
+ });
751
+ it('should canonicalize service name to lowercase', async () => {
752
+ const deps = createMockDependencies({
753
+ registry: new Registry([GITLAB]),
754
+ });
755
+ await runCommand([
756
+ 'services',
757
+ 'register',
758
+ 'My-GitLab',
759
+ '--base-api-url',
760
+ 'https://gitlab.mycompany.com/api/',
761
+ '--service-family',
762
+ 'gitlab',
763
+ ], deps);
764
+ expect(exitCode).toBeNull();
765
+ expect(logs).toContain("Service 'my-gitlab' registered.");
766
+ expect(deps.registry.getByName('my-gitlab')).not.toBeNull();
767
+ });
768
+ it('should convert spaces to hyphens in service name', async () => {
769
+ const deps = createMockDependencies({
770
+ registry: new Registry([GITLAB]),
771
+ });
772
+ await runCommand(['services', 'register', 'my api', '--base-api-url', 'https://api.example.com/'], deps);
773
+ expect(exitCode).toBeNull();
774
+ expect(logs).toContain("Service 'my-api' registered.");
775
+ expect(deps.registry.getByName('my-api')).not.toBeNull();
776
+ });
777
+ it('should reject service name with invalid characters', async () => {
778
+ const deps = createMockDependencies({
779
+ registry: new Registry([GITLAB]),
780
+ });
781
+ await runCommand(['services', 'register', 'my@service!', '--base-api-url', 'https://api.example.com/'], deps);
782
+ expect(exitCode).toBe(1);
783
+ expect(errorLogs[0]).toContain('Invalid service name');
784
+ });
785
+ it('should not expose browser auth without --login-url', async () => {
786
+ const storePath = join(tempDir, 'credentials.json');
787
+ writeSecureFile(storePath, '{}');
788
+ const deps = createMockDependencies({
789
+ registry: new Registry([GITLAB]),
790
+ });
791
+ await runCommand([
792
+ 'services',
793
+ 'register',
794
+ 'my-gitlab',
795
+ '--base-api-url',
796
+ 'https://gitlab.mycompany.com/api/',
797
+ '--service-family',
798
+ 'gitlab',
799
+ ], deps);
800
+ logs = [];
801
+ exitCode = null;
802
+ await runCommand(['services', 'info', 'my-gitlab'], deps);
803
+ const info = JSON.parse(logs[0] ?? '');
804
+ expect(info.authOptions).toEqual(['set']);
805
+ });
806
+ it('should persist and restore loginUrl', async () => {
807
+ const deps = createMockDependencies({
808
+ registry: new Registry([GITHUB]),
809
+ });
810
+ await runCommand([
811
+ 'services',
812
+ 'register',
813
+ 'my-github',
814
+ '--base-api-url',
815
+ 'https://github.mycompany.com/api/',
816
+ '--service-family',
817
+ 'github',
818
+ '--login-url',
819
+ 'https://github.mycompany.com/login',
820
+ ], deps);
821
+ const entries = loadRegisteredServices(deps.config.configPath);
822
+ expect(entries.get('my-github')?.loginUrl).toBe('https://github.mycompany.com/login');
823
+ });
824
+ it('should reject --login-url without --service-family', async () => {
825
+ const deps = createMockDependencies({
826
+ registry: new Registry([]),
827
+ });
828
+ await runCommand([
829
+ 'services',
830
+ 'register',
831
+ 'my-service',
832
+ '--base-api-url',
833
+ 'https://example.com/api/',
834
+ '--login-url',
835
+ 'https://example.com/login',
836
+ ], deps);
837
+ expect(exitCode).toBe(1);
838
+ expect(errorLogs[0]).toContain('--login-url requires a --service-family');
839
+ });
840
+ it('should reject --login-url when service family does not support browser login', async () => {
841
+ const deps = createMockDependencies({
842
+ registry: new Registry([GITLAB]),
843
+ });
844
+ await runCommand([
845
+ 'services',
846
+ 'register',
847
+ 'my-gitlab',
848
+ '--base-api-url',
849
+ 'https://gitlab.mycompany.com/api/',
850
+ '--service-family',
851
+ 'gitlab',
852
+ '--login-url',
853
+ 'https://gitlab.mycompany.com/users/sign_in',
854
+ ], deps);
855
+ expect(exitCode).toBe(1);
856
+ expect(errorLogs[0]).toContain('does not support browser login');
857
+ });
858
+ it('should require --login-url when service family supports browser login', async () => {
859
+ const deps = createMockDependencies({
860
+ registry: new Registry([GITHUB]),
861
+ });
862
+ await runCommand([
863
+ 'services',
864
+ 'register',
865
+ 'my-github',
866
+ '--base-api-url',
867
+ 'https://github.mycompany.com/api/',
868
+ '--service-family',
869
+ 'github',
870
+ ], deps);
871
+ expect(exitCode).toBe(1);
872
+ expect(errorLogs[0]).toContain('--login-url is required');
873
+ });
874
+ it('should make registered service usable with auth set', async () => {
875
+ const deps = createMockDependencies({
876
+ registry: new Registry([GITLAB]),
877
+ });
878
+ // Register the service
879
+ await runCommand([
880
+ 'services',
881
+ 'register',
882
+ 'my-gitlab',
883
+ '--base-api-url',
884
+ 'https://gitlab.mycompany.com/api/',
885
+ '--service-family',
886
+ 'gitlab',
887
+ ], deps);
888
+ // Now store credentials for it
889
+ const storePath = join(tempDir, 'credentials.json');
890
+ writeSecureFile(storePath, '{}');
891
+ logs = [];
892
+ exitCode = null;
893
+ await runCommand(['auth', 'set', 'my-gitlab', '-H', 'PRIVATE-TOKEN: my-secret-token'], deps);
894
+ expect(exitCode).toBeNull();
895
+ expect(logs).toContain('Credentials stored.');
896
+ });
897
+ it('should register a service without --service-family', async () => {
898
+ const deps = createMockDependencies({
899
+ registry: new Registry([GITLAB]),
900
+ });
901
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
902
+ expect(exitCode).toBeNull();
903
+ expect(logs).toContain("Service 'my-api' registered.");
904
+ // Should be findable by name
905
+ expect(deps.registry.getByName('my-api')).not.toBeNull();
906
+ // Should be findable by URL
907
+ expect(deps.registry.getByUrl('https://api.example.com/v1/users')).not.toBeNull();
908
+ });
909
+ it('should persist registration without service family to config.json', async () => {
910
+ const deps = createMockDependencies({
911
+ registry: new Registry([GITLAB]),
912
+ });
913
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
914
+ const configPath = deps.config.configPath;
915
+ const entries = loadRegisteredServices(configPath);
916
+ expect(entries.get('my-api')).toEqual({
917
+ baseApiUrl: 'https://api.example.com/',
918
+ });
919
+ });
920
+ it('should not expose browser auth for service without family', async () => {
921
+ const storePath = join(tempDir, 'credentials.json');
922
+ writeSecureFile(storePath, '{}');
923
+ const deps = createMockDependencies({
924
+ registry: new Registry([GITLAB]),
925
+ });
926
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
927
+ logs = [];
928
+ exitCode = null;
929
+ await runCommand(['services', 'info', 'my-api'], deps);
930
+ const info = JSON.parse(logs[0] ?? '');
931
+ expect(info.authOptions).toEqual(['set']);
932
+ });
933
+ it('should make service without family usable with auth set and curl', async () => {
934
+ const deps = createMockDependencies({
935
+ registry: new Registry([GITLAB]),
936
+ });
937
+ // Register the service without family
938
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
939
+ // Store credentials
940
+ const storePath = join(tempDir, 'credentials.json');
941
+ writeSecureFile(storePath, JSON.stringify({
942
+ 'my-api': {
943
+ objectType: 'rawCurl',
944
+ curlArguments: ['-H', 'Authorization: Bearer my-token'],
945
+ },
946
+ }));
947
+ logs = [];
948
+ exitCode = null;
949
+ capturedArgs = [];
950
+ await runCommand(['curl', 'https://api.example.com/v1/users'], deps);
951
+ expect(exitCode).toBe(0);
952
+ expect(capturedArgs).toContain('-H');
953
+ expect(capturedArgs).toContain('Authorization: Bearer my-token');
954
+ });
955
+ it('should reject browser login for service without family', async () => {
956
+ const deps = createMockDependencies({
957
+ registry: new Registry([GITLAB]),
958
+ });
959
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
960
+ logs = [];
961
+ errorLogs = [];
962
+ exitCode = null;
963
+ await runCommand(['auth', 'browser', 'my-api'], deps);
964
+ expect(exitCode).toBe(1);
965
+ expect(errorLogs[0]).toContain('does not support browser flows');
966
+ });
967
+ it('should reject set-nocurl for service without family', async () => {
968
+ const deps = createMockDependencies({
969
+ registry: new Registry([GITLAB]),
970
+ });
971
+ await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
972
+ logs = [];
973
+ errorLogs = [];
974
+ exitCode = null;
975
+ await runCommand(['auth', 'set-nocurl', 'my-api', 'some-token'], deps);
976
+ expect(exitCode).toBe(1);
977
+ expect(errorLogs[0]).toContain('does not support set-nocurl');
978
+ });
979
+ it('should make registered service usable with curl', async () => {
980
+ const deps = createMockDependencies({
981
+ registry: new Registry([GITLAB]),
982
+ });
983
+ // Register the service
984
+ await runCommand([
985
+ 'services',
986
+ 'register',
987
+ 'my-gitlab',
988
+ '--base-api-url',
989
+ 'https://gitlab.mycompany.com/api/',
990
+ '--service-family',
991
+ 'gitlab',
992
+ ], deps);
993
+ // Store credentials
994
+ const storePath = join(tempDir, 'credentials.json');
995
+ writeSecureFile(storePath, JSON.stringify({
996
+ 'my-gitlab': {
997
+ objectType: 'rawCurl',
998
+ curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
999
+ },
1000
+ }));
1001
+ logs = [];
1002
+ exitCode = null;
1003
+ capturedArgs = [];
1004
+ await runCommand(['curl', 'https://gitlab.mycompany.com/api/v4/user'], deps);
1005
+ expect(exitCode).toBe(0);
1006
+ expect(capturedArgs).toContain('-H');
1007
+ expect(capturedArgs).toContain('PRIVATE-TOKEN: my-secret-token');
1008
+ });
1009
+ });
1010
+ describe('services deregister command', () => {
1011
+ it('should deregister a registered service', async () => {
1012
+ const deps = createMockDependencies({
1013
+ registry: new Registry([GITLAB]),
1014
+ });
1015
+ // Register a service first
1016
+ await runCommand([
1017
+ 'services',
1018
+ 'register',
1019
+ 'my-gitlab',
1020
+ '--base-api-url',
1021
+ 'https://gitlab.mycompany.com/api/',
1022
+ '--service-family',
1023
+ 'gitlab',
1024
+ ], deps);
1025
+ logs = [];
1026
+ exitCode = null;
1027
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1028
+ expect(exitCode).toBeNull();
1029
+ expect(logs).toContain("Service 'my-gitlab' deregistered.");
1030
+ });
1031
+ it('should remove service from config.json', async () => {
1032
+ const deps = createMockDependencies({
1033
+ registry: new Registry([GITLAB]),
1034
+ });
1035
+ await runCommand([
1036
+ 'services',
1037
+ 'register',
1038
+ 'my-gitlab',
1039
+ '--base-api-url',
1040
+ 'https://gitlab.mycompany.com/api/',
1041
+ '--service-family',
1042
+ 'gitlab',
1043
+ ], deps);
1044
+ logs = [];
1045
+ exitCode = null;
1046
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1047
+ const entries = loadRegisteredServices(deps.config.configPath);
1048
+ expect(entries.get('my-gitlab')).toBeUndefined();
1049
+ });
1050
+ it('should reject deregistering an unknown service', async () => {
1051
+ const deps = createMockDependencies();
1052
+ await runCommand(['services', 'deregister', 'nonexistent'], deps);
1053
+ expect(exitCode).toBe(1);
1054
+ expect(errorLogs[0]).toContain('Unknown service');
1055
+ });
1056
+ it('should reject deregistering a built-in service', async () => {
1057
+ const deps = createMockDependencies();
1058
+ await runCommand(['services', 'deregister', 'slack'], deps);
1059
+ expect(exitCode).toBe(1);
1060
+ expect(errorLogs[0]).toContain('built-in service');
1061
+ });
1062
+ it('should reject deregistering when credentials still exist', async () => {
1063
+ const deps = createMockDependencies({
1064
+ registry: new Registry([GITLAB]),
1065
+ });
1066
+ // Register a service
1067
+ await runCommand([
1068
+ 'services',
1069
+ 'register',
1070
+ 'my-gitlab',
1071
+ '--base-api-url',
1072
+ 'https://gitlab.mycompany.com/api/',
1073
+ '--service-family',
1074
+ 'gitlab',
1075
+ ], deps);
1076
+ // Store credentials for it
1077
+ const storePath = join(tempDir, 'credentials.json');
1078
+ writeSecureFile(storePath, JSON.stringify({
1079
+ 'my-gitlab': {
1080
+ objectType: 'rawCurl',
1081
+ curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
1082
+ },
1083
+ }));
1084
+ logs = [];
1085
+ errorLogs = [];
1086
+ exitCode = null;
1087
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1088
+ expect(exitCode).toBe(1);
1089
+ expect(errorLogs[0]).toContain('Credentials still exist');
1090
+ expect(errorLogs[0]).toContain('latchkey auth clear my-gitlab');
1091
+ // Service should still be in config
1092
+ const entries = loadRegisteredServices(deps.config.configPath);
1093
+ expect(entries.get('my-gitlab')).toBeDefined();
1094
+ });
1095
+ it('should allow deregistering after credentials are cleared', async () => {
1096
+ const deps = createMockDependencies({
1097
+ registry: new Registry([GITLAB]),
1098
+ });
1099
+ // Register
1100
+ await runCommand([
1101
+ 'services',
1102
+ 'register',
1103
+ 'my-gitlab',
1104
+ '--base-api-url',
1105
+ 'https://gitlab.mycompany.com/api/',
1106
+ '--service-family',
1107
+ 'gitlab',
1108
+ ], deps);
1109
+ // Store and then clear credentials
1110
+ const storePath = join(tempDir, 'credentials.json');
1111
+ writeSecureFile(storePath, JSON.stringify({
1112
+ 'my-gitlab': {
1113
+ objectType: 'rawCurl',
1114
+ curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
1115
+ },
1116
+ }));
1117
+ logs = [];
1118
+ errorLogs = [];
1119
+ exitCode = null;
1120
+ await runCommand(['auth', 'clear', 'my-gitlab'], deps);
1121
+ expect(exitCode).toBeNull();
1122
+ logs = [];
1123
+ errorLogs = [];
1124
+ exitCode = null;
1125
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1126
+ expect(exitCode).toBeNull();
1127
+ expect(logs).toContain("Service 'my-gitlab' deregistered.");
1128
+ });
1129
+ });
1130
+ });
1131
+ describe('registeredServiceStore', () => {
1132
+ let tempDir;
1133
+ beforeEach(() => {
1134
+ tempDir = mkdtempSync(join(tmpdir(), 'latchkey-store-test-'));
1135
+ });
1136
+ afterEach(() => {
1137
+ rmSync(tempDir, { recursive: true, force: true });
1138
+ });
1139
+ it('should save and load registered services', () => {
1140
+ const configPath = join(tempDir, 'config.json');
1141
+ saveRegisteredService(configPath, 'my-gitlab', {
1142
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1143
+ serviceFamily: 'gitlab',
1144
+ });
1145
+ const entries = loadRegisteredServices(configPath);
1146
+ expect(entries.size).toBe(1);
1147
+ expect(entries.get('my-gitlab')).toEqual({
1148
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1149
+ serviceFamily: 'gitlab',
1150
+ });
1151
+ });
1152
+ it('should return empty map for nonexistent config file', () => {
1153
+ const configPath = join(tempDir, 'nonexistent.json');
1154
+ const entries = loadRegisteredServices(configPath);
1155
+ expect(entries.size).toBe(0);
1156
+ });
1157
+ it('should preserve existing config data when saving', () => {
1158
+ const configPath = join(tempDir, 'config.json');
1159
+ writeFileSync(configPath, JSON.stringify({ browser: { executablePath: '/usr/bin/chrome' } }));
1160
+ saveRegisteredService(configPath, 'my-gitlab', {
1161
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1162
+ serviceFamily: 'gitlab',
1163
+ });
1164
+ const content = JSON.parse(readFileSync(configPath, 'utf-8'));
1165
+ expect(content.browser).toEqual({ executablePath: '/usr/bin/chrome' });
1166
+ expect(content.registeredServices).toBeDefined();
1167
+ });
1168
+ it('should load registered services into registry', () => {
1169
+ const configPath = join(tempDir, 'config.json');
1170
+ saveRegisteredService(configPath, 'my-gitlab', {
1171
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1172
+ serviceFamily: 'gitlab',
1173
+ });
1174
+ const registry = new Registry([GITLAB]);
1175
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1176
+ const service = registry.getByName('my-gitlab');
1177
+ expect(service).not.toBeNull();
1178
+ expect(service.baseApiUrls).toEqual(['https://gitlab.mycompany.com/api/']);
1179
+ });
1180
+ it('should load registered service with loginUrl into registry', () => {
1181
+ const configPath = join(tempDir, 'config.json');
1182
+ saveRegisteredService(configPath, 'my-gitlab', {
1183
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1184
+ serviceFamily: 'gitlab',
1185
+ loginUrl: 'https://gitlab.mycompany.com/users/sign_in',
1186
+ });
1187
+ const registry = new Registry([GITLAB]);
1188
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1189
+ const service = registry.getByName('my-gitlab');
1190
+ expect(service).not.toBeNull();
1191
+ expect(service.loginUrl).toBe('https://gitlab.mycompany.com/users/sign_in');
1192
+ });
1193
+ it('should load registered service without family into registry', () => {
1194
+ const configPath = join(tempDir, 'config.json');
1195
+ saveRegisteredService(configPath, 'my-api', {
1196
+ baseApiUrl: 'https://api.example.com/',
1197
+ });
1198
+ const registry = new Registry([GITLAB]);
1199
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1200
+ const service = registry.getByName('my-api');
1201
+ expect(service).not.toBeNull();
1202
+ expect(service.baseApiUrls).toEqual(['https://api.example.com/']);
1203
+ expect(service.getSession).toBeUndefined(); // eslint-disable-line @typescript-eslint/unbound-method
1204
+ expect(service.loginUrl).toBe('');
1205
+ });
1206
+ it('should skip registered services with unknown family', () => {
1207
+ const configPath = join(tempDir, 'config.json');
1208
+ saveRegisteredService(configPath, 'my-unknown', {
1209
+ baseApiUrl: 'https://unknown.example.com/api/',
1210
+ serviceFamily: 'nonexistent',
1211
+ });
1212
+ const registry = new Registry([GITLAB]);
1213
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1214
+ expect(registry.getByName('my-unknown')).toBeNull();
1215
+ });
1216
+ it('should delete a registered service from config', () => {
1217
+ const configPath = join(tempDir, 'config.json');
1218
+ saveRegisteredService(configPath, 'my-gitlab', {
1219
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1220
+ serviceFamily: 'gitlab',
1221
+ });
1222
+ saveRegisteredService(configPath, 'my-github', {
1223
+ baseApiUrl: 'https://github.mycompany.com/api/',
1224
+ serviceFamily: 'github',
1225
+ });
1226
+ deleteRegisteredService(configPath, 'my-gitlab');
1227
+ const entries = loadRegisteredServices(configPath);
1228
+ expect(entries.get('my-gitlab')).toBeUndefined();
1229
+ expect(entries.get('my-github')).toBeDefined();
1230
+ });
1231
+ it('should preserve other config data when deleting', () => {
1232
+ const configPath = join(tempDir, 'config.json');
1233
+ writeFileSync(configPath, JSON.stringify({ browser: { executablePath: '/usr/bin/chrome' } }));
1234
+ saveRegisteredService(configPath, 'my-gitlab', {
1235
+ baseApiUrl: 'https://gitlab.mycompany.com/api/',
1236
+ serviceFamily: 'gitlab',
1237
+ });
1238
+ deleteRegisteredService(configPath, 'my-gitlab');
1239
+ const content = JSON.parse(readFileSync(configPath, 'utf-8'));
1240
+ expect(content.browser).toEqual({ executablePath: '/usr/bin/chrome' });
1241
+ });
642
1242
  });
643
1243
  // Integration tests that run the actual CLI binary.
644
1244
  // Only tests that exercise behavior not covered by the DI unit tests above.