latchkey 1.0.1 → 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 (224) hide show
  1. package/README.md +66 -8
  2. package/dist/integrations/SKILL.md +15 -3
  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/apiCredentialStore.d.ts +1 -1
  7. package/dist/src/apiCredentialStore.d.ts.map +1 -1
  8. package/dist/src/apiCredentialStore.js +1 -1
  9. package/dist/src/apiCredentialStore.js.map +1 -1
  10. package/dist/src/apiCredentials.d.ts +13 -115
  11. package/dist/src/apiCredentials.d.ts.map +1 -1
  12. package/dist/src/apiCredentials.js +10 -101
  13. package/dist/src/apiCredentials.js.map +1 -1
  14. package/dist/src/apiCredentialsSerialization.d.ts +119 -0
  15. package/dist/src/apiCredentialsSerialization.d.ts.map +1 -0
  16. package/dist/src/apiCredentialsSerialization.js +90 -0
  17. package/dist/src/apiCredentialsSerialization.js.map +1 -0
  18. package/dist/src/browserConfig.d.ts +2 -33
  19. package/dist/src/browserConfig.d.ts.map +1 -1
  20. package/dist/src/browserConfig.js +6 -81
  21. package/dist/src/browserConfig.js.map +1 -1
  22. package/dist/src/cli.js +22 -0
  23. package/dist/src/cli.js.map +1 -1
  24. package/dist/src/cliCommands.d.ts +0 -1
  25. package/dist/src/cliCommands.d.ts.map +1 -1
  26. package/dist/src/cliCommands.js +148 -44
  27. package/dist/src/cliCommands.js.map +1 -1
  28. package/dist/src/config.d.ts +4 -3
  29. package/dist/src/config.d.ts.map +1 -1
  30. package/dist/src/config.js +17 -23
  31. package/dist/src/config.js.map +1 -1
  32. package/dist/src/configDataStore.d.ts +43 -0
  33. package/dist/src/configDataStore.d.ts.map +1 -0
  34. package/dist/src/configDataStore.js +108 -0
  35. package/dist/src/configDataStore.js.map +1 -0
  36. package/dist/src/curl.d.ts +10 -0
  37. package/dist/src/curl.d.ts.map +1 -1
  38. package/dist/src/curl.js +88 -0
  39. package/dist/src/curl.js.map +1 -1
  40. package/dist/src/encryptedStorage.d.ts +9 -0
  41. package/dist/src/encryptedStorage.d.ts.map +1 -1
  42. package/dist/src/encryptedStorage.js +12 -0
  43. package/dist/src/encryptedStorage.js.map +1 -1
  44. package/dist/src/index.d.ts +4 -2
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/index.js +4 -2
  47. package/dist/src/index.js.map +1 -1
  48. package/dist/src/migrations.d.ts +9 -0
  49. package/dist/src/migrations.d.ts.map +1 -0
  50. package/dist/src/migrations.js +77 -0
  51. package/dist/src/migrations.js.map +1 -0
  52. package/dist/src/oauthUtils.d.ts +4 -1
  53. package/dist/src/oauthUtils.d.ts.map +1 -1
  54. package/dist/src/oauthUtils.js +1 -1
  55. package/dist/src/oauthUtils.js.map +1 -1
  56. package/dist/src/playwrightUtils.d.ts +2 -2
  57. package/dist/src/playwrightUtils.d.ts.map +1 -1
  58. package/dist/src/playwrightUtils.js +4 -4
  59. package/dist/src/playwrightUtils.js.map +1 -1
  60. package/dist/src/registeredService.d.ts +20 -0
  61. package/dist/src/registeredService.d.ts.map +1 -0
  62. package/dist/src/registeredService.js +34 -0
  63. package/dist/src/registeredService.js.map +1 -0
  64. package/dist/src/registeredServiceStore.d.ts +24 -0
  65. package/dist/src/registeredServiceStore.d.ts.map +1 -0
  66. package/dist/src/registeredServiceStore.js +70 -0
  67. package/dist/src/registeredServiceStore.js.map +1 -0
  68. package/dist/src/registry.d.ts +11 -1
  69. package/dist/src/registry.d.ts.map +1 -1
  70. package/dist/src/registry.js +70 -6
  71. package/dist/src/registry.js.map +1 -1
  72. package/dist/src/services/aws.d.ts +44 -0
  73. package/dist/src/services/aws.d.ts.map +1 -0
  74. package/dist/src/services/aws.js +237 -0
  75. package/dist/src/services/aws.js.map +1 -0
  76. package/dist/src/services/base.d.ts +14 -0
  77. package/dist/src/services/base.d.ts.map +1 -1
  78. package/dist/src/services/base.js +23 -11
  79. package/dist/src/services/base.js.map +1 -1
  80. package/dist/src/services/calendly.d.ts +12 -0
  81. package/dist/src/services/calendly.d.ts.map +1 -0
  82. package/dist/src/services/calendly.js +18 -0
  83. package/dist/src/services/calendly.js.map +1 -0
  84. package/dist/src/services/core/base.d.ts +141 -0
  85. package/dist/src/services/core/base.d.ts.map +1 -0
  86. package/dist/src/services/core/base.js +189 -0
  87. package/dist/src/services/core/base.js.map +1 -0
  88. package/dist/src/services/core/registered.d.ts +24 -0
  89. package/dist/src/services/core/registered.d.ts.map +1 -0
  90. package/dist/src/services/core/registered.js +53 -0
  91. package/dist/src/services/core/registered.js.map +1 -0
  92. package/dist/src/services/discord.d.ts +2 -1
  93. package/dist/src/services/discord.d.ts.map +1 -1
  94. package/dist/src/services/discord.js +4 -1
  95. package/dist/src/services/discord.js.map +1 -1
  96. package/dist/src/services/dropbox.d.ts +3 -2
  97. package/dist/src/services/dropbox.d.ts.map +1 -1
  98. package/dist/src/services/dropbox.js +6 -1
  99. package/dist/src/services/dropbox.js.map +1 -1
  100. package/dist/src/services/figma.d.ts +12 -0
  101. package/dist/src/services/figma.d.ts.map +1 -0
  102. package/dist/src/services/figma.js +14 -0
  103. package/dist/src/services/figma.js.map +1 -0
  104. package/dist/src/services/github.d.ts +2 -1
  105. package/dist/src/services/github.d.ts.map +1 -1
  106. package/dist/src/services/github.js +4 -1
  107. package/dist/src/services/github.js.map +1 -1
  108. package/dist/src/services/gitlab.d.ts +12 -0
  109. package/dist/src/services/gitlab.d.ts.map +1 -0
  110. package/dist/src/services/gitlab.js +14 -0
  111. package/dist/src/services/gitlab.js.map +1 -0
  112. package/dist/src/services/google/analytics.d.ts +11 -0
  113. package/dist/src/services/google/analytics.d.ts.map +1 -0
  114. package/dist/src/services/google/analytics.js +22 -0
  115. package/dist/src/services/google/analytics.js.map +1 -0
  116. package/dist/src/services/google/base.d.ts +73 -0
  117. package/dist/src/services/google/base.d.ts.map +1 -0
  118. package/dist/src/services/google/base.js +323 -0
  119. package/dist/src/services/google/base.js.map +1 -0
  120. package/dist/src/services/google/calendar.d.ts +11 -0
  121. package/dist/src/services/google/calendar.d.ts.map +1 -0
  122. package/dist/src/services/google/calendar.js +22 -0
  123. package/dist/src/services/google/calendar.js.map +1 -0
  124. package/dist/src/services/google/directions.d.ts +14 -0
  125. package/dist/src/services/google/directions.d.ts.map +1 -0
  126. package/dist/src/services/google/directions.js +49 -0
  127. package/dist/src/services/google/directions.js.map +1 -0
  128. package/dist/src/services/google/docs.d.ts +11 -0
  129. package/dist/src/services/google/docs.d.ts.map +1 -0
  130. package/dist/src/services/google/docs.js +19 -0
  131. package/dist/src/services/google/docs.js.map +1 -0
  132. package/dist/src/services/google/drive.d.ts +11 -0
  133. package/dist/src/services/google/drive.d.ts.map +1 -0
  134. package/dist/src/services/google/drive.js +19 -0
  135. package/dist/src/services/google/drive.js.map +1 -0
  136. package/dist/src/services/google/gmail.d.ts +11 -0
  137. package/dist/src/services/google/gmail.d.ts.map +1 -0
  138. package/dist/src/services/google/gmail.js +24 -0
  139. package/dist/src/services/google/gmail.js.map +1 -0
  140. package/dist/src/services/google/maps.d.ts +39 -0
  141. package/dist/src/services/google/maps.d.ts.map +1 -0
  142. package/dist/src/services/google/maps.js +94 -0
  143. package/dist/src/services/google/maps.js.map +1 -0
  144. package/dist/src/services/google/people.d.ts +11 -0
  145. package/dist/src/services/google/people.d.ts.map +1 -0
  146. package/dist/src/services/google/people.js +22 -0
  147. package/dist/src/services/google/people.js.map +1 -0
  148. package/dist/src/services/google/sheets.d.ts +11 -0
  149. package/dist/src/services/google/sheets.d.ts.map +1 -0
  150. package/dist/src/services/google/sheets.js +19 -0
  151. package/dist/src/services/google/sheets.js.map +1 -0
  152. package/dist/src/services/googleAnalytics.d.ts +11 -0
  153. package/dist/src/services/googleAnalytics.d.ts.map +1 -0
  154. package/dist/src/services/googleAnalytics.js +18 -0
  155. package/dist/src/services/googleAnalytics.js.map +1 -0
  156. package/dist/src/services/googleMaps.d.ts +12 -0
  157. package/dist/src/services/googleMaps.d.ts.map +1 -0
  158. package/dist/src/services/googleMaps.js +17 -0
  159. package/dist/src/services/googleMaps.js.map +1 -0
  160. package/dist/src/services/index.d.ts +21 -3
  161. package/dist/src/services/index.d.ts.map +1 -1
  162. package/dist/src/services/index.js +21 -3
  163. package/dist/src/services/index.js.map +1 -1
  164. package/dist/src/services/linear.d.ts +2 -1
  165. package/dist/src/services/linear.d.ts.map +1 -1
  166. package/dist/src/services/linear.js +4 -1
  167. package/dist/src/services/linear.js.map +1 -1
  168. package/dist/src/services/mailchimp.d.ts +3 -2
  169. package/dist/src/services/mailchimp.d.ts.map +1 -1
  170. package/dist/src/services/mailchimp.js +5 -4
  171. package/dist/src/services/mailchimp.js.map +1 -1
  172. package/dist/src/services/notion.d.ts +2 -1
  173. package/dist/src/services/notion.d.ts.map +1 -1
  174. package/dist/src/services/notion.js +4 -1
  175. package/dist/src/services/notion.js.map +1 -1
  176. package/dist/src/services/sentry.d.ts +14 -0
  177. package/dist/src/services/sentry.d.ts.map +1 -0
  178. package/dist/src/services/sentry.js +43 -0
  179. package/dist/src/services/sentry.js.map +1 -0
  180. package/dist/src/services/slack.d.ts +31 -2
  181. package/dist/src/services/slack.d.ts.map +1 -1
  182. package/dist/src/services/slack.js +46 -3
  183. package/dist/src/services/slack.js.map +1 -1
  184. package/dist/src/services/stripe.d.ts +12 -0
  185. package/dist/src/services/stripe.d.ts.map +1 -0
  186. package/dist/src/services/stripe.js +14 -0
  187. package/dist/src/services/stripe.js.map +1 -0
  188. package/dist/src/services/telegram.d.ts +40 -0
  189. package/dist/src/services/telegram.d.ts.map +1 -0
  190. package/dist/src/services/telegram.js +73 -0
  191. package/dist/src/services/telegram.js.map +1 -0
  192. package/dist/src/services/yelp.d.ts +12 -0
  193. package/dist/src/services/yelp.d.ts.map +1 -0
  194. package/dist/src/services/yelp.js +16 -0
  195. package/dist/src/services/yelp.js.map +1 -0
  196. package/dist/src/services/zoom.d.ts +12 -0
  197. package/dist/src/services/zoom.d.ts.map +1 -0
  198. package/dist/src/services/zoom.js +18 -0
  199. package/dist/src/services/zoom.js.map +1 -0
  200. package/dist/tests/apiCredentialStore.test.js +2 -19
  201. package/dist/tests/apiCredentialStore.test.js.map +1 -1
  202. package/dist/tests/apiCredentials.test.js +139 -178
  203. package/dist/tests/apiCredentials.test.js.map +1 -1
  204. package/dist/tests/cli.test.js +755 -255
  205. package/dist/tests/cli.test.js.map +1 -1
  206. package/dist/tests/encryptedStorage.test.js +0 -4
  207. package/dist/tests/encryptedStorage.test.js.map +1 -1
  208. package/dist/tests/encryptedStorageKeyGeneration.test.d.ts +2 -0
  209. package/dist/tests/encryptedStorageKeyGeneration.test.d.ts.map +1 -0
  210. package/dist/tests/encryptedStorageKeyGeneration.test.js +22 -0
  211. package/dist/tests/encryptedStorageKeyGeneration.test.js.map +1 -0
  212. package/dist/tests/encryption.test.js +3 -35
  213. package/dist/tests/encryption.test.js.map +1 -1
  214. package/dist/tests/migrations.test.d.ts +2 -0
  215. package/dist/tests/migrations.test.d.ts.map +1 -0
  216. package/dist/tests/migrations.test.js +164 -0
  217. package/dist/tests/migrations.test.js.map +1 -0
  218. package/dist/tests/playwrightDownload.test.js +2 -65
  219. package/dist/tests/playwrightDownload.test.js.map +1 -1
  220. package/dist/tests/registry.test.js +147 -75
  221. package/dist/tests/registry.test.js.map +1 -1
  222. package/dist/tests/servicesAgainstRecordings.test.js +2 -2
  223. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  224. package/package.json +2 -2
@@ -1,15 +1,23 @@
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';
6
6
  import { Command } from 'commander';
7
- import { extractUrlFromCurlArguments, registerCommands, } from '../src/cliCommands.js';
8
- import { BrowserFlowsNotSupportedError } from '../src/playwrightUtils.js';
7
+ import { registerCommands } from '../src/cliCommands.js';
8
+ import { extractUrlFromCurlArguments } from '../src/curl.js';
9
9
  import { EncryptedStorage } from '../src/encryptedStorage.js';
10
10
  import { Config } from '../src/config.js';
11
11
  import { Registry } from '../src/registry.js';
12
- import { SlackApiCredentials, ApiCredentialStatus } from '../src/apiCredentials.js';
12
+ import { ApiCredentialStatus } from '../src/apiCredentials.js';
13
+ import { SlackApiCredentials } from '../src/services/slack.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';
18
+ import { TELEGRAM } from '../src/services/telegram.js';
19
+ import { deleteRegisteredService, loadRegisteredServices, saveRegisteredService, } from '../src/configDataStore.js';
20
+ import { loadRegisteredServicesIntoRegistry } from '../src/registry.js';
13
21
  // Use a fixed test key for deterministic test behavior (32 bytes = 256 bits, base64 encoded)
14
22
  const TEST_ENCRYPTION_KEY = 'dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=';
15
23
  function writeSecureFile(path, content) {
@@ -115,10 +123,18 @@ describe('CLI commands with dependency injection', () => {
115
123
  let exitCode;
116
124
  function createMockConfig(overrides = {}) {
117
125
  const defaultConfig = new Config(() => undefined);
126
+ const directory = overrides.directory ?? tempDir;
118
127
  return {
119
- credentialStorePath: overrides.credentialStorePath ?? join(tempDir, 'credentials.json'),
120
- browserStatePath: overrides.browserStatePath ?? join(tempDir, 'browser_state.json'),
121
- configPath: overrides.configPath ?? join(tempDir, 'config.json'),
128
+ directory,
129
+ get credentialStorePath() {
130
+ return join(directory, 'credentials.json');
131
+ },
132
+ get browserStatePath() {
133
+ return join(directory, 'browser_state.json');
134
+ },
135
+ get configPath() {
136
+ return join(directory, 'config.json');
137
+ },
122
138
  curlCommand: overrides.curlCommand ?? defaultConfig.curlCommand,
123
139
  encryptionKeyOverride: overrides.encryptionKeyOverride ?? TEST_ENCRYPTION_KEY,
124
140
  serviceName: overrides.serviceName ?? defaultConfig.serviceName,
@@ -137,6 +153,12 @@ describe('CLI commands with dependency injection', () => {
137
153
  info: 'Test info for Slack service.',
138
154
  credentialCheckCurlArguments: ['https://slack.com/api/auth.test'],
139
155
  checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
156
+ setCredentialsExample(serviceName) {
157
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer xoxb-your-token"`;
158
+ },
159
+ getCredentialsNoCurl() {
160
+ throw new NoCurlCredentialsNotSupportedError('slack');
161
+ },
140
162
  getSession: vi.fn().mockReturnValue({
141
163
  login: vi.fn().mockResolvedValue(new SlackApiCredentials('xoxc-test-token', 'test-cookie')),
142
164
  }),
@@ -195,19 +217,39 @@ describe('CLI commands with dependency injection', () => {
195
217
  const services = JSON.parse(logs[0] ?? '');
196
218
  expect(services).toContain('slack');
197
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
+ });
198
240
  });
199
241
  describe('services info command', () => {
200
242
  it('should show login options, credentials status, and developer notes', async () => {
201
243
  const storePath = join(tempDir, 'credentials.json');
202
244
  writeSecureFile(storePath, '{}');
203
- const deps = createMockDependencies({
204
- config: createMockConfig({ credentialStorePath: storePath }),
205
- });
245
+ const deps = createMockDependencies();
206
246
  await runCommand(['services', 'info', 'slack'], deps);
207
247
  expect(logs).toHaveLength(1);
208
248
  const info = JSON.parse(logs[0] ?? '');
249
+ expect(info.type).toBe('built-in');
209
250
  expect(info.authOptions).toEqual(['browser', 'set']);
210
251
  expect(info.credentialStatus).toBe('missing');
252
+ expect(info.setCredentialsExample).toBe('latchkey auth set slack -H "Authorization: Bearer xoxb-your-token"');
211
253
  expect(info.developerNotes).toBe('Test info for Slack service.');
212
254
  });
213
255
  it('should show auth set only for services without browser login', async () => {
@@ -221,10 +263,15 @@ describe('CLI commands with dependency injection', () => {
221
263
  info: 'A service without browser login support.',
222
264
  credentialCheckCurlArguments: [],
223
265
  checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Missing),
266
+ setCredentialsExample(serviceName) {
267
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
268
+ },
269
+ getCredentialsNoCurl() {
270
+ throw new NoCurlCredentialsNotSupportedError('nologin');
271
+ },
224
272
  };
225
273
  const deps = createMockDependencies({
226
274
  registry: new Registry([noLoginService]),
227
- config: createMockConfig({ credentialStorePath: storePath }),
228
275
  });
229
276
  await runCommand(['services', 'info', 'nologin'], deps);
230
277
  const info = JSON.parse(logs[0] ?? '');
@@ -234,7 +281,7 @@ describe('CLI commands with dependency injection', () => {
234
281
  const storePath = join(tempDir, 'credentials.json');
235
282
  writeSecureFile(storePath, '{}');
236
283
  const deps = createMockDependencies({
237
- config: createMockConfig({ credentialStorePath: storePath, browserDisabled: true }),
284
+ config: createMockConfig({ browserDisabled: true }),
238
285
  });
239
286
  await runCommand(['services', 'info', 'slack'], deps);
240
287
  const info = JSON.parse(logs[0] ?? '');
@@ -245,9 +292,7 @@ describe('CLI commands with dependency injection', () => {
245
292
  writeSecureFile(storePath, JSON.stringify({
246
293
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
247
294
  }));
248
- const deps = createMockDependencies({
249
- config: createMockConfig({ credentialStorePath: storePath }),
250
- });
295
+ const deps = createMockDependencies();
251
296
  await runCommand(['services', 'info', 'slack'], deps);
252
297
  const info = JSON.parse(logs[0] ?? '');
253
298
  expect(info.credentialStatus).toBe('valid');
@@ -256,7 +301,17 @@ describe('CLI commands with dependency injection', () => {
256
301
  const deps = createMockDependencies();
257
302
  await runCommand(['services', 'info', 'unknown-service'], deps);
258
303
  expect(exitCode).toBe(1);
259
- expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
304
+ expect(errorLogs.length).toBeGreaterThan(0);
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');
260
315
  });
261
316
  });
262
317
  describe('clear command', () => {
@@ -265,37 +320,16 @@ describe('CLI commands with dependency injection', () => {
265
320
  writeSecureFile(storePath, JSON.stringify({
266
321
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
267
322
  }));
268
- const deps = createMockDependencies({
269
- config: createMockConfig({ credentialStorePath: storePath }),
270
- });
323
+ const deps = createMockDependencies();
271
324
  await runCommand(['auth', 'clear', 'slack'], deps);
272
- expect(logs.some((log) => log.includes('have been cleared'))).toBe(true);
273
325
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
274
326
  expect(storedData.slack).toBeUndefined();
275
327
  });
276
- it('should report no credentials found when service has no stored credentials', async () => {
277
- const storePath = join(tempDir, 'credentials.json');
278
- writeSecureFile(storePath, '{}');
279
- const deps = createMockDependencies({
280
- config: createMockConfig({ credentialStorePath: storePath }),
281
- });
282
- await runCommand(['auth', 'clear', 'slack'], deps);
283
- expect(logs.some((log) => log.includes('No API credentials found'))).toBe(true);
284
- });
285
328
  it('should return error for unknown service', async () => {
286
- const storePath = join(tempDir, 'credentials.json');
287
- const deps = createMockDependencies({
288
- config: createMockConfig({ credentialStorePath: storePath }),
289
- });
329
+ const deps = createMockDependencies();
290
330
  await runCommand(['auth', 'clear', 'unknown-service'], deps);
291
331
  expect(exitCode).toBe(1);
292
- expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
293
- });
294
- it('should use default config paths', async () => {
295
- const deps = createMockDependencies();
296
- await runCommand(['auth', 'clear', 'slack'], deps);
297
- // With default paths, should report no credentials found (not error about missing env var)
298
- expect(logs.some((log) => log.includes('No API credentials found'))).toBe(true);
332
+ expect(errorLogs.length).toBeGreaterThan(0);
299
333
  });
300
334
  it('should preserve other services when clearing one', async () => {
301
335
  const storePath = join(tempDir, 'credentials.json');
@@ -303,9 +337,7 @@ describe('CLI commands with dependency injection', () => {
303
337
  slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
304
338
  discord: { objectType: 'authorizationBare', token: 'discord-token' },
305
339
  }));
306
- const deps = createMockDependencies({
307
- config: createMockConfig({ credentialStorePath: storePath }),
308
- });
340
+ const deps = createMockDependencies();
309
341
  await runCommand(['auth', 'clear', 'slack'], deps);
310
342
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
311
343
  expect(storedData.slack).toBeUndefined();
@@ -317,23 +349,10 @@ describe('CLI commands with dependency injection', () => {
317
349
  const browserStatePath = join(tempDir, 'browser_state.json');
318
350
  writeSecureFile(storePath, JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } }));
319
351
  writeSecureFile(browserStatePath, '{}');
320
- const deps = createMockDependencies({
321
- config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
322
- });
352
+ const deps = createMockDependencies();
323
353
  await runCommand(['auth', 'clear', '-y'], deps);
324
354
  expect(existsSync(storePath)).toBe(false);
325
355
  expect(existsSync(browserStatePath)).toBe(false);
326
- expect(logs.some((log) => log.includes('Deleted credentials store'))).toBe(true);
327
- expect(logs.some((log) => log.includes('Deleted browser state'))).toBe(true);
328
- });
329
- it('should report no files to delete when none exist', async () => {
330
- const storePath = join(tempDir, 'nonexistent_store.json');
331
- const browserStatePath = join(tempDir, 'nonexistent_browser_state.json');
332
- const deps = createMockDependencies({
333
- config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
334
- });
335
- await runCommand(['auth', 'clear', '-y'], deps);
336
- expect(logs.some((log) => log.includes('No files to delete'))).toBe(true);
337
356
  });
338
357
  });
339
358
  describe('auth list command', () => {
@@ -342,9 +361,7 @@ describe('CLI commands with dependency injection', () => {
342
361
  writeSecureFile(storePath, JSON.stringify({
343
362
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
344
363
  }));
345
- const deps = createMockDependencies({
346
- config: createMockConfig({ credentialStorePath: storePath }),
347
- });
364
+ const deps = createMockDependencies();
348
365
  await runCommand(['auth', 'list'], deps);
349
366
  expect(logs).toHaveLength(1);
350
367
  const entries = JSON.parse(logs[0] ?? '');
@@ -356,9 +373,7 @@ describe('CLI commands with dependency injection', () => {
356
373
  it('should output empty object when no credentials are stored', async () => {
357
374
  const storePath = join(tempDir, 'credentials.json');
358
375
  writeSecureFile(storePath, '{}');
359
- const deps = createMockDependencies({
360
- config: createMockConfig({ credentialStorePath: storePath }),
361
- });
376
+ const deps = createMockDependencies();
362
377
  await runCommand(['auth', 'list'], deps);
363
378
  expect(logs).toHaveLength(1);
364
379
  const entries = JSON.parse(logs[0] ?? '');
@@ -369,9 +384,7 @@ describe('CLI commands with dependency injection', () => {
369
384
  writeSecureFile(storePath, JSON.stringify({
370
385
  unknown: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: secret'] },
371
386
  }));
372
- const deps = createMockDependencies({
373
- config: createMockConfig({ credentialStorePath: storePath }),
374
- });
387
+ const deps = createMockDependencies();
375
388
  await runCommand(['auth', 'list'], deps);
376
389
  expect(logs).toHaveLength(1);
377
390
  const entries = JSON.parse(logs[0] ?? '');
@@ -385,9 +398,7 @@ describe('CLI commands with dependency injection', () => {
385
398
  it('should store raw curl credentials', async () => {
386
399
  const storePath = join(tempDir, 'credentials.json');
387
400
  writeSecureFile(storePath, '{}');
388
- const deps = createMockDependencies({
389
- config: createMockConfig({ credentialStorePath: storePath }),
390
- });
401
+ const deps = createMockDependencies();
391
402
  await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: secret', '-H', 'X-Other: value'], deps);
392
403
  expect(logs).toContain('Credentials stored.');
393
404
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
@@ -400,30 +411,23 @@ describe('CLI commands with dependency injection', () => {
400
411
  const deps = createMockDependencies();
401
412
  await runCommand(['auth', 'set', 'slack'], deps);
402
413
  expect(exitCode).toBe(1);
403
- expect(errorLogs.some((log) => log.includes("don't look like valid curl options"))).toBe(true);
404
- expect(errorLogs.some((log) => log.includes('Authorization: Bearer'))).toBe(true);
405
414
  });
406
415
  it('should return error when arguments lack curl switches', async () => {
407
416
  const deps = createMockDependencies();
408
417
  await runCommand(['auth', 'set', 'slack', 'my-raw-token-value'], deps);
409
418
  expect(exitCode).toBe(1);
410
- expect(errorLogs.some((log) => log.includes("don't look like valid curl options"))).toBe(true);
411
- expect(errorLogs.some((log) => log.includes('Authorization: Bearer'))).toBe(true);
412
419
  });
413
420
  it('should return error for unknown service', async () => {
414
421
  const deps = createMockDependencies();
415
422
  await runCommand(['auth', 'set', 'unknown-service', '-H', 'X-Token: secret'], deps);
416
423
  expect(exitCode).toBe(1);
417
- expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
418
424
  });
419
425
  it('should overwrite existing credentials', async () => {
420
426
  const storePath = join(tempDir, 'credentials.json');
421
427
  writeSecureFile(storePath, JSON.stringify({
422
428
  slack: { objectType: 'slack', token: 'old-token', dCookie: 'old-cookie' },
423
429
  }));
424
- const deps = createMockDependencies({
425
- config: createMockConfig({ credentialStorePath: storePath }),
426
- });
430
+ const deps = createMockDependencies();
427
431
  await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: new-secret'], deps);
428
432
  expect(logs).toContain('Credentials stored.');
429
433
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
@@ -433,15 +437,53 @@ describe('CLI commands with dependency injection', () => {
433
437
  });
434
438
  });
435
439
  });
440
+ describe('auth set-nocurl command', () => {
441
+ it('should store telegram bot credentials', async () => {
442
+ const storePath = join(tempDir, 'credentials.json');
443
+ writeSecureFile(storePath, '{}');
444
+ const deps = createMockDependencies({
445
+ registry: new Registry([TELEGRAM]),
446
+ });
447
+ await runCommand(['auth', 'set-nocurl', 'telegram', '123456:ABC-DEF'], deps);
448
+ expect(logs).toContain('Credentials stored.');
449
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
450
+ expect(storedData.telegram).toEqual({
451
+ objectType: 'telegramBot',
452
+ token: '123456:ABC-DEF',
453
+ });
454
+ });
455
+ it('should return error for unknown service', async () => {
456
+ const deps = createMockDependencies();
457
+ await runCommand(['auth', 'set-nocurl', 'unknown-service', 'some-arg'], deps);
458
+ expect(exitCode).toBe(1);
459
+ });
460
+ it('should return error when service does not support set-nocurl', async () => {
461
+ const deps = createMockDependencies();
462
+ await runCommand(['auth', 'set-nocurl', 'slack', 'some-token'], deps);
463
+ expect(exitCode).toBe(1);
464
+ });
465
+ it('should return error when telegram token is missing', async () => {
466
+ const deps = createMockDependencies({
467
+ registry: new Registry([TELEGRAM]),
468
+ });
469
+ await runCommand(['auth', 'set-nocurl', 'telegram'], deps);
470
+ expect(exitCode).toBe(1);
471
+ });
472
+ it('should return error when telegram token format is invalid', async () => {
473
+ const deps = createMockDependencies({
474
+ registry: new Registry([TELEGRAM]),
475
+ });
476
+ await runCommand(['auth', 'set-nocurl', 'telegram', 'not-a-valid-token'], deps);
477
+ expect(exitCode).toBe(1);
478
+ });
479
+ });
436
480
  describe('curl command', () => {
437
481
  it('should pass arguments to subprocess', async () => {
438
482
  const storePath = join(tempDir, 'credentials.json');
439
483
  writeSecureFile(storePath, JSON.stringify({
440
484
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
441
485
  }));
442
- const deps = createMockDependencies({
443
- config: createMockConfig({ credentialStorePath: storePath }),
444
- });
486
+ const deps = createMockDependencies();
445
487
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
446
488
  expect(capturedArgs).toEqual([
447
489
  '-H',
@@ -457,9 +499,7 @@ describe('CLI commands with dependency injection', () => {
457
499
  writeSecureFile(storePath, JSON.stringify({
458
500
  slack: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Custom: header'] },
459
501
  }));
460
- const deps = createMockDependencies({
461
- config: createMockConfig({ credentialStorePath: storePath }),
462
- });
502
+ const deps = createMockDependencies();
463
503
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
464
504
  expect(capturedArgs).toEqual(['-H', 'X-Custom: header', 'https://slack.com/api/test']);
465
505
  expect(exitCode).toBe(0);
@@ -469,9 +509,7 @@ describe('CLI commands with dependency injection', () => {
469
509
  writeSecureFile(storePath, JSON.stringify({
470
510
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
471
511
  }));
472
- const deps = createMockDependencies({
473
- config: createMockConfig({ credentialStorePath: storePath }),
474
- });
512
+ const deps = createMockDependencies();
475
513
  await runCommand([
476
514
  'curl',
477
515
  '--',
@@ -494,7 +532,6 @@ describe('CLI commands with dependency injection', () => {
494
532
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
495
533
  }));
496
534
  const deps = createMockDependencies({
497
- config: createMockConfig({ credentialStorePath: storePath }),
498
535
  runCurl: () => ({ returncode: 42, stdout: '', stderr: '' }),
499
536
  });
500
537
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
@@ -504,26 +541,11 @@ describe('CLI commands with dependency injection', () => {
504
541
  const deps = createMockDependencies();
505
542
  await runCommand(['curl', '--', '-X', 'POST'], deps);
506
543
  expect(exitCode).toBe(1);
507
- expect(errorLogs.some((log) => log.includes('Could not extract URL'))).toBe(true);
508
544
  });
509
545
  it('should return error for unknown service', async () => {
510
546
  const deps = createMockDependencies();
511
547
  await runCommand(['curl', 'https://unknown-api.example.com'], deps);
512
548
  expect(exitCode).toBe(1);
513
- expect(errorLogs.some((log) => log.includes('No service matches URL'))).toBe(true);
514
- });
515
- it('should inject credentials with verbose flag', async () => {
516
- const storePath = join(tempDir, 'credentials.json');
517
- writeSecureFile(storePath, JSON.stringify({
518
- slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
519
- }));
520
- const deps = createMockDependencies({
521
- config: createMockConfig({ credentialStorePath: storePath }),
522
- });
523
- await runCommand(['curl', '--', '-v', 'https://slack.com/api/conversations.list'], deps);
524
- expect(capturedArgs).toContain('-v');
525
- expect(capturedArgs).toContain('Authorization: Bearer stored-token');
526
- expect(capturedArgs).toContain('https://slack.com/api/conversations.list');
527
549
  });
528
550
  it('should read credentials from store and not call login', async () => {
529
551
  const storePath = join(tempDir, 'credentials.json');
@@ -539,11 +561,16 @@ describe('CLI commands with dependency injection', () => {
539
561
  info: 'Test info for Slack service.',
540
562
  credentialCheckCurlArguments: [],
541
563
  checkApiCredentials: vi.fn(),
564
+ setCredentialsExample(serviceName) {
565
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer xoxb-your-token"`;
566
+ },
567
+ getCredentialsNoCurl() {
568
+ throw new NoCurlCredentialsNotSupportedError('slack');
569
+ },
542
570
  getSession: vi.fn().mockReturnValue({ login: mockLogin }),
543
571
  };
544
572
  const deps = createMockDependencies({
545
573
  registry: new Registry([mockSlackService]),
546
- config: createMockConfig({ credentialStorePath: storePath }),
547
574
  });
548
575
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
549
576
  expect(mockLogin).not.toHaveBeenCalled();
@@ -552,14 +579,21 @@ describe('CLI commands with dependency injection', () => {
552
579
  it('should return error when no credentials in store', async () => {
553
580
  const storePath = join(tempDir, 'credentials.json');
554
581
  writeSecureFile(storePath, '{}');
555
- const deps = createMockDependencies({
556
- config: createMockConfig({ credentialStorePath: storePath }),
557
- });
582
+ const deps = createMockDependencies();
558
583
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
559
584
  expect(exitCode).toBe(1);
560
- expect(errorLogs.some((log) => log.includes('No credentials found for slack'))).toBe(true);
561
- expect(errorLogs.some((log) => log.includes('auth browser'))).toBe(true);
562
- expect(errorLogs.some((log) => log.includes('auth set'))).toBe(true);
585
+ });
586
+ it('should inject telegram bot token into URL path', async () => {
587
+ const storePath = join(tempDir, 'credentials.json');
588
+ writeSecureFile(storePath, JSON.stringify({
589
+ telegram: { objectType: 'telegramBot', token: '123456:ABC-DEF' },
590
+ }));
591
+ const deps = createMockDependencies({
592
+ registry: new Registry([TELEGRAM]),
593
+ });
594
+ await runCommand(['curl', 'https://api.telegram.org/getMe'], deps);
595
+ expect(capturedArgs).toEqual(['https://api.telegram.org/bot123456:ABC-DEF/getMe']);
596
+ expect(exitCode).toBe(0);
563
597
  });
564
598
  it('should work when service does not have getSession but credentials exist', async () => {
565
599
  const storePath = join(tempDir, 'credentials.json');
@@ -574,11 +608,16 @@ describe('CLI commands with dependency injection', () => {
574
608
  info: 'A service without browser login support.',
575
609
  credentialCheckCurlArguments: [],
576
610
  checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
611
+ setCredentialsExample(serviceName) {
612
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
613
+ },
614
+ getCredentialsNoCurl() {
615
+ throw new NoCurlCredentialsNotSupportedError('nologin');
616
+ },
577
617
  // No getSession - service doesn't support browser login
578
618
  };
579
619
  const deps = createMockDependencies({
580
620
  registry: new Registry([noLoginService]),
581
- config: createMockConfig({ credentialStorePath: storePath }),
582
621
  });
583
622
  await runCommand(['curl', 'https://nologin.example.com/api/test'], deps);
584
623
  expect(exitCode).toBe(0);
@@ -596,6 +635,11 @@ describe('CLI commands with dependency injection', () => {
596
635
  info: 'A service without browser login support.',
597
636
  credentialCheckCurlArguments: [],
598
637
  checkApiCredentials: vi.fn(),
638
+ setCredentialsExample(serviceName) {
639
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
640
+ },
641
+ // eslint-disable-next-line @typescript-eslint/unbound-method
642
+ getCredentialsNoCurl: Service.prototype.getCredentialsNoCurl,
599
643
  // No getSession - service doesn't support browser login
600
644
  };
601
645
  const deps = createMockDependencies({
@@ -603,174 +647,630 @@ describe('CLI commands with dependency injection', () => {
603
647
  });
604
648
  await runCommand(['auth', 'browser', 'nologin'], deps);
605
649
  expect(exitCode).toBe(1);
606
- const expectedMessage = new BrowserFlowsNotSupportedError('nologin').message;
607
- expect(errorLogs.some((log) => log.includes(expectedMessage))).toBe(true);
650
+ });
651
+ it('should suggest set-nocurl when service supports nocurl credentials', async () => {
652
+ const nocurlService = {
653
+ name: 'nocurl-only',
654
+ displayName: 'NoCurl Only Service',
655
+ baseApiUrls: ['https://nocurl.example.com/api/'],
656
+ loginUrl: 'https://nocurl.example.com',
657
+ info: 'A service with nocurl credentials but no browser login.',
658
+ credentialCheckCurlArguments: [],
659
+ checkApiCredentials: vi.fn(),
660
+ setCredentialsExample(serviceName) {
661
+ return `latchkey auth set-nocurl ${serviceName} <some-arg>`;
662
+ },
663
+ getCredentialsNoCurl(arguments_) {
664
+ if (arguments_.length !== 1) {
665
+ throw new Error('Expected exactly one argument');
666
+ }
667
+ return { objectType: 'test', injectIntoCurlCall: vi.fn(), isExpired: () => false };
668
+ },
669
+ // No getSession - service doesn't support browser login
670
+ };
671
+ const deps = createMockDependencies({
672
+ registry: new Registry([nocurlService]),
673
+ });
674
+ await runCommand(['auth', 'browser', 'nocurl-only'], deps);
675
+ expect(exitCode).toBe(1);
676
+ });
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.");
608
1128
  });
609
1129
  });
610
1130
  });
611
- // Integration tests that run the actual CLI binary
612
- describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
1131
+ describe('registeredServiceStore', () => {
613
1132
  let tempDir;
614
- let testEnv;
615
1133
  beforeEach(() => {
616
- tempDir = mkdtempSync(join(tmpdir(), 'latchkey-cli-test-'));
617
- testEnv = {
618
- LATCHKEY_STORE: join(tempDir, 'credentials.json'),
619
- LATCHKEY_BROWSER_STATE: join(tempDir, 'browser_state.json'),
620
- };
1134
+ tempDir = mkdtempSync(join(tmpdir(), 'latchkey-store-test-'));
621
1135
  });
622
1136
  afterEach(() => {
623
1137
  rmSync(tempDir, { recursive: true, force: true });
624
1138
  });
625
- describe('curl command', () => {
626
- it('should return error when curl has no arguments', () => {
627
- const result = runCli(['curl'], testEnv);
628
- expect(result.exitCode).toBe(1);
629
- expect(result.stderr).toContain('Could not extract URL');
630
- });
631
- it('should return error when no URL found in curl arguments', () => {
632
- const result = runCli(['curl', '--', '-X', 'POST'], testEnv);
633
- expect(result.exitCode).toBe(1);
634
- expect(result.stderr).toContain('Could not extract URL');
635
- });
636
- it('should return error for unknown service', () => {
637
- const result = runCli(['curl', 'https://unknown-api.example.com'], testEnv);
638
- expect(result.exitCode).toBe(1);
639
- expect(result.stderr).toContain('No service matches URL');
640
- expect(result.stderr).toContain('https://unknown-api.example.com');
641
- });
642
- it('should return error when no credentials exist', () => {
643
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
644
- const result = runCli(['curl', 'https://slack.com/api/test'], testEnv);
645
- expect(result.exitCode).toBe(1);
646
- expect(result.stderr).toContain('No credentials found for slack');
647
- expect(result.stderr).toContain('auth browser');
648
- expect(result.stderr).toContain('auth set');
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',
649
1144
  });
650
- });
651
- describe('auth browser command', () => {
652
- it('should return error when browser is disabled via LATCHKEY_DISABLE_BROWSER', () => {
653
- const result = runCli(['auth', 'browser', 'slack'], {
654
- ...testEnv,
655
- LATCHKEY_DISABLE_BROWSER: '1',
656
- });
657
- expect(result.exitCode).toBe(1);
658
- expect(result.stderr).toContain('Browser is disabled');
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',
659
1150
  });
660
1151
  });
661
- describe('clear command', () => {
662
- it('should delete credentials for a service', () => {
663
- writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
664
- slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
665
- }));
666
- const result = runCli(['auth', 'clear', 'slack'], testEnv);
667
- expect(result.exitCode).toBe(0);
668
- expect(result.stdout).toContain('API credentials for slack have been cleared');
669
- const storedData = JSON.parse(readSecureFile(testEnv.LATCHKEY_STORE) ?? '{}');
670
- expect(storedData.slack).toBeUndefined();
671
- });
672
- it('should report no credentials found when service has no stored credentials', () => {
673
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
674
- const result = runCli(['auth', 'clear', 'slack'], testEnv);
675
- expect(result.exitCode).toBe(0);
676
- expect(result.stdout).toContain('No API credentials found for slack');
677
- });
678
- it('should return error for unknown service', () => {
679
- const result = runCli(['auth', 'clear', 'unknown-service'], testEnv);
680
- expect(result.exitCode).toBe(1);
681
- expect(result.stderr).toContain('Unknown service: unknown-service');
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',
682
1163
  });
683
- it('should preserve other services when clearing one', () => {
684
- writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
685
- slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
686
- discord: { objectType: 'authorizationBare', token: 'discord-token' },
687
- }));
688
- const result = runCli(['auth', 'clear', 'slack'], testEnv);
689
- expect(result.exitCode).toBe(0);
690
- const storedData = JSON.parse(readSecureFile(testEnv.LATCHKEY_STORE) ?? '{}');
691
- expect(storedData.slack).toBeUndefined();
692
- expect(storedData.discord).toBeDefined();
693
- expect(storedData.discord?.token).toBe('discord-token');
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',
694
1173
  });
695
- it('should delete both store and browser state with -y flag', () => {
696
- writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } }));
697
- writeSecureFile(testEnv.LATCHKEY_BROWSER_STATE, '{}');
698
- const result = runCli(['auth', 'clear', '-y'], testEnv);
699
- expect(result.exitCode).toBe(0);
700
- expect(existsSync(testEnv.LATCHKEY_STORE)).toBe(false);
701
- expect(existsSync(testEnv.LATCHKEY_BROWSER_STATE)).toBe(false);
702
- expect(result.stdout).toContain(`Deleted credentials store: ${testEnv.LATCHKEY_STORE}`);
703
- expect(result.stdout).toContain(`Deleted browser state: ${testEnv.LATCHKEY_BROWSER_STATE}`);
704
- });
705
- it('should delete only existing files with -y flag', () => {
706
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
707
- // browser_state does not exist
708
- const result = runCli(['auth', 'clear', '-y'], testEnv);
709
- expect(result.exitCode).toBe(0);
710
- expect(existsSync(testEnv.LATCHKEY_STORE)).toBe(false);
711
- expect(result.stdout).toContain(`Deleted credentials store: ${testEnv.LATCHKEY_STORE}`);
712
- expect(result.stdout).not.toContain('browser state');
713
- });
714
- it('should report no files to delete when none exist', () => {
715
- const result = runCli(['auth', 'clear', '-y'], testEnv);
716
- expect(result.exitCode).toBe(0);
717
- expect(result.stdout).toContain('No files to delete');
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',
718
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');
719
1192
  });
720
- describe('auth list command', () => {
721
- it('should list stored credentials as beautified JSON', () => {
722
- writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
723
- slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
724
- }));
725
- const result = runCli(['auth', 'list'], testEnv);
726
- expect(result.exitCode).toBe(0);
727
- const entries = JSON.parse(result.stdout);
728
- expect(entries.slack).toBeDefined();
729
- expect(entries.slack?.credentialType).toBe('slack');
730
- expect(entries.slack?.credentialStatus).toEqual(expect.any(String));
731
- });
732
- it('should output empty object when no credentials are stored', () => {
733
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
734
- const result = runCli(['auth', 'list'], testEnv);
735
- expect(result.exitCode).toBe(0);
736
- const entries = JSON.parse(result.stdout);
737
- expect(Object.keys(entries)).toHaveLength(0);
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/',
738
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('');
739
1205
  });
740
- describe('services list command', () => {
741
- it('should list all services as JSON', () => {
742
- const result = runCli(['services', 'list'], testEnv);
743
- expect(result.exitCode).toBe(0);
744
- const services = JSON.parse(result.stdout.trim());
745
- expect(services).toContain('slack');
746
- expect(services).toContain('discord');
747
- expect(services).toContain('github');
748
- expect(services).toContain('dropbox');
749
- expect(services).toContain('linear');
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',
750
1211
  });
1212
+ const registry = new Registry([GITLAB]);
1213
+ loadRegisteredServicesIntoRegistry(configPath, registry);
1214
+ expect(registry.getByName('my-unknown')).toBeNull();
751
1215
  });
752
- describe('services info command', () => {
753
- it('should show login options, credentials status, and developer notes as JSON', () => {
754
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
755
- const result = runCli(['services', 'info', 'slack'], testEnv);
756
- expect(result.exitCode).toBe(0);
757
- const info = JSON.parse(result.stdout);
758
- expect(info.authOptions).toEqual(['browser', 'set']);
759
- expect(info.credentialStatus).toBe('missing');
760
- expect(info.developerNotes).toEqual(expect.any(String));
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',
761
1221
  });
762
- it('should show auth set only for services without browser login', () => {
763
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
764
- const result = runCli(['services', 'info', 'mailchimp'], testEnv);
765
- expect(result.exitCode).toBe(0);
766
- const info = JSON.parse(result.stdout);
767
- expect(info.authOptions).toEqual(['set']);
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',
768
1237
  });
769
- it('should return error for unknown service', () => {
770
- const result = runCli(['services', 'info', 'unknown-service'], testEnv);
771
- expect(result.exitCode).toBe(1);
772
- expect(result.stderr).toContain('Unknown service');
1238
+ deleteRegisteredService(configPath, 'my-gitlab');
1239
+ const content = JSON.parse(readFileSync(configPath, 'utf-8'));
1240
+ expect(content.browser).toEqual({ executablePath: '/usr/bin/chrome' });
1241
+ });
1242
+ });
1243
+ // Integration tests that run the actual CLI binary.
1244
+ // Only tests that exercise behavior not covered by the DI unit tests above.
1245
+ describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
1246
+ let tempDir;
1247
+ let testEnv;
1248
+ beforeEach(() => {
1249
+ tempDir = mkdtempSync(join(tmpdir(), 'latchkey-cli-test-'));
1250
+ testEnv = {
1251
+ LATCHKEY_DIRECTORY: tempDir,
1252
+ };
1253
+ });
1254
+ afterEach(() => {
1255
+ rmSync(tempDir, { recursive: true, force: true });
1256
+ });
1257
+ it('should return non-zero exit code for unknown service URL', () => {
1258
+ const result = runCli(['curl', 'https://unknown-api.example.com'], testEnv);
1259
+ expect(result.exitCode).toBe(1);
1260
+ });
1261
+ it('should return error when browser is disabled via LATCHKEY_DISABLE_BROWSER', () => {
1262
+ const result = runCli(['auth', 'browser', 'slack'], {
1263
+ ...testEnv,
1264
+ LATCHKEY_DISABLE_BROWSER: '1',
773
1265
  });
1266
+ expect(result.exitCode).toBe(1);
1267
+ });
1268
+ it('should list services as JSON', () => {
1269
+ const result = runCli(['services', 'list'], testEnv);
1270
+ expect(result.exitCode).toBe(0);
1271
+ const services = JSON.parse(result.stdout.trim());
1272
+ expect(services).toContain('slack');
1273
+ expect(services).toContain('github');
774
1274
  });
775
1275
  });
776
1276
  //# sourceMappingURL=cli.test.js.map