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.
- package/README.md +39 -1
- package/dist/integrations/SKILL.md +6 -1
- package/dist/package.json +2 -2
- package/dist/scripts/codegen.js +1 -1
- package/dist/scripts/codegen.js.map +1 -1
- package/dist/src/apiCredentials.d.ts +2 -1
- package/dist/src/apiCredentials.d.ts.map +1 -1
- package/dist/src/apiCredentials.js +1 -0
- package/dist/src/apiCredentials.js.map +1 -1
- package/dist/src/browserConfig.d.ts +1 -28
- package/dist/src/browserConfig.d.ts.map +1 -1
- package/dist/src/browserConfig.js +4 -73
- package/dist/src/browserConfig.js.map +1 -1
- package/dist/src/cli.js +2 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/cliCommands.d.ts.map +1 -1
- package/dist/src/cliCommands.js +98 -4
- package/dist/src/cliCommands.js.map +1 -1
- package/dist/src/configDataStore.d.ts +43 -0
- package/dist/src/configDataStore.d.ts.map +1 -0
- package/dist/src/configDataStore.js +108 -0
- package/dist/src/configDataStore.js.map +1 -0
- package/dist/src/oauthUtils.js +1 -1
- package/dist/src/oauthUtils.js.map +1 -1
- package/dist/src/registeredService.d.ts +20 -0
- package/dist/src/registeredService.d.ts.map +1 -0
- package/dist/src/registeredService.js +34 -0
- package/dist/src/registeredService.js.map +1 -0
- package/dist/src/registeredServiceStore.d.ts +24 -0
- package/dist/src/registeredServiceStore.d.ts.map +1 -0
- package/dist/src/registeredServiceStore.js +70 -0
- package/dist/src/registeredServiceStore.js.map +1 -0
- package/dist/src/registry.d.ts +11 -1
- package/dist/src/registry.d.ts.map +1 -1
- package/dist/src/registry.js +52 -4
- package/dist/src/registry.js.map +1 -1
- package/dist/src/services/aws.d.ts +1 -1
- package/dist/src/services/aws.d.ts.map +1 -1
- package/dist/src/services/aws.js +1 -1
- package/dist/src/services/aws.js.map +1 -1
- package/dist/src/services/calendly.d.ts +1 -1
- package/dist/src/services/calendly.d.ts.map +1 -1
- package/dist/src/services/calendly.js +1 -1
- package/dist/src/services/calendly.js.map +1 -1
- package/dist/src/services/core/base.d.ts +141 -0
- package/dist/src/services/core/base.d.ts.map +1 -0
- package/dist/src/services/core/base.js +189 -0
- package/dist/src/services/core/base.js.map +1 -0
- package/dist/src/services/core/registered.d.ts +24 -0
- package/dist/src/services/core/registered.d.ts.map +1 -0
- package/dist/src/services/core/registered.js +53 -0
- package/dist/src/services/core/registered.js.map +1 -0
- package/dist/src/services/discord.d.ts +1 -1
- package/dist/src/services/discord.d.ts.map +1 -1
- package/dist/src/services/discord.js +1 -1
- package/dist/src/services/discord.js.map +1 -1
- package/dist/src/services/dropbox.d.ts +1 -1
- package/dist/src/services/dropbox.d.ts.map +1 -1
- package/dist/src/services/dropbox.js +1 -1
- package/dist/src/services/dropbox.js.map +1 -1
- package/dist/src/services/figma.d.ts +1 -1
- package/dist/src/services/figma.d.ts.map +1 -1
- package/dist/src/services/figma.js +1 -1
- package/dist/src/services/figma.js.map +1 -1
- package/dist/src/services/github.d.ts +1 -1
- package/dist/src/services/github.d.ts.map +1 -1
- package/dist/src/services/github.js +1 -1
- package/dist/src/services/github.js.map +1 -1
- package/dist/src/services/gitlab.d.ts +1 -1
- package/dist/src/services/gitlab.d.ts.map +1 -1
- package/dist/src/services/gitlab.js +1 -1
- package/dist/src/services/gitlab.js.map +1 -1
- package/dist/src/services/google/base.d.ts +1 -1
- package/dist/src/services/google/base.d.ts.map +1 -1
- package/dist/src/services/google/base.js +1 -1
- package/dist/src/services/google/base.js.map +1 -1
- package/dist/src/services/google/directions.d.ts +1 -1
- package/dist/src/services/google/directions.d.ts.map +1 -1
- package/dist/src/services/google/directions.js +1 -1
- package/dist/src/services/google/directions.js.map +1 -1
- package/dist/src/services/index.d.ts +3 -2
- package/dist/src/services/index.d.ts.map +1 -1
- package/dist/src/services/index.js +3 -2
- package/dist/src/services/index.js.map +1 -1
- package/dist/src/services/linear.d.ts +1 -1
- package/dist/src/services/linear.d.ts.map +1 -1
- package/dist/src/services/linear.js +1 -1
- package/dist/src/services/linear.js.map +1 -1
- package/dist/src/services/mailchimp.d.ts +1 -1
- package/dist/src/services/mailchimp.d.ts.map +1 -1
- package/dist/src/services/mailchimp.js +1 -1
- package/dist/src/services/mailchimp.js.map +1 -1
- package/dist/src/services/notion.d.ts +1 -1
- package/dist/src/services/notion.d.ts.map +1 -1
- package/dist/src/services/notion.js +1 -1
- package/dist/src/services/notion.js.map +1 -1
- package/dist/src/services/sentry.d.ts +1 -1
- package/dist/src/services/sentry.d.ts.map +1 -1
- package/dist/src/services/sentry.js +1 -1
- package/dist/src/services/sentry.js.map +1 -1
- package/dist/src/services/slack.d.ts +1 -1
- package/dist/src/services/slack.d.ts.map +1 -1
- package/dist/src/services/slack.js +1 -1
- package/dist/src/services/slack.js.map +1 -1
- package/dist/src/services/stripe.d.ts +1 -1
- package/dist/src/services/stripe.d.ts.map +1 -1
- package/dist/src/services/stripe.js +1 -1
- package/dist/src/services/stripe.js.map +1 -1
- package/dist/src/services/telegram.d.ts +1 -1
- package/dist/src/services/telegram.d.ts.map +1 -1
- package/dist/src/services/telegram.js +1 -1
- package/dist/src/services/telegram.js.map +1 -1
- package/dist/src/services/yelp.d.ts +1 -1
- package/dist/src/services/yelp.d.ts.map +1 -1
- package/dist/src/services/yelp.js +1 -1
- package/dist/src/services/yelp.js.map +1 -1
- package/dist/src/services/zoom.d.ts +1 -1
- package/dist/src/services/zoom.d.ts.map +1 -1
- package/dist/src/services/zoom.js +1 -1
- package/dist/src/services/zoom.js.map +1 -1
- package/dist/tests/cli.test.js +602 -2
- package/dist/tests/cli.test.js.map +1 -1
- package/dist/tests/registry.test.js +100 -2
- package/dist/tests/registry.test.js.map +1 -1
- package/dist/tests/servicesAgainstRecordings.test.js +1 -1
- package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
- package/package.json +2 -2
package/dist/tests/cli.test.js
CHANGED
|
@@ -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.
|