latchkey 2.21.0 → 3.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 (230) hide show
  1. package/README.md +36 -4
  2. package/dist/skills/generic/latchkey/SKILL.md +21 -0
  3. package/dist/skills/openclaw/latchkey/SKILL.md +21 -0
  4. package/dist/src/apiCredentials/account.d.ts +28 -0
  5. package/dist/src/apiCredentials/account.d.ts.map +1 -0
  6. package/dist/src/apiCredentials/account.js +49 -0
  7. package/dist/src/apiCredentials/account.js.map +1 -0
  8. package/dist/src/apiCredentials/store.d.ts +70 -4
  9. package/dist/src/apiCredentials/store.d.ts.map +1 -1
  10. package/dist/src/apiCredentials/store.js +161 -19
  11. package/dist/src/apiCredentials/store.js.map +1 -1
  12. package/dist/src/apiCredentials/utils.d.ts +2 -2
  13. package/dist/src/apiCredentials/utils.d.ts.map +1 -1
  14. package/dist/src/apiCredentials/utils.js +5 -5
  15. package/dist/src/apiCredentials/utils.js.map +1 -1
  16. package/dist/src/cli.js +1 -1
  17. package/dist/src/cli.js.map +1 -1
  18. package/dist/src/cliCommands.d.ts.map +1 -1
  19. package/dist/src/cliCommands.js +118 -22
  20. package/dist/src/cliCommands.js.map +1 -1
  21. package/dist/src/config.d.ts +6 -0
  22. package/dist/src/config.d.ts.map +1 -1
  23. package/dist/src/config.js +8 -0
  24. package/dist/src/config.js.map +1 -1
  25. package/dist/src/configDataStore.d.ts +3 -0
  26. package/dist/src/configDataStore.d.ts.map +1 -1
  27. package/dist/src/configDataStore.js +1 -0
  28. package/dist/src/configDataStore.js.map +1 -1
  29. package/dist/src/curl.d.ts +21 -16
  30. package/dist/src/curl.d.ts.map +1 -1
  31. package/dist/src/curl.js +17 -24
  32. package/dist/src/curl.js.map +1 -1
  33. package/dist/src/curlInjection.d.ts +8 -2
  34. package/dist/src/curlInjection.d.ts.map +1 -1
  35. package/dist/src/curlInjection.js +13 -8
  36. package/dist/src/curlInjection.js.map +1 -1
  37. package/dist/src/errorMessages.d.ts +2 -2
  38. package/dist/src/errorMessages.d.ts.map +1 -1
  39. package/dist/src/errorMessages.js +22 -6
  40. package/dist/src/errorMessages.js.map +1 -1
  41. package/dist/src/gateway/gatewayEndpoint.d.ts +1 -1
  42. package/dist/src/gateway/gatewayEndpoint.d.ts.map +1 -1
  43. package/dist/src/gateway/gatewayEndpoint.js +3 -1
  44. package/dist/src/gateway/gatewayEndpoint.js.map +1 -1
  45. package/dist/src/gateway/latchkeyEndpoint.d.ts +6 -0
  46. package/dist/src/gateway/latchkeyEndpoint.d.ts.map +1 -1
  47. package/dist/src/gateway/latchkeyEndpoint.js +6 -4
  48. package/dist/src/gateway/latchkeyEndpoint.js.map +1 -1
  49. package/dist/src/index.d.ts +2 -2
  50. package/dist/src/index.d.ts.map +1 -1
  51. package/dist/src/index.js +1 -1
  52. package/dist/src/index.js.map +1 -1
  53. package/dist/src/migrations.d.ts +21 -1
  54. package/dist/src/migrations.d.ts.map +1 -1
  55. package/dist/src/migrations.js +112 -4
  56. package/dist/src/migrations.js.map +1 -1
  57. package/dist/src/oauthUtils.d.ts +2 -2
  58. package/dist/src/oauthUtils.d.ts.map +1 -1
  59. package/dist/src/oauthUtils.js +5 -5
  60. package/dist/src/oauthUtils.js.map +1 -1
  61. package/dist/src/services/aws.d.ts +1 -0
  62. package/dist/src/services/aws.d.ts.map +1 -1
  63. package/dist/src/services/aws.js +13 -0
  64. package/dist/src/services/aws.js.map +1 -1
  65. package/dist/src/services/calendly.d.ts +2 -0
  66. package/dist/src/services/calendly.d.ts.map +1 -1
  67. package/dist/src/services/calendly.js +7 -0
  68. package/dist/src/services/calendly.js.map +1 -1
  69. package/dist/src/services/coolify.d.ts +3 -1
  70. package/dist/src/services/coolify.d.ts.map +1 -1
  71. package/dist/src/services/coolify.js +10 -1
  72. package/dist/src/services/coolify.js.map +1 -1
  73. package/dist/src/services/core/base.d.ts +43 -1
  74. package/dist/src/services/core/base.d.ts.map +1 -1
  75. package/dist/src/services/core/base.js +29 -9
  76. package/dist/src/services/core/base.js.map +1 -1
  77. package/dist/src/services/core/registered.d.ts +1 -0
  78. package/dist/src/services/core/registered.d.ts.map +1 -1
  79. package/dist/src/services/core/registered.js +5 -0
  80. package/dist/src/services/core/registered.js.map +1 -1
  81. package/dist/src/services/discord.d.ts +1 -0
  82. package/dist/src/services/discord.d.ts.map +1 -1
  83. package/dist/src/services/discord.js +9 -0
  84. package/dist/src/services/discord.js.map +1 -1
  85. package/dist/src/services/dropbox.d.ts +2 -1
  86. package/dist/src/services/dropbox.d.ts.map +1 -1
  87. package/dist/src/services/dropbox.js +17 -11
  88. package/dist/src/services/dropbox.js.map +1 -1
  89. package/dist/src/services/figma.d.ts +2 -0
  90. package/dist/src/services/figma.d.ts.map +1 -1
  91. package/dist/src/services/figma.js +7 -0
  92. package/dist/src/services/figma.js.map +1 -1
  93. package/dist/src/services/github.d.ts +1 -0
  94. package/dist/src/services/github.d.ts.map +1 -1
  95. package/dist/src/services/github.js +9 -0
  96. package/dist/src/services/github.js.map +1 -1
  97. package/dist/src/services/gitlab.d.ts +2 -0
  98. package/dist/src/services/gitlab.d.ts.map +1 -1
  99. package/dist/src/services/gitlab.js +9 -0
  100. package/dist/src/services/gitlab.js.map +1 -1
  101. package/dist/src/services/google/analytics.d.ts +0 -1
  102. package/dist/src/services/google/analytics.d.ts.map +1 -1
  103. package/dist/src/services/google/analytics.js +0 -3
  104. package/dist/src/services/google/analytics.js.map +1 -1
  105. package/dist/src/services/google/base.d.ts +11 -0
  106. package/dist/src/services/google/base.d.ts.map +1 -1
  107. package/dist/src/services/google/base.js +26 -7
  108. package/dist/src/services/google/base.js.map +1 -1
  109. package/dist/src/services/google/calendar.d.ts +0 -1
  110. package/dist/src/services/google/calendar.d.ts.map +1 -1
  111. package/dist/src/services/google/calendar.js +0 -3
  112. package/dist/src/services/google/calendar.js.map +1 -1
  113. package/dist/src/services/google/directions.d.ts +1 -0
  114. package/dist/src/services/google/directions.d.ts.map +1 -1
  115. package/dist/src/services/google/directions.js +5 -0
  116. package/dist/src/services/google/directions.js.map +1 -1
  117. package/dist/src/services/google/docs.d.ts +0 -1
  118. package/dist/src/services/google/docs.d.ts.map +1 -1
  119. package/dist/src/services/google/docs.js +0 -3
  120. package/dist/src/services/google/docs.js.map +1 -1
  121. package/dist/src/services/google/drive.d.ts +0 -1
  122. package/dist/src/services/google/drive.d.ts.map +1 -1
  123. package/dist/src/services/google/drive.js +0 -3
  124. package/dist/src/services/google/drive.js.map +1 -1
  125. package/dist/src/services/google/gmail.d.ts +0 -1
  126. package/dist/src/services/google/gmail.d.ts.map +1 -1
  127. package/dist/src/services/google/gmail.js +0 -3
  128. package/dist/src/services/google/gmail.js.map +1 -1
  129. package/dist/src/services/google/people.d.ts +0 -1
  130. package/dist/src/services/google/people.d.ts.map +1 -1
  131. package/dist/src/services/google/people.js +0 -3
  132. package/dist/src/services/google/people.js.map +1 -1
  133. package/dist/src/services/google/sheets.d.ts +0 -1
  134. package/dist/src/services/google/sheets.d.ts.map +1 -1
  135. package/dist/src/services/google/sheets.js +0 -3
  136. package/dist/src/services/google/sheets.js.map +1 -1
  137. package/dist/src/services/google/slides.d.ts +0 -1
  138. package/dist/src/services/google/slides.d.ts.map +1 -1
  139. package/dist/src/services/google/slides.js +0 -3
  140. package/dist/src/services/google/slides.js.map +1 -1
  141. package/dist/src/services/index.d.ts +1 -1
  142. package/dist/src/services/index.d.ts.map +1 -1
  143. package/dist/src/services/index.js.map +1 -1
  144. package/dist/src/services/linear.d.ts +2 -1
  145. package/dist/src/services/linear.d.ts.map +1 -1
  146. package/dist/src/services/linear.js +8 -1
  147. package/dist/src/services/linear.js.map +1 -1
  148. package/dist/src/services/mailchimp.d.ts +2 -0
  149. package/dist/src/services/mailchimp.d.ts.map +1 -1
  150. package/dist/src/services/mailchimp.js +7 -0
  151. package/dist/src/services/mailchimp.js.map +1 -1
  152. package/dist/src/services/notion-mcp.d.ts +3 -2
  153. package/dist/src/services/notion-mcp.d.ts.map +1 -1
  154. package/dist/src/services/notion-mcp.js +111 -14
  155. package/dist/src/services/notion-mcp.js.map +1 -1
  156. package/dist/src/services/notion.d.ts +2 -0
  157. package/dist/src/services/notion.d.ts.map +1 -1
  158. package/dist/src/services/notion.js +17 -0
  159. package/dist/src/services/notion.js.map +1 -1
  160. package/dist/src/services/ramp.d.ts +11 -2
  161. package/dist/src/services/ramp.d.ts.map +1 -1
  162. package/dist/src/services/ramp.js +35 -9
  163. package/dist/src/services/ramp.js.map +1 -1
  164. package/dist/src/services/sentry.d.ts +3 -2
  165. package/dist/src/services/sentry.d.ts.map +1 -1
  166. package/dist/src/services/sentry.js +12 -27
  167. package/dist/src/services/sentry.js.map +1 -1
  168. package/dist/src/services/slack.d.ts +3 -2
  169. package/dist/src/services/slack.d.ts.map +1 -1
  170. package/dist/src/services/slack.js +19 -13
  171. package/dist/src/services/slack.js.map +1 -1
  172. package/dist/src/services/stripe.d.ts +7 -0
  173. package/dist/src/services/stripe.d.ts.map +1 -1
  174. package/dist/src/services/stripe.js +15 -0
  175. package/dist/src/services/stripe.js.map +1 -1
  176. package/dist/src/services/telegram.d.ts +1 -0
  177. package/dist/src/services/telegram.d.ts.map +1 -1
  178. package/dist/src/services/telegram.js +7 -0
  179. package/dist/src/services/telegram.js.map +1 -1
  180. package/dist/src/services/todoist.d.ts +2 -1
  181. package/dist/src/services/todoist.d.ts.map +1 -1
  182. package/dist/src/services/todoist.js +9 -1
  183. package/dist/src/services/todoist.js.map +1 -1
  184. package/dist/src/services/umami.d.ts +3 -1
  185. package/dist/src/services/umami.d.ts.map +1 -1
  186. package/dist/src/services/umami.js +11 -1
  187. package/dist/src/services/umami.js.map +1 -1
  188. package/dist/src/services/yelp.d.ts +1 -0
  189. package/dist/src/services/yelp.d.ts.map +1 -1
  190. package/dist/src/services/yelp.js +5 -0
  191. package/dist/src/services/yelp.js.map +1 -1
  192. package/dist/src/services/zoom.d.ts +8 -0
  193. package/dist/src/services/zoom.d.ts.map +1 -1
  194. package/dist/src/services/zoom.js +16 -0
  195. package/dist/src/services/zoom.js.map +1 -1
  196. package/dist/src/sharedOperations.d.ts +30 -10
  197. package/dist/src/sharedOperations.d.ts.map +1 -1
  198. package/dist/src/sharedOperations.js +67 -29
  199. package/dist/src/sharedOperations.js.map +1 -1
  200. package/dist/src/version.d.ts +1 -1
  201. package/dist/src/version.d.ts.map +1 -1
  202. package/dist/src/version.js +1 -1
  203. package/dist/src/version.js.map +1 -1
  204. package/dist/tests/apiCredentialStore.test.js +155 -2
  205. package/dist/tests/apiCredentialStore.test.js.map +1 -1
  206. package/dist/tests/apiCredentialsUtils.test.js +1 -1
  207. package/dist/tests/apiCredentialsUtils.test.js.map +1 -1
  208. package/dist/tests/cli.test.js +334 -147
  209. package/dist/tests/cli.test.js.map +1 -1
  210. package/dist/tests/config.test.js +5 -0
  211. package/dist/tests/config.test.js.map +1 -1
  212. package/dist/tests/gateway.test.js +8 -18
  213. package/dist/tests/gateway.test.js.map +1 -1
  214. package/dist/tests/latchkeyEndpoint.test.js +18 -28
  215. package/dist/tests/latchkeyEndpoint.test.js.map +1 -1
  216. package/dist/tests/migrations.test.js +220 -39
  217. package/dist/tests/migrations.test.js.map +1 -1
  218. package/dist/tests/mockService.d.ts +25 -0
  219. package/dist/tests/mockService.d.ts.map +1 -0
  220. package/dist/tests/mockService.js +40 -0
  221. package/dist/tests/mockService.js.map +1 -0
  222. package/dist/tests/oauthUtils.test.js +12 -12
  223. package/dist/tests/oauthUtils.test.js.map +1 -1
  224. package/dist/tests/serviceAccounts.test.d.ts +12 -0
  225. package/dist/tests/serviceAccounts.test.d.ts.map +1 -0
  226. package/dist/tests/serviceAccounts.test.js +278 -0
  227. package/dist/tests/serviceAccounts.test.js.map +1 -0
  228. package/dist/tests/sharedOperations.test.js +327 -58
  229. package/dist/tests/sharedOperations.test.js.map +1 -1
  230. package/package.json +1 -1
@@ -11,8 +11,8 @@ import { EncryptedStorage } from '../src/encryptedStorage.js';
11
11
  import { Config } from '../src/config.js';
12
12
  import { ServiceRegistry } from '../src/serviceRegistry.js';
13
13
  import { ApiCredentialStatus } from '../src/apiCredentials/base.js';
14
- import { SlackApiCredentials } from '../src/services/slack.js';
15
- import { NoCurlCredentialsNotSupportedError, Service } from '../src/services/core/base.js';
14
+ import { Service } from '../src/services/core/base.js';
15
+ import { createMockService, MockService } from './mockService.js';
16
16
  import { RegisteredService } from '../src/services/core/registered.js';
17
17
  import { GITLAB } from '../src/services/gitlab.js';
18
18
  import { GITHUB } from '../src/services/github.js';
@@ -33,6 +33,27 @@ function readSecureFile(path) {
33
33
  const storage = new EncryptedStorage(TEST_ENCRYPTION_KEY);
34
34
  return storage.readFile(path);
35
35
  }
36
+ // Wrap a flat { service: credentials } map into the on-disk layout, storing
37
+ // everything as account-keyed credentials under the default account.
38
+ function nestAccounts(flat) {
39
+ return {
40
+ credentials: Object.fromEntries(Object.entries(flat).map(([service, creds]) => [service, { '': creds }])),
41
+ };
42
+ }
43
+ // Read the raw account-keyed credentials section of the store.
44
+ function readStore(path) {
45
+ const parsed = JSON.parse(readSecureFile(path) ?? '{}');
46
+ return parsed.credentials ?? {};
47
+ }
48
+ // Read the raw preparations section of the store.
49
+ function readPreparations(path) {
50
+ const parsed = JSON.parse(readSecureFile(path) ?? '{}');
51
+ return parsed.preparations ?? {};
52
+ }
53
+ // Read a single service's default-account credentials from the store.
54
+ function readDefaultCredentials(path, service) {
55
+ return readStore(path)[service]?.[''];
56
+ }
36
57
  function getCliPath() {
37
58
  const projectRoot = join(__dirname, '..');
38
59
  // Check both possible paths based on tsconfig setup
@@ -302,6 +323,7 @@ describe('CLI commands with dependency injection', () => {
302
323
  serviceName: overrides.serviceName ?? defaultConfig.serviceName,
303
324
  accountName: overrides.accountName ?? defaultConfig.accountName,
304
325
  browserDisabled: overrides.browserDisabled ?? false,
326
+ browserEphemeral: overrides.browserEphemeral ?? false,
305
327
  countingDisabled: overrides.countingDisabled ?? false,
306
328
  credentialsRefreshDisabled: overrides.credentialsRefreshDisabled ?? false,
307
329
  permissionsDoNotUseBuiltinSchemas: overrides.permissionsDoNotUseBuiltinSchemas ?? false,
@@ -318,24 +340,7 @@ describe('CLI commands with dependency injection', () => {
318
340
  };
319
341
  }
320
342
  function createMockDependencies(overrides = {}) {
321
- const mockSlackService = {
322
- name: 'slack',
323
- displayName: 'Slack',
324
- baseApiUrls: ['https://slack.com/api/'],
325
- loginUrl: 'https://slack.com/signin',
326
- info: 'Test info for Slack service.',
327
- credentialCheckCurlArguments: ['https://slack.com/api/auth.test'],
328
- checkApiCredentials: vi.fn().mockResolvedValue(ApiCredentialStatus.Valid),
329
- setCredentialsExample(serviceName) {
330
- return `latchkey auth set ${serviceName} -H "Authorization: Bearer xoxb-your-token"`;
331
- },
332
- getCredentialsNoCurl() {
333
- throw new NoCurlCredentialsNotSupportedError('slack');
334
- },
335
- getSession: vi.fn().mockReturnValue({
336
- login: vi.fn().mockResolvedValue(new SlackApiCredentials('xoxc-test-token', 'test-cookie')),
337
- }),
338
- };
343
+ const mockSlackService = new MockService();
339
344
  const mockRegistry = new ServiceRegistry([mockSlackService]);
340
345
  return {
341
346
  registry: mockRegistry,
@@ -416,9 +421,9 @@ describe('CLI commands with dependency injection', () => {
416
421
  });
417
422
  it('should include services with stored credentials when using --viable', async () => {
418
423
  const storePath = join(tempDir, 'credentials.json');
419
- writeSecureFile(storePath, JSON.stringify({
424
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
420
425
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
421
- }));
426
+ })));
422
427
  const deps = createMockDependencies();
423
428
  await runCommand(['services', 'list', '--viable'], deps);
424
429
  expect(logs).toHaveLength(1);
@@ -451,7 +456,7 @@ describe('CLI commands with dependency injection', () => {
451
456
  it('should exclude services without credentials or browser auth when using --viable', async () => {
452
457
  const storePath = join(tempDir, 'credentials.json');
453
458
  writeSecureFile(storePath, '{}');
454
- const noLoginService = {
459
+ const noLoginService = createMockService({
455
460
  name: 'nologin',
456
461
  displayName: 'No Login Service',
457
462
  baseApiUrls: ['https://nologin.example.com/api/'],
@@ -459,13 +464,9 @@ describe('CLI commands with dependency injection', () => {
459
464
  info: 'A service without browser login support.',
460
465
  credentialCheckCurlArguments: [],
461
466
  checkApiCredentials: vi.fn().mockResolvedValue(ApiCredentialStatus.Missing),
462
- setCredentialsExample(serviceName) {
463
- return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
464
- },
465
- getCredentialsNoCurl() {
466
- throw new NoCurlCredentialsNotSupportedError('nologin');
467
- },
468
- };
467
+ setCredentialsExample: (serviceName) => `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`,
468
+ getSession: undefined,
469
+ });
469
470
  const deps = createMockDependencies({
470
471
  registry: new ServiceRegistry([noLoginService]),
471
472
  });
@@ -519,9 +520,9 @@ describe('CLI commands with dependency injection', () => {
519
520
  });
520
521
  it('should include services with credentials even when no graphical environment with --viable', async () => {
521
522
  const storePath = join(tempDir, 'credentials.json');
522
- writeSecureFile(storePath, JSON.stringify({
523
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
523
524
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
524
- }));
525
+ })));
525
526
  const originalPlatform = process.platform;
526
527
  const originalDisplay = process.env.DISPLAY;
527
528
  const originalWayland = process.env.WAYLAND_DISPLAY;
@@ -553,9 +554,9 @@ describe('CLI commands with dependency injection', () => {
553
554
  });
554
555
  it('should include services with credentials even when browser is disabled with --viable', async () => {
555
556
  const storePath = join(tempDir, 'credentials.json');
556
- writeSecureFile(storePath, JSON.stringify({
557
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
557
558
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
558
- }));
559
+ })));
559
560
  const deps = createMockDependencies({
560
561
  config: createMockConfig({ browserDisabled: true }),
561
562
  });
@@ -566,12 +567,12 @@ describe('CLI commands with dependency injection', () => {
566
567
  });
567
568
  it('should combine --builtin and --viable filters', async () => {
568
569
  const storePath = join(tempDir, 'credentials.json');
569
- writeSecureFile(storePath, JSON.stringify({
570
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
570
571
  'my-gitlab': {
571
572
  objectType: 'rawCurl',
572
573
  curlArguments: ['-H', 'PRIVATE-TOKEN: token'],
573
574
  },
574
- }));
575
+ })));
575
576
  // Ensure a graphical environment is available so browser auth is considered viable
576
577
  const originalDisplay = process.env.DISPLAY;
577
578
  try {
@@ -608,14 +609,14 @@ describe('CLI commands with dependency injection', () => {
608
609
  expect(info.type).toBe('built-in');
609
610
  expect(info.baseApiUrls).toEqual(['https://slack.com/api/']);
610
611
  expect(info.authOptions).toEqual(['browser', 'set']);
611
- expect(info.credentialStatus).toBe('missing');
612
+ expect(info.credentials).toEqual({});
612
613
  expect(info.setCredentialsExample).toBe('latchkey auth set slack -H "Authorization: Bearer xoxb-your-token"');
613
614
  expect(info.developerNotes).toBe('Test info for Slack service.');
614
615
  });
615
616
  it('should show auth set only for services without browser login', async () => {
616
617
  const storePath = join(tempDir, 'credentials.json');
617
618
  writeSecureFile(storePath, '{}');
618
- const noLoginService = {
619
+ const noLoginService = createMockService({
619
620
  name: 'nologin',
620
621
  displayName: 'No Login Service',
621
622
  baseApiUrls: ['https://nologin.example.com/api/'],
@@ -623,13 +624,9 @@ describe('CLI commands with dependency injection', () => {
623
624
  info: 'A service without browser login support.',
624
625
  credentialCheckCurlArguments: [],
625
626
  checkApiCredentials: vi.fn().mockResolvedValue(ApiCredentialStatus.Missing),
626
- setCredentialsExample(serviceName) {
627
- return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
628
- },
629
- getCredentialsNoCurl() {
630
- throw new NoCurlCredentialsNotSupportedError('nologin');
631
- },
632
- };
627
+ setCredentialsExample: (serviceName) => `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`,
628
+ getSession: undefined,
629
+ });
633
630
  const deps = createMockDependencies({
634
631
  registry: new ServiceRegistry([noLoginService]),
635
632
  });
@@ -649,13 +646,33 @@ describe('CLI commands with dependency injection', () => {
649
646
  });
650
647
  it('should show valid credentials status when credentials are valid', async () => {
651
648
  const storePath = join(tempDir, 'credentials.json');
652
- writeSecureFile(storePath, JSON.stringify({
649
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
653
650
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
651
+ })));
652
+ const deps = createMockDependencies();
653
+ await runCommand(['services', 'info', 'slack'], deps);
654
+ const info = JSON.parse(logs[0] ?? '');
655
+ expect(info.credentials).toEqual({
656
+ '': { credentialType: 'slack', credentialStatus: 'valid' },
657
+ });
658
+ });
659
+ it('should list credentials for each account', async () => {
660
+ const storePath = join(tempDir, 'credentials.json');
661
+ writeSecureFile(storePath, JSON.stringify({
662
+ credentials: {
663
+ slack: {
664
+ '': { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: default'] },
665
+ 'work@example.com': { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: work'] },
666
+ },
667
+ },
654
668
  }));
655
669
  const deps = createMockDependencies();
656
670
  await runCommand(['services', 'info', 'slack'], deps);
657
671
  const info = JSON.parse(logs[0] ?? '');
658
- expect(info.credentialStatus).toBe('valid');
672
+ expect(info.credentials).toEqual({
673
+ '': { credentialType: 'rawCurl', credentialStatus: 'valid' },
674
+ 'work@example.com': { credentialType: 'rawCurl', credentialStatus: 'valid' },
675
+ });
659
676
  });
660
677
  it('should return error for unknown service', async () => {
661
678
  const deps = createMockDependencies();
@@ -677,9 +694,9 @@ describe('CLI commands with dependency injection', () => {
677
694
  describe('clear command', () => {
678
695
  it('should delete credentials for a service', async () => {
679
696
  const storePath = join(tempDir, 'credentials.json');
680
- writeSecureFile(storePath, JSON.stringify({
697
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
681
698
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
682
- }));
699
+ })));
683
700
  const deps = createMockDependencies();
684
701
  await runCommand(['auth', 'clear', 'slack'], deps);
685
702
  const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
@@ -693,21 +710,21 @@ describe('CLI commands with dependency injection', () => {
693
710
  });
694
711
  it('should preserve other services when clearing one', async () => {
695
712
  const storePath = join(tempDir, 'credentials.json');
696
- writeSecureFile(storePath, JSON.stringify({
713
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
697
714
  slack: { objectType: 'slack', token: 'slack-token', dCookie: 'slack-cookie' },
698
715
  discord: { objectType: 'authorizationBare', token: 'discord-token' },
699
- }));
716
+ })));
700
717
  const deps = createMockDependencies();
701
718
  await runCommand(['auth', 'clear', 'slack'], deps);
702
- const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
719
+ const storedData = readStore(storePath);
703
720
  expect(storedData.slack).toBeUndefined();
704
721
  expect(storedData.discord).toBeDefined();
705
- expect(storedData.discord?.token).toBe('discord-token');
722
+ expect((storedData.discord?.['']).token).toBe('discord-token');
706
723
  });
707
724
  it('should delete both store and browser state with -y flag', async () => {
708
725
  const storePath = join(tempDir, 'credentials.json');
709
726
  const browserStatePath = join(tempDir, 'browser_state.json');
710
- writeSecureFile(storePath, JSON.stringify({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } }));
727
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({ slack: { objectType: 'slack', token: 'test', dCookie: 'test' } })));
711
728
  writeSecureFile(browserStatePath, '{}');
712
729
  const deps = createMockDependencies();
713
730
  await runCommand(['auth', 'clear', '-y'], deps);
@@ -715,19 +732,168 @@ describe('CLI commands with dependency injection', () => {
715
732
  expect(existsSync(browserStatePath)).toBe(false);
716
733
  });
717
734
  });
735
+ describe('--account option', () => {
736
+ const rawCurl = (token) => ({
737
+ objectType: 'rawCurl',
738
+ curlArguments: ['-H', `X-Token: ${token}`],
739
+ });
740
+ function writeMultiAccountStore(accounts) {
741
+ const storePath = join(tempDir, 'credentials.json');
742
+ const slackAccounts = Object.fromEntries(Object.entries(accounts).map(([account, token]) => [account, rawCurl(token)]));
743
+ writeSecureFile(storePath, JSON.stringify({ credentials: { slack: slackAccounts } }));
744
+ return storePath;
745
+ }
746
+ it('auth set stores credentials under the given account', async () => {
747
+ const storePath = join(tempDir, 'credentials.json');
748
+ writeSecureFile(storePath, '{}');
749
+ const deps = createMockDependencies();
750
+ await runCommand(['--account', 'work@example.com', 'auth', 'set', 'slack', '-H', 'X-Token: secret'], deps);
751
+ expect(logs).toContain('Credentials stored.');
752
+ const store = readStore(storePath);
753
+ expect(store.slack?.['work@example.com']).toEqual(rawCurl('secret'));
754
+ expect(store.slack?.['']).toBeUndefined();
755
+ });
756
+ it('curl injects credentials for the requested account', async () => {
757
+ writeMultiAccountStore({ '': 'default', 'work@example.com': 'work' });
758
+ const deps = createMockDependencies();
759
+ await runCommand(['--account', 'work@example.com', 'curl', 'https://slack.com/api/test'], deps);
760
+ expect(exitCode).toBe(0);
761
+ expect(capturedArgs).toEqual(['-H', 'X-Token: work', 'https://slack.com/api/test']);
762
+ });
763
+ it('curl uses the single stored account without requiring --account', async () => {
764
+ writeMultiAccountStore({ 'only@example.com': 'solo' });
765
+ const deps = createMockDependencies();
766
+ await runCommand(['curl', 'https://slack.com/api/test'], deps);
767
+ expect(exitCode).toBe(0);
768
+ expect(capturedArgs).toEqual(['-H', 'X-Token: solo', 'https://slack.com/api/test']);
769
+ });
770
+ it('curl requires --account when multiple accounts exist', async () => {
771
+ writeMultiAccountStore({ 'a@example.com': 'a', 'b@example.com': 'b' });
772
+ const deps = createMockDependencies();
773
+ await runCommand(['curl', 'https://slack.com/api/test'], deps);
774
+ expect(exitCode).toBe(1);
775
+ expect(errorLogs.join('\n')).toContain('--account');
776
+ });
777
+ it('curl fails gracefully for a non-existing account', async () => {
778
+ writeMultiAccountStore({ '': 'default' });
779
+ const deps = createMockDependencies();
780
+ await runCommand(['--account', 'missing@example.com', 'curl', 'https://slack.com/api/test'], deps);
781
+ // A missing account is treated as "no credentials for this service",
782
+ // and the suggested commands carry the requested account.
783
+ expect(exitCode).toBe(1);
784
+ expect(capturedArgs).toEqual([]);
785
+ expect(errorLogs.join('\n')).toContain("No credentials found for slack (account 'missing@example.com')");
786
+ expect(errorLogs.join('\n')).toContain('latchkey --account missing@example.com auth browser slack');
787
+ });
788
+ it('auth clear keeps the preparation without --all', async () => {
789
+ const storePath = join(tempDir, 'credentials.json');
790
+ writeSecureFile(storePath, JSON.stringify({
791
+ credentials: { slack: { 'a@example.com': rawCurl('a') } },
792
+ preparations: {
793
+ slack: { objectType: 'oauth', clientId: 'cid', clientSecret: 'csecret' },
794
+ },
795
+ }));
796
+ const deps = createMockDependencies();
797
+ await runCommand(['auth', 'clear', 'slack'], deps);
798
+ expect(readStore(storePath).slack).toBeUndefined();
799
+ expect(readPreparations(storePath).slack).toBeDefined();
800
+ });
801
+ it('auth clear --all removes all accounts and the preparation', async () => {
802
+ const storePath = join(tempDir, 'credentials.json');
803
+ writeSecureFile(storePath, JSON.stringify({
804
+ credentials: { slack: { 'a@example.com': rawCurl('a'), 'b@example.com': rawCurl('b') } },
805
+ preparations: {
806
+ slack: { objectType: 'oauth', clientId: 'cid', clientSecret: 'csecret' },
807
+ },
808
+ }));
809
+ const deps = createMockDependencies();
810
+ await runCommand(['auth', 'clear', 'slack', '--all'], deps);
811
+ expect(logs.join('\n')).toContain('API credentials for slack have been cleared.');
812
+ expect(readStore(storePath).slack).toBeUndefined();
813
+ expect(readPreparations(storePath).slack).toBeUndefined();
814
+ });
815
+ it('auth clear --all rejects a missing service name', async () => {
816
+ const deps = createMockDependencies();
817
+ await runCommand(['auth', 'clear', '--all'], deps);
818
+ expect(exitCode).toBe(1);
819
+ expect(errorLogs.join('\n')).toContain('--all requires a service name');
820
+ });
821
+ it('auth clear --all rejects --account', async () => {
822
+ writeMultiAccountStore({ 'a@example.com': 'a' });
823
+ const deps = createMockDependencies();
824
+ await runCommand(['--account', 'a@example.com', 'auth', 'clear', 'slack', '--all'], deps);
825
+ expect(exitCode).toBe(1);
826
+ expect(errorLogs.join('\n')).toContain('--all cannot be combined with --account');
827
+ });
828
+ it('auth clear removes only the requested account', async () => {
829
+ const storePath = writeMultiAccountStore({
830
+ 'a@example.com': 'a',
831
+ 'b@example.com': 'b',
832
+ });
833
+ const deps = createMockDependencies();
834
+ await runCommand(['--account', 'a@example.com', 'auth', 'clear', 'slack'], deps);
835
+ const store = readStore(storePath);
836
+ expect(store.slack?.['a@example.com']).toBeUndefined();
837
+ expect(store.slack?.['b@example.com']).toBeDefined();
838
+ });
839
+ it('auth clear requires --account when multiple accounts exist', async () => {
840
+ writeMultiAccountStore({ 'a@example.com': 'a', 'b@example.com': 'b' });
841
+ const deps = createMockDependencies();
842
+ await runCommand(['auth', 'clear', 'slack'], deps);
843
+ expect(exitCode).toBe(1);
844
+ expect(errorLogs.join('\n')).toContain('--account');
845
+ });
846
+ it('auth clear fails gracefully for a non-existing account', async () => {
847
+ writeMultiAccountStore({ '': 'default' });
848
+ const deps = createMockDependencies();
849
+ await runCommand(['--account', 'missing@example.com', 'auth', 'clear', 'slack'], deps);
850
+ // Clearing a missing account is a no-op rather than an error.
851
+ expect(exitCode).toBeNull();
852
+ expect(logs.join('\n')).toContain('No API credentials found for slack.');
853
+ });
854
+ it.each([
855
+ ['services', 'info', 'slack'],
856
+ ['auth', 'browser-prepare', 'slack'],
857
+ ['auth', 'prepare', 'google-gmail', '{"clientId":"a","clientSecret":"b"}'],
858
+ ['auth', 'list'],
859
+ ['services', 'list'],
860
+ ])('rejects --account for the unsupported command: %s %s', async (...commandArgs) => {
861
+ const deps = createMockDependencies();
862
+ await runCommand(['--account', 'hola@jola.com', ...commandArgs], deps);
863
+ expect(exitCode).toBe(1);
864
+ expect(errorLogs.join('\n')).toContain('--account option is not supported');
865
+ });
866
+ });
718
867
  describe('auth list command', () => {
719
868
  it('should list stored credentials with their status', async () => {
720
869
  const storePath = join(tempDir, 'credentials.json');
721
- writeSecureFile(storePath, JSON.stringify({
870
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
722
871
  slack: { objectType: 'slack', token: 'test-token', dCookie: 'test-cookie' },
723
- }));
872
+ })));
724
873
  const deps = createMockDependencies();
725
874
  await runCommand(['auth', 'list'], deps);
726
875
  expect(logs).toHaveLength(1);
727
876
  const entries = JSON.parse(logs[0] ?? '');
728
877
  expect(entries.slack).toEqual({
729
- credentialType: 'slack',
730
- credentialStatus: 'valid',
878
+ '': { credentialType: 'slack', credentialStatus: 'valid' },
879
+ });
880
+ });
881
+ it('should key each service by account', async () => {
882
+ const storePath = join(tempDir, 'credentials.json');
883
+ writeSecureFile(storePath, JSON.stringify({
884
+ credentials: {
885
+ slack: {
886
+ '': { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: default'] },
887
+ 'work@example.com': { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: work'] },
888
+ },
889
+ },
890
+ }));
891
+ const deps = createMockDependencies();
892
+ await runCommand(['auth', 'list'], deps);
893
+ const entries = JSON.parse(logs[0] ?? '');
894
+ expect(entries.slack).toEqual({
895
+ '': { credentialType: 'rawCurl', credentialStatus: 'valid' },
896
+ 'work@example.com': { credentialType: 'rawCurl', credentialStatus: 'valid' },
731
897
  });
732
898
  });
733
899
  it('should output empty object when no credentials are stored', async () => {
@@ -741,16 +907,15 @@ describe('CLI commands with dependency injection', () => {
741
907
  });
742
908
  it('should treat unknown services as valid', async () => {
743
909
  const storePath = join(tempDir, 'credentials.json');
744
- writeSecureFile(storePath, JSON.stringify({
910
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
745
911
  unknown: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Token: secret'] },
746
- }));
912
+ })));
747
913
  const deps = createMockDependencies();
748
914
  await runCommand(['auth', 'list'], deps);
749
915
  expect(logs).toHaveLength(1);
750
916
  const entries = JSON.parse(logs[0] ?? '');
751
917
  expect(entries.unknown).toEqual({
752
- credentialType: 'rawCurl',
753
- credentialStatus: 'valid',
918
+ '': { credentialType: 'rawCurl', credentialStatus: 'valid' },
754
919
  });
755
920
  });
756
921
  });
@@ -761,8 +926,7 @@ describe('CLI commands with dependency injection', () => {
761
926
  const deps = createMockDependencies();
762
927
  await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: secret', '-H', 'X-Other: value'], deps);
763
928
  expect(logs).toContain('Credentials stored.');
764
- const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
765
- expect(storedData.slack).toEqual({
929
+ expect(readDefaultCredentials(storePath, 'slack')).toEqual({
766
930
  objectType: 'rawCurl',
767
931
  curlArguments: ['-H', 'X-Token: secret', '-H', 'X-Other: value'],
768
932
  });
@@ -784,14 +948,13 @@ describe('CLI commands with dependency injection', () => {
784
948
  });
785
949
  it('should overwrite existing credentials', async () => {
786
950
  const storePath = join(tempDir, 'credentials.json');
787
- writeSecureFile(storePath, JSON.stringify({
951
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
788
952
  slack: { objectType: 'slack', token: 'old-token', dCookie: 'old-cookie' },
789
- }));
953
+ })));
790
954
  const deps = createMockDependencies();
791
955
  await runCommand(['auth', 'set', 'slack', '-H', 'X-Token: new-secret'], deps);
792
956
  expect(logs).toContain('Credentials stored.');
793
- const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
794
- expect(storedData.slack).toEqual({
957
+ expect(readDefaultCredentials(storePath, 'slack')).toEqual({
795
958
  objectType: 'rawCurl',
796
959
  curlArguments: ['-H', 'X-Token: new-secret'],
797
960
  });
@@ -802,7 +965,9 @@ describe('CLI commands with dependency injection', () => {
802
965
  const NEW_ENCRYPTION_KEY = 'Cc3mOUF63KFLOU3oJwFdfCBaMWlvjlbgxkcLlX+bEzM=';
803
966
  function readWithKey(path, key) {
804
967
  const storage = new EncryptedStorage(key);
805
- return JSON.parse(storage.readFile(path) ?? '{}');
968
+ const raw = JSON.parse(storage.readFile(path) ?? '{}');
969
+ // Unwrap the default account so assertions can compare flat credentials.
970
+ return Object.fromEntries(Object.entries(raw.credentials ?? {}).map(([service, accounts]) => [service, accounts['']]));
806
971
  }
807
972
  function withStdinKey(key, overrides = {}) {
808
973
  return createMockDependencies({ readStdin: () => Promise.resolve(key), ...overrides });
@@ -810,7 +975,7 @@ describe('CLI commands with dependency injection', () => {
810
975
  // The credential store filename used by the mock config.
811
976
  const STORE_FILENAME = 'credentials.json';
812
977
  function writeStore(contents) {
813
- writeSecureFile(join(tempDir, STORE_FILENAME), JSON.stringify(contents));
978
+ writeSecureFile(join(tempDir, STORE_FILENAME), JSON.stringify(nestAccounts(contents)));
814
979
  }
815
980
  it('re-encrypts all stored credentials with the new key', async () => {
816
981
  writeStore({
@@ -920,8 +1085,7 @@ describe('CLI commands with dependency injection', () => {
920
1085
  });
921
1086
  await runCommand(['auth', 'set-nocurl', 'telegram', '123456:ABC-DEF'], deps);
922
1087
  expect(logs).toContain('Credentials stored.');
923
- const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
924
- expect(storedData.telegram).toEqual({
1088
+ expect(readDefaultCredentials(storePath, 'telegram')).toEqual({
925
1089
  objectType: 'telegramBot',
926
1090
  token: '123456:ABC-DEF',
927
1091
  });
@@ -952,7 +1116,7 @@ describe('CLI commands with dependency injection', () => {
952
1116
  });
953
1117
  });
954
1118
  describe('prepare command', () => {
955
- it('stores an OAuth client for a Google service and reports success', async () => {
1119
+ it("stores an OAuth client as the service's preparation and reports success", async () => {
956
1120
  const storePath = join(tempDir, 'credentials.json');
957
1121
  writeSecureFile(storePath, '{}');
958
1122
  const deps = createMockDependencies({
@@ -960,12 +1124,12 @@ describe('CLI commands with dependency injection', () => {
960
1124
  });
961
1125
  await runCommand(['auth', 'prepare', 'google-gmail', '{"clientId":"cid","clientSecret":"csecret"}'], deps);
962
1126
  expect(logs).toContain('Done');
963
- const storedData = JSON.parse(readSecureFile(storePath) ?? '{}');
964
- expect(storedData['google-gmail']).toEqual({
1127
+ expect(readPreparations(storePath)['google-gmail']).toEqual({
965
1128
  objectType: 'oauth',
966
1129
  clientId: 'cid',
967
1130
  clientSecret: 'csecret',
968
1131
  });
1132
+ expect(readDefaultCredentials(storePath, 'google-gmail')).toBeUndefined();
969
1133
  });
970
1134
  it('returns an error for an unknown service', async () => {
971
1135
  const deps = createMockDependencies();
@@ -1003,9 +1167,9 @@ describe('CLI commands with dependency injection', () => {
1003
1167
  describe('curl command', () => {
1004
1168
  it('should pass arguments to subprocess', async () => {
1005
1169
  const storePath = join(tempDir, 'credentials.json');
1006
- writeSecureFile(storePath, JSON.stringify({
1170
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1007
1171
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1008
- }));
1172
+ })));
1009
1173
  const deps = createMockDependencies();
1010
1174
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
1011
1175
  expect(capturedArgs).toEqual([
@@ -1019,9 +1183,9 @@ describe('CLI commands with dependency injection', () => {
1019
1183
  });
1020
1184
  it('should pass raw curl credentials to subprocess', async () => {
1021
1185
  const storePath = join(tempDir, 'credentials.json');
1022
- writeSecureFile(storePath, JSON.stringify({
1186
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1023
1187
  slack: { objectType: 'rawCurl', curlArguments: ['-H', 'X-Custom: header'] },
1024
- }));
1188
+ })));
1025
1189
  const deps = createMockDependencies();
1026
1190
  await runCommand(['curl', 'https://slack.com/api/test'], deps);
1027
1191
  expect(capturedArgs).toEqual(['-H', 'X-Custom: header', 'https://slack.com/api/test']);
@@ -1029,9 +1193,9 @@ describe('CLI commands with dependency injection', () => {
1029
1193
  });
1030
1194
  it('should pass multiple arguments correctly', async () => {
1031
1195
  const storePath = join(tempDir, 'credentials.json');
1032
- writeSecureFile(storePath, JSON.stringify({
1196
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1033
1197
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1034
- }));
1198
+ })));
1035
1199
  const deps = createMockDependencies();
1036
1200
  await runCommand([
1037
1201
  'curl',
@@ -1051,9 +1215,9 @@ describe('CLI commands with dependency injection', () => {
1051
1215
  });
1052
1216
  it('should return subprocess exit code', async () => {
1053
1217
  const storePath = join(tempDir, 'credentials.json');
1054
- writeSecureFile(storePath, JSON.stringify({
1218
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1055
1219
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1056
- }));
1220
+ })));
1057
1221
  const deps = createMockDependencies({
1058
1222
  runCurl: () => ({ returncode: 42, stdout: '', stderr: '' }),
1059
1223
  });
@@ -1072,9 +1236,9 @@ describe('CLI commands with dependency injection', () => {
1072
1236
  });
1073
1237
  it('should return error when OAuth credentials lack an access token', async () => {
1074
1238
  const storePath = join(tempDir, 'credentials.json');
1075
- writeSecureFile(storePath, JSON.stringify({
1239
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1076
1240
  'google-gmail': { objectType: 'oauth', clientId: 'cid', clientSecret: 'csecret' },
1077
- }));
1241
+ })));
1078
1242
  const deps = createMockDependencies({
1079
1243
  registry: new ServiceRegistry([GOOGLE_GMAIL]),
1080
1244
  });
@@ -1084,14 +1248,14 @@ describe('CLI commands with dependency injection', () => {
1084
1248
  });
1085
1249
  it('should route a shared Drive files call to the Docs service when only Docs is authed', async () => {
1086
1250
  const storePath = join(tempDir, 'credentials.json');
1087
- writeSecureFile(storePath, JSON.stringify({
1251
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1088
1252
  'google-docs': {
1089
1253
  objectType: 'oauth',
1090
1254
  clientId: 'cid',
1091
1255
  clientSecret: 'csecret',
1092
1256
  accessToken: 'docs-token',
1093
1257
  },
1094
- }));
1258
+ })));
1095
1259
  const deps = createMockDependencies({
1096
1260
  registry: new ServiceRegistry([GOOGLE_DRIVE, GOOGLE_DOCS]),
1097
1261
  });
@@ -1101,7 +1265,7 @@ describe('CLI commands with dependency injection', () => {
1101
1265
  });
1102
1266
  it('should prefer the canonical Drive service for a shared Drive files call when both are authed', async () => {
1103
1267
  const storePath = join(tempDir, 'credentials.json');
1104
- writeSecureFile(storePath, JSON.stringify({
1268
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1105
1269
  'google-drive': {
1106
1270
  objectType: 'oauth',
1107
1271
  clientId: 'cid',
@@ -1114,7 +1278,7 @@ describe('CLI commands with dependency injection', () => {
1114
1278
  clientSecret: 'csecret',
1115
1279
  accessToken: 'docs-token',
1116
1280
  },
1117
- }));
1281
+ })));
1118
1282
  const deps = createMockDependencies({
1119
1283
  registry: new ServiceRegistry([GOOGLE_DRIVE, GOOGLE_DOCS]),
1120
1284
  });
@@ -1124,7 +1288,7 @@ describe('CLI commands with dependency injection', () => {
1124
1288
  });
1125
1289
  it('should skip an expired candidate in favor of one with unexpired credentials', async () => {
1126
1290
  const storePath = join(tempDir, 'credentials.json');
1127
- writeSecureFile(storePath, JSON.stringify({
1291
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1128
1292
  'google-drive': {
1129
1293
  objectType: 'oauth',
1130
1294
  clientId: 'cid',
@@ -1138,7 +1302,7 @@ describe('CLI commands with dependency injection', () => {
1138
1302
  clientSecret: 'csecret',
1139
1303
  accessToken: 'docs-token',
1140
1304
  },
1141
- }));
1305
+ })));
1142
1306
  const deps = createMockDependencies({
1143
1307
  registry: new ServiceRegistry([GOOGLE_DRIVE, GOOGLE_DOCS]),
1144
1308
  config: createMockConfig({ credentialsRefreshDisabled: true }),
@@ -1149,14 +1313,14 @@ describe('CLI commands with dependency injection', () => {
1149
1313
  });
1150
1314
  it('should not route a non-files Drive call to the Docs service', async () => {
1151
1315
  const storePath = join(tempDir, 'credentials.json');
1152
- writeSecureFile(storePath, JSON.stringify({
1316
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1153
1317
  'google-docs': {
1154
1318
  objectType: 'oauth',
1155
1319
  clientId: 'cid',
1156
1320
  clientSecret: 'csecret',
1157
1321
  accessToken: 'docs-token',
1158
1322
  },
1159
- }));
1323
+ })));
1160
1324
  const deps = createMockDependencies({
1161
1325
  registry: new ServiceRegistry([GOOGLE_DRIVE, GOOGLE_DOCS]),
1162
1326
  });
@@ -1186,9 +1350,9 @@ describe('CLI commands with dependency injection', () => {
1186
1350
  });
1187
1351
  it('should still inject credentials for known services when passthroughUnknown is enabled', async () => {
1188
1352
  const storePath = join(tempDir, 'credentials.json');
1189
- writeSecureFile(storePath, JSON.stringify({
1353
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1190
1354
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1191
- }));
1355
+ })));
1192
1356
  const deps = createMockDependencies({
1193
1357
  config: createMockConfig({ passthroughUnknown: true }),
1194
1358
  });
@@ -1198,26 +1362,15 @@ describe('CLI commands with dependency injection', () => {
1198
1362
  });
1199
1363
  it('should read credentials from store and not call login', async () => {
1200
1364
  const storePath = join(tempDir, 'credentials.json');
1201
- writeSecureFile(storePath, JSON.stringify({
1365
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1202
1366
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1203
- }));
1367
+ })));
1204
1368
  const mockLogin = vi.fn();
1205
- const mockSlackService = {
1206
- name: 'slack',
1207
- displayName: 'Slack',
1208
- baseApiUrls: ['https://slack.com/api/'],
1209
- loginUrl: 'https://slack.com/signin',
1210
- info: 'Test info for Slack service.',
1369
+ const mockSlackService = createMockService({
1211
1370
  credentialCheckCurlArguments: [],
1212
1371
  checkApiCredentials: vi.fn(),
1213
- setCredentialsExample(serviceName) {
1214
- return `latchkey auth set ${serviceName} -H "Authorization: Bearer xoxb-your-token"`;
1215
- },
1216
- getCredentialsNoCurl() {
1217
- throw new NoCurlCredentialsNotSupportedError('slack');
1218
- },
1219
1372
  getSession: vi.fn().mockReturnValue({ login: mockLogin }),
1220
- };
1373
+ });
1221
1374
  const deps = createMockDependencies({
1222
1375
  registry: new ServiceRegistry([mockSlackService]),
1223
1376
  });
@@ -1234,9 +1387,9 @@ describe('CLI commands with dependency injection', () => {
1234
1387
  });
1235
1388
  it('should inject telegram bot token into URL path', async () => {
1236
1389
  const storePath = join(tempDir, 'credentials.json');
1237
- writeSecureFile(storePath, JSON.stringify({
1390
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1238
1391
  telegram: { objectType: 'telegramBot', token: '123456:ABC-DEF' },
1239
- }));
1392
+ })));
1240
1393
  const deps = createMockDependencies({
1241
1394
  registry: new ServiceRegistry([TELEGRAM]),
1242
1395
  });
@@ -1246,10 +1399,10 @@ describe('CLI commands with dependency injection', () => {
1246
1399
  });
1247
1400
  it('should work when service does not have getSession but credentials exist', async () => {
1248
1401
  const storePath = join(tempDir, 'credentials.json');
1249
- writeSecureFile(storePath, JSON.stringify({
1402
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1250
1403
  nologin: { objectType: 'rawCurl', curlArguments: ['-H', 'X-API-Key: secret'] },
1251
- }));
1252
- const noLoginService = {
1404
+ })));
1405
+ const noLoginService = createMockService({
1253
1406
  name: 'nologin',
1254
1407
  displayName: 'No Login Service',
1255
1408
  baseApiUrls: ['https://nologin.example.com/api/'],
@@ -1257,14 +1410,10 @@ describe('CLI commands with dependency injection', () => {
1257
1410
  info: 'A service without browser login support.',
1258
1411
  credentialCheckCurlArguments: [],
1259
1412
  checkApiCredentials: vi.fn().mockResolvedValue(ApiCredentialStatus.Valid),
1260
- setCredentialsExample(serviceName) {
1261
- return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
1262
- },
1263
- getCredentialsNoCurl() {
1264
- throw new NoCurlCredentialsNotSupportedError('nologin');
1265
- },
1413
+ setCredentialsExample: (serviceName) => `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`,
1266
1414
  // No getSession - service doesn't support browser login
1267
- };
1415
+ getSession: undefined,
1416
+ });
1268
1417
  const deps = createMockDependencies({
1269
1418
  registry: new ServiceRegistry([noLoginService]),
1270
1419
  });
@@ -1275,9 +1424,9 @@ describe('CLI commands with dependency injection', () => {
1275
1424
  });
1276
1425
  it('should reject request when permission check denies it', async () => {
1277
1426
  const storePath = join(tempDir, 'credentials.json');
1278
- writeSecureFile(storePath, JSON.stringify({
1427
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1279
1428
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1280
- }));
1429
+ })));
1281
1430
  const deps = createMockDependencies({
1282
1431
  checkPermission: () => Promise.resolve(false),
1283
1432
  });
@@ -1288,9 +1437,9 @@ describe('CLI commands with dependency injection', () => {
1288
1437
  });
1289
1438
  it('should allow request when permission check approves it', async () => {
1290
1439
  const storePath = join(tempDir, 'credentials.json');
1291
- writeSecureFile(storePath, JSON.stringify({
1440
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1292
1441
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1293
- }));
1442
+ })));
1294
1443
  const deps = createMockDependencies({
1295
1444
  checkPermission: () => Promise.resolve(true),
1296
1445
  });
@@ -1300,9 +1449,9 @@ describe('CLI commands with dependency injection', () => {
1300
1449
  });
1301
1450
  it('should exit with error when permission check fails', async () => {
1302
1451
  const storePath = join(tempDir, 'credentials.json');
1303
- writeSecureFile(storePath, JSON.stringify({
1452
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1304
1453
  slack: { objectType: 'slack', token: 'stored-token', dCookie: 'stored-cookie' },
1305
- }));
1454
+ })));
1306
1455
  const { PermissionCheckError } = await import('../src/permissions.js');
1307
1456
  const deps = createMockDependencies({
1308
1457
  checkPermission: () => {
@@ -1317,7 +1466,7 @@ describe('CLI commands with dependency injection', () => {
1317
1466
  });
1318
1467
  describe('auth browser command', () => {
1319
1468
  it('should return error when service does not support browser login', async () => {
1320
- const noLoginService = {
1469
+ const noLoginService = createMockService({
1321
1470
  name: 'nologin',
1322
1471
  displayName: 'No Login Service',
1323
1472
  baseApiUrls: ['https://nologin.example.com/api/'],
@@ -1325,13 +1474,12 @@ describe('CLI commands with dependency injection', () => {
1325
1474
  info: 'A service without browser login support.',
1326
1475
  credentialCheckCurlArguments: [],
1327
1476
  checkApiCredentials: vi.fn(),
1328
- setCredentialsExample(serviceName) {
1329
- return `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`;
1330
- },
1477
+ setCredentialsExample: (serviceName) => `latchkey auth set ${serviceName} -H "Authorization: Bearer <token>"`,
1331
1478
  // eslint-disable-next-line @typescript-eslint/unbound-method
1332
1479
  getCredentialsNoCurl: Service.prototype.getCredentialsNoCurl,
1333
1480
  // No getSession - service doesn't support browser login
1334
- };
1481
+ getSession: undefined,
1482
+ });
1335
1483
  const deps = createMockDependencies({
1336
1484
  registry: new ServiceRegistry([noLoginService]),
1337
1485
  });
@@ -1370,7 +1518,7 @@ describe('CLI commands with dependency injection', () => {
1370
1518
  }
1371
1519
  });
1372
1520
  it('should suggest set-nocurl when service supports nocurl credentials', async () => {
1373
- const nocurlService = {
1521
+ const nocurlService = createMockService({
1374
1522
  name: 'nocurl-only',
1375
1523
  displayName: 'NoCurl Only Service',
1376
1524
  baseApiUrls: ['https://nocurl.example.com/api/'],
@@ -1378,9 +1526,7 @@ describe('CLI commands with dependency injection', () => {
1378
1526
  info: 'A service with nocurl credentials but no browser login.',
1379
1527
  credentialCheckCurlArguments: [],
1380
1528
  checkApiCredentials: vi.fn(),
1381
- setCredentialsExample(serviceName) {
1382
- return `latchkey auth set-nocurl ${serviceName} <some-arg>`;
1383
- },
1529
+ setCredentialsExample: (serviceName) => `latchkey auth set-nocurl ${serviceName} <some-arg>`,
1384
1530
  getCredentialsNoCurl(arguments_) {
1385
1531
  if (arguments_.length !== 1) {
1386
1532
  throw new Error('Expected exactly one argument');
@@ -1388,7 +1534,8 @@ describe('CLI commands with dependency injection', () => {
1388
1534
  return { objectType: 'test', injectIntoCurlCall: vi.fn(), isExpired: () => false };
1389
1535
  },
1390
1536
  // No getSession - service doesn't support browser login
1391
- };
1537
+ getSession: undefined,
1538
+ });
1392
1539
  const deps = createMockDependencies({
1393
1540
  registry: new ServiceRegistry([nocurlService]),
1394
1541
  });
@@ -1659,12 +1806,12 @@ describe('CLI commands with dependency injection', () => {
1659
1806
  await runCommand(['services', 'register', 'my-api', '--base-api-url', 'https://api.example.com/'], deps);
1660
1807
  // Store credentials
1661
1808
  const storePath = join(tempDir, 'credentials.json');
1662
- writeSecureFile(storePath, JSON.stringify({
1809
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1663
1810
  'my-api': {
1664
1811
  objectType: 'rawCurl',
1665
1812
  curlArguments: ['-H', 'Authorization: Bearer my-token'],
1666
1813
  },
1667
- }));
1814
+ })));
1668
1815
  logs = [];
1669
1816
  exitCode = null;
1670
1817
  capturedArgs = [];
@@ -1713,12 +1860,12 @@ describe('CLI commands with dependency injection', () => {
1713
1860
  ], deps);
1714
1861
  // Store credentials
1715
1862
  const storePath = join(tempDir, 'credentials.json');
1716
- writeSecureFile(storePath, JSON.stringify({
1863
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1717
1864
  'my-gitlab': {
1718
1865
  objectType: 'rawCurl',
1719
1866
  curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
1720
1867
  },
1721
- }));
1868
+ })));
1722
1869
  logs = [];
1723
1870
  exitCode = null;
1724
1871
  capturedArgs = [];
@@ -1796,19 +1943,51 @@ describe('CLI commands with dependency injection', () => {
1796
1943
  ], deps);
1797
1944
  // Store credentials for it
1798
1945
  const storePath = join(tempDir, 'credentials.json');
1799
- writeSecureFile(storePath, JSON.stringify({
1946
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1800
1947
  'my-gitlab': {
1801
1948
  objectType: 'rawCurl',
1802
1949
  curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
1803
1950
  },
1951
+ })));
1952
+ logs = [];
1953
+ errorLogs = [];
1954
+ exitCode = null;
1955
+ await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1956
+ expect(exitCode).toBe(1);
1957
+ expect(errorLogs[0]).toContain('Credentials or a preparation still exist');
1958
+ expect(errorLogs[0]).toContain('latchkey auth clear my-gitlab --all');
1959
+ // Service should still be in config
1960
+ const entries = loadRegisteredServices(deps.config.configPath);
1961
+ expect(entries.get('my-gitlab')).toBeDefined();
1962
+ });
1963
+ it('should reject deregistering when only a preparation still exists', async () => {
1964
+ const deps = createMockDependencies({
1965
+ registry: new ServiceRegistry([GITLAB]),
1966
+ });
1967
+ await runCommand([
1968
+ 'services',
1969
+ 'register',
1970
+ 'my-gitlab',
1971
+ '--base-api-url',
1972
+ 'https://gitlab.mycompany.com/api/',
1973
+ '--service-family',
1974
+ 'gitlab',
1975
+ ], deps);
1976
+ // Store a preparation (but no credentials) for the service
1977
+ const storePath = join(tempDir, 'credentials.json');
1978
+ writeSecureFile(storePath, JSON.stringify({
1979
+ credentials: {},
1980
+ preparations: {
1981
+ 'my-gitlab': { objectType: 'oauth', clientId: 'cid', clientSecret: 'csecret' },
1982
+ },
1804
1983
  }));
1805
1984
  logs = [];
1806
1985
  errorLogs = [];
1807
1986
  exitCode = null;
1808
1987
  await runCommand(['services', 'deregister', 'my-gitlab'], deps);
1809
1988
  expect(exitCode).toBe(1);
1810
- expect(errorLogs[0]).toContain('Credentials still exist');
1811
- expect(errorLogs[0]).toContain('latchkey auth clear my-gitlab');
1989
+ expect(errorLogs[0]).toContain('Credentials or a preparation still exist');
1990
+ expect(errorLogs[0]).toContain('latchkey auth clear my-gitlab --all');
1812
1991
  // Service should still be in config
1813
1992
  const entries = loadRegisteredServices(deps.config.configPath);
1814
1993
  expect(entries.get('my-gitlab')).toBeDefined();
@@ -1829,12 +2008,12 @@ describe('CLI commands with dependency injection', () => {
1829
2008
  ], deps);
1830
2009
  // Store and then clear credentials
1831
2010
  const storePath = join(tempDir, 'credentials.json');
1832
- writeSecureFile(storePath, JSON.stringify({
2011
+ writeSecureFile(storePath, JSON.stringify(nestAccounts({
1833
2012
  'my-gitlab': {
1834
2013
  objectType: 'rawCurl',
1835
2014
  curlArguments: ['-H', 'PRIVATE-TOKEN: my-secret-token'],
1836
2015
  },
1837
- }));
2016
+ })));
1838
2017
  logs = [];
1839
2018
  errorLogs = [];
1840
2019
  exitCode = null;
@@ -1998,6 +2177,14 @@ describe('CLI commands with dependency injection', () => {
1998
2177
  });
1999
2178
  expect(logs).toContain('Done');
2000
2179
  });
2180
+ it('reports the account returned by the gateway for `auth browser`', async () => {
2181
+ makeFetchMock(new Response(JSON.stringify({ result: { account: 'user@example.com' } }), { status: 200 }));
2182
+ const deps = createMockDependencies({
2183
+ config: createMockConfig({ gatewayUrl: GATEWAY_URL }),
2184
+ });
2185
+ await runCommand(['auth', 'browser', 'slack'], deps);
2186
+ expect(logs).toContain("Done. Stored credentials for account 'user@example.com'.");
2187
+ });
2001
2188
  it('forwards `auth browser-prepare` and reports `Already prepared.` when the gateway says so', async () => {
2002
2189
  makeFetchMock(new Response(JSON.stringify({ result: { alreadyPrepared: true } }), { status: 200 }));
2003
2190
  const deps = createMockDependencies({