latchkey 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +27 -7
  2. package/dist/integrations/SKILL.md +9 -2
  3. package/dist/package.json +1 -1
  4. package/dist/src/apiCredentialStore.d.ts +1 -1
  5. package/dist/src/apiCredentialStore.d.ts.map +1 -1
  6. package/dist/src/apiCredentialStore.js +1 -1
  7. package/dist/src/apiCredentialStore.js.map +1 -1
  8. package/dist/src/apiCredentials.d.ts +11 -114
  9. package/dist/src/apiCredentials.d.ts.map +1 -1
  10. package/dist/src/apiCredentials.js +9 -101
  11. package/dist/src/apiCredentials.js.map +1 -1
  12. package/dist/src/apiCredentialsSerialization.d.ts +119 -0
  13. package/dist/src/apiCredentialsSerialization.d.ts.map +1 -0
  14. package/dist/src/apiCredentialsSerialization.js +90 -0
  15. package/dist/src/apiCredentialsSerialization.js.map +1 -0
  16. package/dist/src/browserConfig.d.ts +1 -5
  17. package/dist/src/browserConfig.d.ts.map +1 -1
  18. package/dist/src/browserConfig.js +2 -8
  19. package/dist/src/browserConfig.js.map +1 -1
  20. package/dist/src/cli.js +20 -0
  21. package/dist/src/cli.js.map +1 -1
  22. package/dist/src/cliCommands.d.ts +0 -1
  23. package/dist/src/cliCommands.d.ts.map +1 -1
  24. package/dist/src/cliCommands.js +50 -40
  25. package/dist/src/cliCommands.js.map +1 -1
  26. package/dist/src/config.d.ts +4 -3
  27. package/dist/src/config.d.ts.map +1 -1
  28. package/dist/src/config.js +17 -23
  29. package/dist/src/config.js.map +1 -1
  30. package/dist/src/curl.d.ts +10 -0
  31. package/dist/src/curl.d.ts.map +1 -1
  32. package/dist/src/curl.js +88 -0
  33. package/dist/src/curl.js.map +1 -1
  34. package/dist/src/encryptedStorage.d.ts +9 -0
  35. package/dist/src/encryptedStorage.d.ts.map +1 -1
  36. package/dist/src/encryptedStorage.js +12 -0
  37. package/dist/src/encryptedStorage.js.map +1 -1
  38. package/dist/src/index.d.ts +4 -2
  39. package/dist/src/index.d.ts.map +1 -1
  40. package/dist/src/index.js +4 -2
  41. package/dist/src/index.js.map +1 -1
  42. package/dist/src/migrations.d.ts +9 -0
  43. package/dist/src/migrations.d.ts.map +1 -0
  44. package/dist/src/migrations.js +77 -0
  45. package/dist/src/migrations.js.map +1 -0
  46. package/dist/src/oauthUtils.d.ts +4 -1
  47. package/dist/src/oauthUtils.d.ts.map +1 -1
  48. package/dist/src/oauthUtils.js.map +1 -1
  49. package/dist/src/playwrightUtils.d.ts +2 -2
  50. package/dist/src/playwrightUtils.d.ts.map +1 -1
  51. package/dist/src/playwrightUtils.js +4 -4
  52. package/dist/src/playwrightUtils.js.map +1 -1
  53. package/dist/src/registry.d.ts.map +1 -1
  54. package/dist/src/registry.js +18 -2
  55. package/dist/src/registry.js.map +1 -1
  56. package/dist/src/services/aws.d.ts +44 -0
  57. package/dist/src/services/aws.d.ts.map +1 -0
  58. package/dist/src/services/aws.js +237 -0
  59. package/dist/src/services/aws.js.map +1 -0
  60. package/dist/src/services/base.d.ts +14 -0
  61. package/dist/src/services/base.d.ts.map +1 -1
  62. package/dist/src/services/base.js +23 -11
  63. package/dist/src/services/base.js.map +1 -1
  64. package/dist/src/services/calendly.d.ts +12 -0
  65. package/dist/src/services/calendly.d.ts.map +1 -0
  66. package/dist/src/services/calendly.js +18 -0
  67. package/dist/src/services/calendly.js.map +1 -0
  68. package/dist/src/services/discord.d.ts +1 -0
  69. package/dist/src/services/discord.d.ts.map +1 -1
  70. package/dist/src/services/discord.js +3 -0
  71. package/dist/src/services/discord.js.map +1 -1
  72. package/dist/src/services/dropbox.d.ts +2 -1
  73. package/dist/src/services/dropbox.d.ts.map +1 -1
  74. package/dist/src/services/dropbox.js +5 -0
  75. package/dist/src/services/dropbox.js.map +1 -1
  76. package/dist/src/services/figma.d.ts +12 -0
  77. package/dist/src/services/figma.d.ts.map +1 -0
  78. package/dist/src/services/figma.js +14 -0
  79. package/dist/src/services/figma.js.map +1 -0
  80. package/dist/src/services/github.d.ts +1 -0
  81. package/dist/src/services/github.d.ts.map +1 -1
  82. package/dist/src/services/github.js +3 -0
  83. package/dist/src/services/github.js.map +1 -1
  84. package/dist/src/services/gitlab.d.ts +12 -0
  85. package/dist/src/services/gitlab.d.ts.map +1 -0
  86. package/dist/src/services/gitlab.js +14 -0
  87. package/dist/src/services/gitlab.js.map +1 -0
  88. package/dist/src/services/google/analytics.d.ts +11 -0
  89. package/dist/src/services/google/analytics.d.ts.map +1 -0
  90. package/dist/src/services/google/analytics.js +22 -0
  91. package/dist/src/services/google/analytics.js.map +1 -0
  92. package/dist/src/services/google/base.d.ts +73 -0
  93. package/dist/src/services/google/base.d.ts.map +1 -0
  94. package/dist/src/services/google/base.js +323 -0
  95. package/dist/src/services/google/base.js.map +1 -0
  96. package/dist/src/services/google/calendar.d.ts +11 -0
  97. package/dist/src/services/google/calendar.d.ts.map +1 -0
  98. package/dist/src/services/google/calendar.js +22 -0
  99. package/dist/src/services/google/calendar.js.map +1 -0
  100. package/dist/src/services/google/directions.d.ts +14 -0
  101. package/dist/src/services/google/directions.d.ts.map +1 -0
  102. package/dist/src/services/google/directions.js +49 -0
  103. package/dist/src/services/google/directions.js.map +1 -0
  104. package/dist/src/services/google/docs.d.ts +11 -0
  105. package/dist/src/services/google/docs.d.ts.map +1 -0
  106. package/dist/src/services/google/docs.js +19 -0
  107. package/dist/src/services/google/docs.js.map +1 -0
  108. package/dist/src/services/google/drive.d.ts +11 -0
  109. package/dist/src/services/google/drive.d.ts.map +1 -0
  110. package/dist/src/services/google/drive.js +19 -0
  111. package/dist/src/services/google/drive.js.map +1 -0
  112. package/dist/src/services/google/gmail.d.ts +11 -0
  113. package/dist/src/services/google/gmail.d.ts.map +1 -0
  114. package/dist/src/services/google/gmail.js +24 -0
  115. package/dist/src/services/google/gmail.js.map +1 -0
  116. package/dist/src/services/google/maps.d.ts +39 -0
  117. package/dist/src/services/google/maps.d.ts.map +1 -0
  118. package/dist/src/services/google/maps.js +94 -0
  119. package/dist/src/services/google/maps.js.map +1 -0
  120. package/dist/src/services/google/people.d.ts +11 -0
  121. package/dist/src/services/google/people.d.ts.map +1 -0
  122. package/dist/src/services/google/people.js +22 -0
  123. package/dist/src/services/google/people.js.map +1 -0
  124. package/dist/src/services/google/sheets.d.ts +11 -0
  125. package/dist/src/services/google/sheets.d.ts.map +1 -0
  126. package/dist/src/services/google/sheets.js +19 -0
  127. package/dist/src/services/google/sheets.js.map +1 -0
  128. package/dist/src/services/googleAnalytics.d.ts +11 -0
  129. package/dist/src/services/googleAnalytics.d.ts.map +1 -0
  130. package/dist/src/services/googleAnalytics.js +18 -0
  131. package/dist/src/services/googleAnalytics.js.map +1 -0
  132. package/dist/src/services/googleMaps.d.ts +12 -0
  133. package/dist/src/services/googleMaps.d.ts.map +1 -0
  134. package/dist/src/services/googleMaps.js +17 -0
  135. package/dist/src/services/googleMaps.js.map +1 -0
  136. package/dist/src/services/index.d.ts +19 -2
  137. package/dist/src/services/index.d.ts.map +1 -1
  138. package/dist/src/services/index.js +19 -2
  139. package/dist/src/services/index.js.map +1 -1
  140. package/dist/src/services/linear.d.ts +1 -0
  141. package/dist/src/services/linear.d.ts.map +1 -1
  142. package/dist/src/services/linear.js +3 -0
  143. package/dist/src/services/linear.js.map +1 -1
  144. package/dist/src/services/mailchimp.d.ts +2 -1
  145. package/dist/src/services/mailchimp.d.ts.map +1 -1
  146. package/dist/src/services/mailchimp.js +4 -3
  147. package/dist/src/services/mailchimp.js.map +1 -1
  148. package/dist/src/services/notion.d.ts +1 -0
  149. package/dist/src/services/notion.d.ts.map +1 -1
  150. package/dist/src/services/notion.js +3 -0
  151. package/dist/src/services/notion.js.map +1 -1
  152. package/dist/src/services/sentry.d.ts +14 -0
  153. package/dist/src/services/sentry.d.ts.map +1 -0
  154. package/dist/src/services/sentry.js +43 -0
  155. package/dist/src/services/sentry.js.map +1 -0
  156. package/dist/src/services/slack.d.ts +30 -1
  157. package/dist/src/services/slack.d.ts.map +1 -1
  158. package/dist/src/services/slack.js +45 -2
  159. package/dist/src/services/slack.js.map +1 -1
  160. package/dist/src/services/stripe.d.ts +12 -0
  161. package/dist/src/services/stripe.d.ts.map +1 -0
  162. package/dist/src/services/stripe.js +14 -0
  163. package/dist/src/services/stripe.js.map +1 -0
  164. package/dist/src/services/telegram.d.ts +40 -0
  165. package/dist/src/services/telegram.d.ts.map +1 -0
  166. package/dist/src/services/telegram.js +73 -0
  167. package/dist/src/services/telegram.js.map +1 -0
  168. package/dist/src/services/yelp.d.ts +12 -0
  169. package/dist/src/services/yelp.d.ts.map +1 -0
  170. package/dist/src/services/yelp.js +16 -0
  171. package/dist/src/services/yelp.js.map +1 -0
  172. package/dist/src/services/zoom.d.ts +12 -0
  173. package/dist/src/services/zoom.d.ts.map +1 -0
  174. package/dist/src/services/zoom.js +18 -0
  175. package/dist/src/services/zoom.js.map +1 -0
  176. package/dist/tests/apiCredentialStore.test.js +2 -19
  177. package/dist/tests/apiCredentialStore.test.js.map +1 -1
  178. package/dist/tests/apiCredentials.test.js +139 -178
  179. package/dist/tests/apiCredentials.test.js.map +1 -1
  180. package/dist/tests/cli.test.js +160 -260
  181. package/dist/tests/cli.test.js.map +1 -1
  182. package/dist/tests/encryptedStorage.test.js +0 -4
  183. package/dist/tests/encryptedStorage.test.js.map +1 -1
  184. package/dist/tests/encryptedStorageKeyGeneration.test.d.ts +2 -0
  185. package/dist/tests/encryptedStorageKeyGeneration.test.d.ts.map +1 -0
  186. package/dist/tests/encryptedStorageKeyGeneration.test.js +22 -0
  187. package/dist/tests/encryptedStorageKeyGeneration.test.js.map +1 -0
  188. package/dist/tests/encryption.test.js +3 -35
  189. package/dist/tests/encryption.test.js.map +1 -1
  190. package/dist/tests/migrations.test.d.ts +2 -0
  191. package/dist/tests/migrations.test.d.ts.map +1 -0
  192. package/dist/tests/migrations.test.js +164 -0
  193. package/dist/tests/migrations.test.js.map +1 -0
  194. package/dist/tests/playwrightDownload.test.js +2 -65
  195. package/dist/tests/playwrightDownload.test.js.map +1 -1
  196. package/dist/tests/registry.test.js +49 -75
  197. package/dist/tests/registry.test.js.map +1 -1
  198. package/dist/tests/servicesAgainstRecordings.test.js +1 -1
  199. package/dist/tests/servicesAgainstRecordings.test.js.map +1 -1
  200. package/package.json +1 -1
@@ -4,12 +4,15 @@ 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/base.js';
15
+ import { TELEGRAM } from '../src/services/telegram.js';
13
16
  // Use a fixed test key for deterministic test behavior (32 bytes = 256 bits, base64 encoded)
14
17
  const TEST_ENCRYPTION_KEY = 'dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=';
15
18
  function writeSecureFile(path, content) {
@@ -115,10 +118,18 @@ describe('CLI commands with dependency injection', () => {
115
118
  let exitCode;
116
119
  function createMockConfig(overrides = {}) {
117
120
  const defaultConfig = new Config(() => undefined);
121
+ const directory = overrides.directory ?? tempDir;
118
122
  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'),
123
+ directory,
124
+ get credentialStorePath() {
125
+ return join(directory, 'credentials.json');
126
+ },
127
+ get browserStatePath() {
128
+ return join(directory, 'browser_state.json');
129
+ },
130
+ get configPath() {
131
+ return join(directory, 'config.json');
132
+ },
122
133
  curlCommand: overrides.curlCommand ?? defaultConfig.curlCommand,
123
134
  encryptionKeyOverride: overrides.encryptionKeyOverride ?? TEST_ENCRYPTION_KEY,
124
135
  serviceName: overrides.serviceName ?? defaultConfig.serviceName,
@@ -137,6 +148,12 @@ describe('CLI commands with dependency injection', () => {
137
148
  info: 'Test info for Slack service.',
138
149
  credentialCheckCurlArguments: ['https://slack.com/api/auth.test'],
139
150
  checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
151
+ setCredentialsExample(serviceName) {
152
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer xoxb-your-token"`;
153
+ },
154
+ getCredentialsNoCurl() {
155
+ throw new NoCurlCredentialsNotSupportedError('slack');
156
+ },
140
157
  getSession: vi.fn().mockReturnValue({
141
158
  login: vi.fn().mockResolvedValue(new SlackApiCredentials('xoxc-test-token', 'test-cookie')),
142
159
  }),
@@ -200,14 +217,13 @@ describe('CLI commands with dependency injection', () => {
200
217
  it('should show login options, credentials status, and developer notes', async () => {
201
218
  const storePath = join(tempDir, 'credentials.json');
202
219
  writeSecureFile(storePath, '{}');
203
- const deps = createMockDependencies({
204
- config: createMockConfig({ credentialStorePath: storePath }),
205
- });
220
+ const deps = createMockDependencies();
206
221
  await runCommand(['services', 'info', 'slack'], deps);
207
222
  expect(logs).toHaveLength(1);
208
223
  const info = JSON.parse(logs[0] ?? '');
209
224
  expect(info.authOptions).toEqual(['browser', 'set']);
210
225
  expect(info.credentialStatus).toBe('missing');
226
+ expect(info.setCredentialsExample).toBe('latchkey auth set slack -H "Authorization: Bearer xoxb-your-token"');
211
227
  expect(info.developerNotes).toBe('Test info for Slack service.');
212
228
  });
213
229
  it('should show auth set only for services without browser login', async () => {
@@ -221,10 +237,15 @@ describe('CLI commands with dependency injection', () => {
221
237
  info: 'A service without browser login support.',
222
238
  credentialCheckCurlArguments: [],
223
239
  checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Missing),
240
+ setCredentialsExample(serviceName) {
241
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
242
+ },
243
+ getCredentialsNoCurl() {
244
+ throw new NoCurlCredentialsNotSupportedError('nologin');
245
+ },
224
246
  };
225
247
  const deps = createMockDependencies({
226
248
  registry: new Registry([noLoginService]),
227
- config: createMockConfig({ credentialStorePath: storePath }),
228
249
  });
229
250
  await runCommand(['services', 'info', 'nologin'], deps);
230
251
  const info = JSON.parse(logs[0] ?? '');
@@ -234,7 +255,7 @@ describe('CLI commands with dependency injection', () => {
234
255
  const storePath = join(tempDir, 'credentials.json');
235
256
  writeSecureFile(storePath, '{}');
236
257
  const deps = createMockDependencies({
237
- config: createMockConfig({ credentialStorePath: storePath, browserDisabled: true }),
258
+ config: createMockConfig({ browserDisabled: true }),
238
259
  });
239
260
  await runCommand(['services', 'info', 'slack'], deps);
240
261
  const info = JSON.parse(logs[0] ?? '');
@@ -245,9 +266,7 @@ describe('CLI commands with dependency injection', () => {
245
266
  writeSecureFile(storePath, JSON.stringify({
246
267
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
247
268
  }));
248
- const deps = createMockDependencies({
249
- config: createMockConfig({ credentialStorePath: storePath }),
250
- });
269
+ const deps = createMockDependencies();
251
270
  await runCommand(['services', 'info', 'slack'], deps);
252
271
  const info = JSON.parse(logs[0] ?? '');
253
272
  expect(info.credentialStatus).toBe('valid');
@@ -256,7 +275,7 @@ describe('CLI commands with dependency injection', () => {
256
275
  const deps = createMockDependencies();
257
276
  await runCommand(['services', 'info', 'unknown-service'], deps);
258
277
  expect(exitCode).toBe(1);
259
- expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
278
+ expect(errorLogs.length).toBeGreaterThan(0);
260
279
  });
261
280
  });
262
281
  describe('clear command', () => {
@@ -265,37 +284,16 @@ describe('CLI commands with dependency injection', () => {
265
284
  writeSecureFile(storePath, JSON.stringify({
266
285
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
267
286
  }));
268
- const deps = createMockDependencies({
269
- config: createMockConfig({ credentialStorePath: storePath }),
270
- });
287
+ const deps = createMockDependencies();
271
288
  await runCommand(['auth', 'clear', 'slack'], deps);
272
- expect(logs.some((log) => log.includes('have been cleared'))).toBe(true);
273
289
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
274
290
  expect(storedData.slack).toBeUndefined();
275
291
  });
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
292
  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
- });
293
+ const deps = createMockDependencies();
290
294
  await runCommand(['auth', 'clear', 'unknown-service'], deps);
291
295
  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);
296
+ expect(errorLogs.length).toBeGreaterThan(0);
299
297
  });
300
298
  it('should preserve other services when clearing one', async () => {
301
299
  const storePath = join(tempDir, 'credentials.json');
@@ -303,9 +301,7 @@ describe('CLI commands with dependency injection', () => {
303
301
  slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
304
302
  discord: { objectType: 'authorizationBare', token: 'discord-token' },
305
303
  }));
306
- const deps = createMockDependencies({
307
- config: createMockConfig({ credentialStorePath: storePath }),
308
- });
304
+ const deps = createMockDependencies();
309
305
  await runCommand(['auth', 'clear', 'slack'], deps);
310
306
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
311
307
  expect(storedData.slack).toBeUndefined();
@@ -317,23 +313,10 @@ describe('CLI commands with dependency injection', () => {
317
313
  const browserStatePath = join(tempDir, 'browser_state.json');
318
314
  writeSecureFile(storePath, JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } }));
319
315
  writeSecureFile(browserStatePath, '{}');
320
- const deps = createMockDependencies({
321
- config: createMockConfig({ credentialStorePath: storePath, browserStatePath }),
322
- });
316
+ const deps = createMockDependencies();
323
317
  await runCommand(['auth', 'clear', '-y'], deps);
324
318
  expect(existsSync(storePath)).toBe(false);
325
319
  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
320
  });
338
321
  });
339
322
  describe('auth list command', () => {
@@ -342,9 +325,7 @@ describe('CLI commands with dependency injection', () => {
342
325
  writeSecureFile(storePath, JSON.stringify({
343
326
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
344
327
  }));
345
- const deps = createMockDependencies({
346
- config: createMockConfig({ credentialStorePath: storePath }),
347
- });
328
+ const deps = createMockDependencies();
348
329
  await runCommand(['auth', 'list'], deps);
349
330
  expect(logs).toHaveLength(1);
350
331
  const entries = JSON.parse(logs[0] ?? '');
@@ -356,9 +337,7 @@ describe('CLI commands with dependency injection', () => {
356
337
  it('should output empty object when no credentials are stored', async () => {
357
338
  const storePath = join(tempDir, 'credentials.json');
358
339
  writeSecureFile(storePath, '{}');
359
- const deps = createMockDependencies({
360
- config: createMockConfig({ credentialStorePath: storePath }),
361
- });
340
+ const deps = createMockDependencies();
362
341
  await runCommand(['auth', 'list'], deps);
363
342
  expect(logs).toHaveLength(1);
364
343
  const entries = JSON.parse(logs[0] ?? '');
@@ -369,9 +348,7 @@ describe('CLI commands with dependency injection', () => {
369
348
  writeSecureFile(storePath, JSON.stringify({
370
349
  unknown: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: secret'] },
371
350
  }));
372
- const deps = createMockDependencies({
373
- config: createMockConfig({ credentialStorePath: storePath }),
374
- });
351
+ const deps = createMockDependencies();
375
352
  await runCommand(['auth', 'list'], deps);
376
353
  expect(logs).toHaveLength(1);
377
354
  const entries = JSON.parse(logs[0] ?? '');
@@ -385,9 +362,7 @@ describe('CLI commands with dependency injection', () => {
385
362
  it('should store raw curl credentials', async () => {
386
363
  const storePath = join(tempDir, 'credentials.json');
387
364
  writeSecureFile(storePath, '{}');
388
- const deps = createMockDependencies({
389
- config: createMockConfig({ credentialStorePath: storePath }),
390
- });
365
+ const deps = createMockDependencies();
391
366
  await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: secret', '-H', 'X-Other: value'], deps);
392
367
  expect(logs).toContain('Credentials stored.');
393
368
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
@@ -400,30 +375,23 @@ describe('CLI commands with dependency injection', () => {
400
375
  const deps = createMockDependencies();
401
376
  await runCommand(['auth', 'set', 'slack'], deps);
402
377
  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
378
  });
406
379
  it('should return error when arguments lack curl switches', async () => {
407
380
  const deps = createMockDependencies();
408
381
  await runCommand(['auth', 'set', 'slack', 'my-raw-token-value'], deps);
409
382
  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
383
  });
413
384
  it('should return error for unknown service', async () => {
414
385
  const deps = createMockDependencies();
415
386
  await runCommand(['auth', 'set', 'unknown-service', '-H', 'X-Token: secret'], deps);
416
387
  expect(exitCode).toBe(1);
417
- expect(errorLogs.some((log) => log.includes('Unknown service'))).toBe(true);
418
388
  });
419
389
  it('should overwrite existing credentials', async () => {
420
390
  const storePath = join(tempDir, 'credentials.json');
421
391
  writeSecureFile(storePath, JSON.stringify({
422
392
  slack: { objectType: 'slack', token: 'old-token', dCookie: 'old-cookie' },
423
393
  }));
424
- const deps = createMockDependencies({
425
- config: createMockConfig({ credentialStorePath: storePath }),
426
- });
394
+ const deps = createMockDependencies();
427
395
  await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: new-secret'], deps);
428
396
  expect(logs).toContain('Credentials stored.');
429
397
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
@@ -433,15 +401,53 @@ describe('CLI commands with dependency injection', () => {
433
401
  });
434
402
  });
435
403
  });
404
+ describe('auth set-nocurl command', () => {
405
+ it('should store telegram bot credentials', async () => {
406
+ const storePath = join(tempDir, 'credentials.json');
407
+ writeSecureFile(storePath, '{}');
408
+ const deps = createMockDependencies({
409
+ registry: new Registry([TELEGRAM]),
410
+ });
411
+ await runCommand(['auth', 'set-nocurl', 'telegram', '123456:ABC-DEF'], deps);
412
+ expect(logs).toContain('Credentials stored.');
413
+ const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
414
+ expect(storedData.telegram).toEqual({
415
+ objectType: 'telegramBot',
416
+ token: '123456:ABC-DEF',
417
+ });
418
+ });
419
+ it('should return error for unknown service', async () => {
420
+ const deps = createMockDependencies();
421
+ await runCommand(['auth', 'set-nocurl', 'unknown-service', 'some-arg'], deps);
422
+ expect(exitCode).toBe(1);
423
+ });
424
+ it('should return error when service does not support set-nocurl', async () => {
425
+ const deps = createMockDependencies();
426
+ await runCommand(['auth', 'set-nocurl', 'slack', 'some-token'], deps);
427
+ expect(exitCode).toBe(1);
428
+ });
429
+ it('should return error when telegram token is missing', async () => {
430
+ const deps = createMockDependencies({
431
+ registry: new Registry([TELEGRAM]),
432
+ });
433
+ await runCommand(['auth', 'set-nocurl', 'telegram'], deps);
434
+ expect(exitCode).toBe(1);
435
+ });
436
+ it('should return error when telegram token format is invalid', async () => {
437
+ const deps = createMockDependencies({
438
+ registry: new Registry([TELEGRAM]),
439
+ });
440
+ await runCommand(['auth', 'set-nocurl', 'telegram', 'not-a-valid-token'], deps);
441
+ expect(exitCode).toBe(1);
442
+ });
443
+ });
436
444
  describe('curl command', () => {
437
445
  it('should pass arguments to subprocess', async () => {
438
446
  const storePath = join(tempDir, 'credentials.json');
439
447
  writeSecureFile(storePath, JSON.stringify({
440
448
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
441
449
  }));
442
- const deps = createMockDependencies({
443
- config: createMockConfig({ credentialStorePath: storePath }),
444
- });
450
+ const deps = createMockDependencies();
445
451
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
446
452
  expect(capturedArgs).toEqual([
447
453
  '-H',
@@ -457,9 +463,7 @@ describe('CLI commands with dependency injection', () => {
457
463
  writeSecureFile(storePath, JSON.stringify({
458
464
  slack: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Custom: header'] },
459
465
  }));
460
- const deps = createMockDependencies({
461
- config: createMockConfig({ credentialStorePath: storePath }),
462
- });
466
+ const deps = createMockDependencies();
463
467
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
464
468
  expect(capturedArgs).toEqual(['-H', 'X-Custom: header', 'https://slack.com/api/test']);
465
469
  expect(exitCode).toBe(0);
@@ -469,9 +473,7 @@ describe('CLI commands with dependency injection', () => {
469
473
  writeSecureFile(storePath, JSON.stringify({
470
474
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
471
475
  }));
472
- const deps = createMockDependencies({
473
- config: createMockConfig({ credentialStorePath: storePath }),
474
- });
476
+ const deps = createMockDependencies();
475
477
  await runCommand([
476
478
  'curl',
477
479
  '--',
@@ -494,7 +496,6 @@ describe('CLI commands with dependency injection', () => {
494
496
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
495
497
  }));
496
498
  const deps = createMockDependencies({
497
- config: createMockConfig({ credentialStorePath: storePath }),
498
499
  runCurl: () => ({ returncode: 42, stdout: '', stderr: '' }),
499
500
  });
500
501
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
@@ -504,26 +505,11 @@ describe('CLI commands with dependency injection', () => {
504
505
  const deps = createMockDependencies();
505
506
  await runCommand(['curl', '--', '-X', 'POST'], deps);
506
507
  expect(exitCode).toBe(1);
507
- expect(errorLogs.some((log) => log.includes('Could not extract URL'))).toBe(true);
508
508
  });
509
509
  it('should return error for unknown service', async () => {
510
510
  const deps = createMockDependencies();
511
511
  await runCommand(['curl', 'https://unknown-api.example.com'], deps);
512
512
  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
513
  });
528
514
  it('should read credentials from store and not call login', async () => {
529
515
  const storePath = join(tempDir, 'credentials.json');
@@ -539,11 +525,16 @@ describe('CLI commands with dependency injection', () => {
539
525
  info: 'Test info for Slack service.',
540
526
  credentialCheckCurlArguments: [],
541
527
  checkApiCredentials: vi.fn(),
528
+ setCredentialsExample(serviceName) {
529
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer xoxb-your-token"`;
530
+ },
531
+ getCredentialsNoCurl() {
532
+ throw new NoCurlCredentialsNotSupportedError('slack');
533
+ },
542
534
  getSession: vi.fn().mockReturnValue({ login: mockLogin }),
543
535
  };
544
536
  const deps = createMockDependencies({
545
537
  registry: new Registry([mockSlackService]),
546
- config: createMockConfig({ credentialStorePath: storePath }),
547
538
  });
548
539
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
549
540
  expect(mockLogin).not.toHaveBeenCalled();
@@ -552,14 +543,21 @@ describe('CLI commands with dependency injection', () => {
552
543
  it('should return error when no credentials in store', async () => {
553
544
  const storePath = join(tempDir, 'credentials.json');
554
545
  writeSecureFile(storePath, '{}');
555
- const deps = createMockDependencies({
556
- config: createMockConfig({ credentialStorePath: storePath }),
557
- });
546
+ const deps = createMockDependencies();
558
547
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
559
548
  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);
549
+ });
550
+ it('should inject telegram bot token into URL path', async () => {
551
+ const storePath = join(tempDir, 'credentials.json');
552
+ writeSecureFile(storePath, JSON.stringify({
553
+ telegram: { objectType: 'telegramBot', token: '123456:ABC-DEF' },
554
+ }));
555
+ const deps = createMockDependencies({
556
+ registry: new Registry([TELEGRAM]),
557
+ });
558
+ await runCommand(['curl', 'https://api.telegram.org/getMe'], deps);
559
+ expect(capturedArgs).toEqual(['https://api.telegram.org/bot123456:ABC-DEF/getMe']);
560
+ expect(exitCode).toBe(0);
563
561
  });
564
562
  it('should work when service does not have getSession but credentials exist', async () => {
565
563
  const storePath = join(tempDir, 'credentials.json');
@@ -574,11 +572,16 @@ describe('CLI commands with dependency injection', () => {
574
572
  info: 'A service without browser login support.',
575
573
  credentialCheckCurlArguments: [],
576
574
  checkApiCredentials: vi.fn().mockReturnValue(ApiCredentialStatus.Valid),
575
+ setCredentialsExample(serviceName) {
576
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
577
+ },
578
+ getCredentialsNoCurl() {
579
+ throw new NoCurlCredentialsNotSupportedError('nologin');
580
+ },
577
581
  // No getSession - service doesn't support browser login
578
582
  };
579
583
  const deps = createMockDependencies({
580
584
  registry: new Registry([noLoginService]),
581
- config: createMockConfig({ credentialStorePath: storePath }),
582
585
  });
583
586
  await runCommand(['curl', 'https://nologin.example.com/api/test'], deps);
584
587
  expect(exitCode).toBe(0);
@@ -596,6 +599,11 @@ describe('CLI commands with dependency injection', () => {
596
599
  info: 'A service without browser login support.',
597
600
  credentialCheckCurlArguments: [],
598
601
  checkApiCredentials: vi.fn(),
602
+ setCredentialsExample(serviceName) {
603
+ return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
604
+ },
605
+ // eslint-disable-next-line @typescript-eslint/unbound-method
606
+ getCredentialsNoCurl: Service.prototype.getCredentialsNoCurl,
599
607
  // No getSession - service doesn't support browser login
600
608
  };
601
609
  const deps = createMockDependencies({
@@ -603,174 +611,66 @@ describe('CLI commands with dependency injection', () => {
603
611
  });
604
612
  await runCommand(['auth', 'browser', 'nologin'], deps);
605
613
  expect(exitCode).toBe(1);
606
- const expectedMessage = new BrowserFlowsNotSupportedError('nologin').message;
607
- expect(errorLogs.some((log) => log.includes(expectedMessage))).toBe(true);
614
+ });
615
+ it('should suggest set-nocurl when service supports nocurl credentials', async () => {
616
+ const nocurlService = {
617
+ name: 'nocurl-only',
618
+ displayName: 'NoCurl Only Service',
619
+ baseApiUrls: ['https://nocurl.example.com/api/'],
620
+ loginUrl: 'https://nocurl.example.com',
621
+ info: 'A service with nocurl credentials but no browser login.',
622
+ credentialCheckCurlArguments: [],
623
+ checkApiCredentials: vi.fn(),
624
+ setCredentialsExample(serviceName) {
625
+ return `latchkey auth set-nocurl ${serviceName} <some-arg>`;
626
+ },
627
+ getCredentialsNoCurl(arguments_) {
628
+ if (arguments_.length !== 1) {
629
+ throw new Error('Expected exactly one argument');
630
+ }
631
+ return { objectType: 'test', injectIntoCurlCall: vi.fn(), isExpired: () => false };
632
+ },
633
+ // No getSession - service doesn't support browser login
634
+ };
635
+ const deps = createMockDependencies({
636
+ registry: new Registry([nocurlService]),
637
+ });
638
+ await runCommand(['auth', 'browser', 'nocurl-only'], deps);
639
+ expect(exitCode).toBe(1);
608
640
  });
609
641
  });
610
642
  });
611
- // Integration tests that run the actual CLI binary
643
+ // Integration tests that run the actual CLI binary.
644
+ // Only tests that exercise behavior not covered by the DI unit tests above.
612
645
  describe.skipIf(!cliPath)('CLI integration tests (subprocess)', () => {
613
646
  let tempDir;
614
647
  let testEnv;
615
648
  beforeEach(() => {
616
649
  tempDir = mkdtempSync(join(tmpdir(), 'latchkey-cli-test-'));
617
650
  testEnv = {
618
- LATCHKEY_STORE: join(tempDir, 'credentials.json'),
619
- LATCHKEY_BROWSER_STATE: join(tempDir, 'browser_state.json'),
651
+ LATCHKEY_DIRECTORY: tempDir,
620
652
  };
621
653
  });
622
654
  afterEach(() => {
623
655
  rmSync(tempDir, { recursive: true, force: true });
624
656
  });
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');
649
- });
657
+ it('should return non-zero exit code for unknown service URL', () => {
658
+ const result = runCli(['curl', 'https://unknown-api.example.com'], testEnv);
659
+ expect(result.exitCode).toBe(1);
650
660
  });
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');
661
+ it('should return error when browser is disabled via LATCHKEY_DISABLE_BROWSER', () => {
662
+ const result = runCli(['auth', 'browser', 'slack'], {
663
+ ...testEnv,
664
+ LATCHKEY_DISABLE_BROWSER: '1',
659
665
  });
666
+ expect(result.exitCode).toBe(1);
660
667
  });
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');
682
- });
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');
694
- });
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');
718
- });
719
- });
720
- describe('auth list command', () => {
721
- it('should list stored credentials as beautified JSON', () => {
722
- writeSecureFile(testEnv.LATCHKEY_STORE, JSON.stringify({
723
- slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
724
- }));
725
- const result = runCli(['auth', 'list'], testEnv);
726
- expect(result.exitCode).toBe(0);
727
- const entries = JSON.parse(result.stdout);
728
- expect(entries.slack).toBeDefined();
729
- expect(entries.slack?.credentialType).toBe('slack');
730
- expect(entries.slack?.credentialStatus).toEqual(expect.any(String));
731
- });
732
- it('should output empty object when no credentials are stored', () => {
733
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
734
- const result = runCli(['auth', 'list'], testEnv);
735
- expect(result.exitCode).toBe(0);
736
- const entries = JSON.parse(result.stdout);
737
- expect(Object.keys(entries)).toHaveLength(0);
738
- });
739
- });
740
- describe('services list command', () => {
741
- it('should list all services as JSON', () => {
742
- const result = runCli(['services', 'list'], testEnv);
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');
750
- });
751
- });
752
- describe('services info command', () => {
753
- it('should show login options, credentials status, and developer notes as JSON', () => {
754
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
755
- const result = runCli(['services', 'info', 'slack'], testEnv);
756
- expect(result.exitCode).toBe(0);
757
- const info = JSON.parse(result.stdout);
758
- expect(info.authOptions).toEqual(['browser', 'set']);
759
- expect(info.credentialStatus).toBe('missing');
760
- expect(info.developerNotes).toEqual(expect.any(String));
761
- });
762
- it('should show auth set only for services without browser login', () => {
763
- writeSecureFile(testEnv.LATCHKEY_STORE, '{}');
764
- const result = runCli(['services', 'info', 'mailchimp'], testEnv);
765
- expect(result.exitCode).toBe(0);
766
- const info = JSON.parse(result.stdout);
767
- expect(info.authOptions).toEqual(['set']);
768
- });
769
- it('should return error for unknown service', () => {
770
- const result = runCli(['services', 'info', 'unknown-service'], testEnv);
771
- expect(result.exitCode).toBe(1);
772
- expect(result.stderr).toContain('Unknown service');
773
- });
668
+ it('should list services as JSON', () => {
669
+ const result = runCli(['services', 'list'], testEnv);
670
+ expect(result.exitCode).toBe(0);
671
+ const services = JSON.parse(result.stdout.trim());
672
+ expect(services).toContain('slack');
673
+ expect(services).toContain('github');
774
674
  });
775
675
  });
776
676
  //# sourceMappingURL=cli.test.js.map