agent-messenger 2.17.0 → 2.19.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 (112) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bunfig.toml +1 -0
  3. package/dist/package.json +1 -1
  4. package/dist/src/platforms/discordbot/client.d.ts +1 -0
  5. package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
  6. package/dist/src/platforms/discordbot/client.js +3 -0
  7. package/dist/src/platforms/discordbot/client.js.map +1 -1
  8. package/dist/src/platforms/discordbot/commands/message.d.ts +1 -0
  9. package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
  10. package/dist/src/platforms/discordbot/commands/message.js +2 -0
  11. package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
  12. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  13. package/dist/src/platforms/instagram/commands/auth.js +1 -3
  14. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  15. package/dist/src/platforms/kakaotalk/client.d.ts +4 -2
  16. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  17. package/dist/src/platforms/kakaotalk/client.js +16 -2
  18. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  19. package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
  20. package/dist/src/platforms/kakaotalk/commands/auth.js +2 -17
  21. package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
  22. package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
  23. package/dist/src/platforms/kakaotalk/commands/message.js +23 -1
  24. package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
  25. package/dist/src/platforms/kakaotalk/index.d.ts +1 -1
  26. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  27. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  28. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +2 -1
  29. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  30. package/dist/src/platforms/kakaotalk/protocol/session.js +15 -0
  31. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  32. package/dist/src/platforms/kakaotalk/types.d.ts +18 -0
  33. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  34. package/dist/src/platforms/kakaotalk/types.js +1 -0
  35. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  36. package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
  37. package/dist/src/platforms/line/commands/auth.js +2 -4
  38. package/dist/src/platforms/line/commands/auth.js.map +1 -1
  39. package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
  40. package/dist/src/platforms/telegram/commands/auth.js +5 -7
  41. package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
  42. package/dist/src/platforms/webex/commands/auth.d.ts +5 -2
  43. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  44. package/dist/src/platforms/webex/commands/auth.js +59 -1
  45. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  46. package/dist/src/platforms/webex/credential-manager.d.ts +11 -0
  47. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  48. package/dist/src/platforms/webex/credential-manager.js +37 -0
  49. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  50. package/dist/src/platforms/whatsapp/commands/auth.d.ts.map +1 -1
  51. package/dist/src/platforms/whatsapp/commands/auth.js +2 -4
  52. package/dist/src/platforms/whatsapp/commands/auth.js.map +1 -1
  53. package/dist/src/shared/chromium/browsers.js +1 -1
  54. package/dist/src/shared/chromium/browsers.js.map +1 -1
  55. package/dist/src/shared/utils/interactive.d.ts +3 -0
  56. package/dist/src/shared/utils/interactive.d.ts.map +1 -0
  57. package/dist/src/shared/utils/interactive.js +16 -0
  58. package/dist/src/shared/utils/interactive.js.map +1 -0
  59. package/docs/content/docs/cli/discordbot.mdx +6 -0
  60. package/docs/content/docs/sdk/discordbot.mdx +4 -0
  61. package/package.json +1 -1
  62. package/skills/agent-channeltalk/SKILL.md +1 -1
  63. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  64. package/skills/agent-discord/SKILL.md +1 -1
  65. package/skills/agent-discordbot/SKILL.md +9 -1
  66. package/skills/agent-instagram/SKILL.md +1 -1
  67. package/skills/agent-kakaotalk/SKILL.md +24 -1
  68. package/skills/agent-line/SKILL.md +1 -1
  69. package/skills/agent-slack/SKILL.md +1 -1
  70. package/skills/agent-slackbot/SKILL.md +1 -1
  71. package/skills/agent-teams/SKILL.md +1 -1
  72. package/skills/agent-telegram/SKILL.md +1 -1
  73. package/skills/agent-telegrambot/SKILL.md +1 -1
  74. package/skills/agent-webex/SKILL.md +32 -1
  75. package/skills/agent-wechatbot/SKILL.md +1 -1
  76. package/skills/agent-whatsapp/SKILL.md +1 -1
  77. package/skills/agent-whatsappbot/SKILL.md +1 -1
  78. package/src/platforms/discord/commands/dm.test.ts +28 -20
  79. package/src/platforms/discord/commands/reaction.test.ts +12 -7
  80. package/src/platforms/discordbot/client.test.ts +17 -0
  81. package/src/platforms/discordbot/client.ts +9 -2
  82. package/src/platforms/discordbot/commands/message.test.ts +28 -11
  83. package/src/platforms/discordbot/commands/message.ts +4 -2
  84. package/src/platforms/instagram/commands/auth.test.ts +11 -9
  85. package/src/platforms/instagram/commands/auth.ts +1 -4
  86. package/src/platforms/instagram/commands/chat.test.ts +8 -6
  87. package/src/platforms/instagram/commands/message.test.ts +8 -6
  88. package/src/platforms/kakaotalk/client.test.ts +57 -0
  89. package/src/platforms/kakaotalk/client.ts +23 -2
  90. package/src/platforms/kakaotalk/commands/auth.ts +2 -18
  91. package/src/platforms/kakaotalk/commands/message.test.ts +42 -0
  92. package/src/platforms/kakaotalk/commands/message.ts +33 -2
  93. package/src/platforms/kakaotalk/index.ts +2 -0
  94. package/src/platforms/kakaotalk/protocol/session.ts +15 -1
  95. package/src/platforms/kakaotalk/types.ts +24 -0
  96. package/src/platforms/line/commands/auth.ts +2 -5
  97. package/src/platforms/telegram/commands/auth.ts +5 -8
  98. package/src/platforms/webex/commands/auth.test.ts +178 -14
  99. package/src/platforms/webex/commands/auth.ts +102 -3
  100. package/src/platforms/webex/commands/member.test.ts +14 -20
  101. package/src/platforms/webex/commands/message.test.ts +11 -20
  102. package/src/platforms/webex/commands/snapshot.test.ts +11 -20
  103. package/src/platforms/webex/commands/space.test.ts +15 -23
  104. package/src/platforms/webex/commands/whoami.test.ts +8 -22
  105. package/src/platforms/webex/credential-manager.test.ts +78 -0
  106. package/src/platforms/webex/credential-manager.ts +59 -0
  107. package/src/platforms/whatsapp/commands/auth.ts +2 -5
  108. package/src/shared/chromium/browsers.ts +1 -1
  109. package/src/shared/utils/interactive.test.ts +55 -0
  110. package/src/shared/utils/interactive.ts +15 -0
  111. package/src/test-setup.ts +5 -0
  112. package/tsconfig.json +1 -1
@@ -2,6 +2,7 @@ import { Command } from 'commander'
2
2
 
3
3
  import { collectBrowserProfileOption } from '@/shared/chromium'
4
4
  import { handleError } from '@/shared/utils/error-handler'
5
+ import { isInteractive } from '@/shared/utils/interactive'
5
6
  import { formatOutput } from '@/shared/utils/output'
6
7
  import { info, debug } from '@/shared/utils/stderr'
7
8
 
@@ -43,12 +44,15 @@ async function resolveClientCredentials(options: {
43
44
  return getWebexAppCredentials()
44
45
  }
45
46
 
46
- export async function loginAction(options: {
47
+ interface LoginOptions {
47
48
  token?: string
49
+ deviceCode?: string
48
50
  clientId?: string
49
51
  clientSecret?: string
50
52
  pretty?: boolean
51
- }): Promise<void> {
53
+ }
54
+
55
+ export async function loginAction(options: LoginOptions): Promise<void> {
52
56
  try {
53
57
  const credManager = new WebexCredentialManager()
54
58
 
@@ -73,6 +77,16 @@ export async function loginAction(options: {
73
77
  return
74
78
  }
75
79
 
80
+ if (options.deviceCode) {
81
+ await finishDeviceGrant(credManager, options)
82
+ return
83
+ }
84
+
85
+ if (!isInteractive()) {
86
+ await startNonInteractiveDeviceGrant(credManager, options)
87
+ return
88
+ }
89
+
76
90
  const { clientId, clientSecret } = await resolveClientCredentials(options)
77
91
 
78
92
  const device = await credManager.requestDeviceCode(clientId)
@@ -110,6 +124,81 @@ export async function loginAction(options: {
110
124
  }
111
125
  }
112
126
 
127
+ async function startNonInteractiveDeviceGrant(
128
+ credManager: WebexCredentialManager,
129
+ options: LoginOptions,
130
+ ): Promise<void> {
131
+ const { clientId } = await resolveClientCredentials(options)
132
+ const device = await credManager.requestDeviceCode(clientId)
133
+ const expiresAt = Date.now() + device.expiresIn * 1000
134
+
135
+ console.log(
136
+ formatOutput(
137
+ {
138
+ next_action: 'authorize_in_browser',
139
+ verification_uri: device.verificationUri,
140
+ verification_uri_complete: device.verificationUriComplete,
141
+ user_code: device.userCode,
142
+ device_code: device.deviceCode,
143
+ expires_at: expiresAt,
144
+ message:
145
+ 'Show the user `verification_uri` and `user_code` (or just `verification_uri_complete`) and ask them to approve access in any browser. After they approve, run `agent-webex auth login --device-code <device_code>` to retrieve the token. The device code expires at `expires_at`. If you passed `--client-id`/`--client-secret`, pass them again on the second call.',
146
+ },
147
+ options.pretty,
148
+ ),
149
+ )
150
+ process.exit(0)
151
+ }
152
+
153
+ async function finishDeviceGrant(credManager: WebexCredentialManager, options: LoginOptions): Promise<void> {
154
+ const { clientId, clientSecret } = await resolveClientCredentials(options)
155
+ const result = await credManager.exchangeDeviceCode(options.deviceCode!, clientId, clientSecret)
156
+
157
+ if (result.status === 'success') {
158
+ await credManager.saveConfig({ ...result.config, clientId, clientSecret, tokenType: 'oauth' })
159
+ const client = await new WebexClient().login({ token: result.config.accessToken })
160
+ const person = await client.testAuth()
161
+ console.log(
162
+ formatOutput(
163
+ {
164
+ authenticated: true,
165
+ user: { id: person.id, displayName: person.displayName, emails: person.emails },
166
+ },
167
+ options.pretty,
168
+ ),
169
+ )
170
+ return
171
+ }
172
+
173
+ if (result.status === 'pending') {
174
+ console.log(
175
+ formatOutput(
176
+ {
177
+ next_action: 'still_pending',
178
+ device_code: options.deviceCode,
179
+ message:
180
+ 'User has not approved access yet. Confirm with the user that they completed authorization in the browser, then run `agent-webex auth login --device-code <device_code>` again to retry.',
181
+ },
182
+ options.pretty,
183
+ ),
184
+ )
185
+ process.exit(0)
186
+ return
187
+ }
188
+
189
+ console.log(
190
+ formatOutput(
191
+ {
192
+ next_action: 'restart',
193
+ error: result.status === 'expired' ? 'Device code expired.' : `Device code exchange failed: ${result.message}`,
194
+ message: 'This device code is no longer valid. Run `agent-webex auth login` to start a new login.',
195
+ },
196
+ options.pretty,
197
+ ),
198
+ )
199
+ process.exit(1)
200
+ }
201
+
113
202
  export async function statusAction(options: { pretty?: boolean }): Promise<void> {
114
203
  try {
115
204
  const credManager = new WebexCredentialManager()
@@ -276,8 +365,18 @@ export const authCommand = new Command('auth')
276
365
  .description('Authentication commands')
277
366
  .addCommand(
278
367
  new Command('login')
279
- .description('Login to Webex')
368
+ .description(
369
+ 'Log in to Webex. In a TTY this opens a browser and waits for approval. ' +
370
+ 'When non-interactive (AI agents, CI/CD): first call starts the OAuth Device Grant flow ' +
371
+ 'and returns a verification URL, user code, and device code. After the user approves in a ' +
372
+ 'browser, call again with --device-code to exchange it for a token. Pass --token for ' +
373
+ 'fully unattended auth.',
374
+ )
280
375
  .option('--token <token>', 'Use a bot token or personal access token directly')
376
+ .option(
377
+ '--device-code <code>',
378
+ 'OAuth Device Grant code returned from a previous non-interactive `auth login` call',
379
+ )
281
380
  .option('--client-id <id>', 'Webex Integration client ID')
282
381
  .option('--client-secret <secret>', 'Webex Integration client secret')
283
382
  .option('--pretty', 'Pretty print JSON output')
@@ -1,14 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:test'
2
2
 
3
- import { WebexError } from '../types'
4
-
5
- const mockHandleError = mock((err: Error) => {
6
- throw err
7
- })
3
+ import * as errorHandler from '@/shared/utils/error-handler'
8
4
 
9
- mock.module('@/shared/utils/error-handler', () => ({
10
- handleError: mockHandleError,
11
- }))
5
+ import { WebexClient } from '../client'
6
+ import { WebexError } from '../types'
12
7
 
13
8
  const mockMembers = [
14
9
  {
@@ -32,30 +27,29 @@ const mockMembers = [
32
27
  ]
33
28
 
34
29
  const mockListMemberships = mock(() => Promise.resolve(mockMembers))
35
- const mockLogin = mock(() => Promise.resolve({ listMemberships: mockListMemberships }))
36
-
37
- mock.module('../client', () => ({
38
- WebexClient: class {
39
- login = mockLogin
40
- },
41
- }))
42
30
 
43
31
  import { listAction } from './member'
44
32
 
45
33
  describe('member commands', () => {
46
34
  let consoleSpy: ReturnType<typeof spyOn>
35
+ let loginSpy: ReturnType<typeof spyOn>
36
+ let handleErrorSpy: ReturnType<typeof spyOn>
47
37
 
48
38
  beforeEach(() => {
49
39
  mockListMemberships.mockReset().mockImplementation(() => Promise.resolve(mockMembers))
50
- mockLogin.mockReset().mockImplementation(() => Promise.resolve({ listMemberships: mockListMemberships }))
51
- mockHandleError.mockReset().mockImplementation((err: Error) => {
40
+ handleErrorSpy = spyOn(errorHandler, 'handleError').mockImplementation((err: Error) => {
52
41
  throw err
53
42
  })
54
43
 
44
+ loginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue(
45
+ Object.assign(new WebexClient(), { listMemberships: mockListMemberships }),
46
+ )
55
47
  consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
56
48
  })
57
49
 
58
50
  afterEach(() => {
51
+ loginSpy.mockRestore()
52
+ handleErrorSpy.mockRestore()
59
53
  consoleSpy.mockRestore()
60
54
  })
61
55
 
@@ -92,13 +86,13 @@ describe('member commands', () => {
92
86
  })
93
87
 
94
88
  it('throws when not authenticated', async () => {
95
- mockLogin.mockImplementation(async () => {
89
+ loginSpy.mockImplementation(async () => {
96
90
  throw new WebexError('No Webex credentials found.', 'no_credentials')
97
91
  })
98
92
 
99
93
  await expect(listAction('room-1', {})).rejects.toThrow('No Webex credentials found.')
100
94
 
101
- expect(mockHandleError).toHaveBeenCalledWith(expect.any(WebexError))
95
+ expect(handleErrorSpy).toHaveBeenCalledWith(expect.any(WebexError))
102
96
  })
103
97
 
104
98
  it('throws on API error', async () => {
@@ -108,6 +102,6 @@ describe('member commands', () => {
108
102
 
109
103
  await expect(listAction('room-1', {})).rejects.toThrow('API failure')
110
104
 
111
- expect(mockHandleError).toHaveBeenCalledWith(expect.any(Error))
105
+ expect(handleErrorSpy).toHaveBeenCalledWith(expect.any(Error))
112
106
  })
113
107
  })
@@ -1,14 +1,9 @@
1
1
  import { afterEach, beforeEach, expect, mock, spyOn, it } from 'bun:test'
2
2
 
3
- import { WebexError } from '../types'
4
-
5
- const mockHandleError = mock((err: Error) => {
6
- throw err
7
- })
3
+ import * as errorHandler from '@/shared/utils/error-handler'
8
4
 
9
- mock.module('@/shared/utils/error-handler', () => ({
10
- handleError: mockHandleError,
11
- }))
5
+ import { WebexClient } from '../client'
6
+ import { WebexError } from '../types'
12
7
 
13
8
  const mockMessage = {
14
9
  id: 'msg_123',
@@ -46,17 +41,11 @@ const mockClient = {
46
41
  editMessage: mockEditMessage,
47
42
  }
48
43
 
49
- const mockLogin = mock(() => Promise.resolve(mockClient))
50
-
51
- mock.module('../client', () => ({
52
- WebexClient: class {
53
- login = mockLogin
54
- },
55
- }))
56
-
57
44
  import { deleteAction, dmAction, editAction, getAction, listAction, sendAction } from './message'
58
45
 
59
46
  let consoleLogSpy: ReturnType<typeof spyOn>
47
+ let loginSpy: ReturnType<typeof spyOn>
48
+ let handleErrorSpy: ReturnType<typeof spyOn>
60
49
 
61
50
  beforeEach(() => {
62
51
  mockSendMessage.mockReset().mockImplementation(() => Promise.resolve(mockMessage))
@@ -65,15 +54,17 @@ beforeEach(() => {
65
54
  mockGetMessage.mockReset().mockImplementation(() => Promise.resolve(mockMessage))
66
55
  mockDeleteMessage.mockReset().mockImplementation(() => Promise.resolve(undefined))
67
56
  mockEditMessage.mockReset().mockImplementation(() => Promise.resolve({ ...mockMessage, text: 'Updated message' }))
68
- mockLogin.mockReset().mockImplementation(() => Promise.resolve(mockClient))
69
- mockHandleError.mockReset().mockImplementation((err: Error) => {
57
+ handleErrorSpy = spyOn(errorHandler, 'handleError').mockImplementation((err: Error) => {
70
58
  throw err
71
59
  })
72
60
 
61
+ loginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue(Object.assign(new WebexClient(), mockClient))
73
62
  consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
74
63
  })
75
64
 
76
65
  afterEach(() => {
66
+ loginSpy.mockRestore()
67
+ handleErrorSpy.mockRestore()
77
68
  consoleLogSpy.mockRestore()
78
69
  })
79
70
 
@@ -97,13 +88,13 @@ it('passes markdown option when --markdown flag is set on send', async () => {
97
88
  })
98
89
 
99
90
  it('throws when not authenticated on send', async () => {
100
- mockLogin.mockImplementation(async () => {
91
+ loginSpy.mockImplementation(async () => {
101
92
  throw new WebexError('No Webex credentials found.', 'no_credentials')
102
93
  })
103
94
 
104
95
  await expect(sendAction('space_456', 'Hello', { pretty: false })).rejects.toThrow('No Webex credentials found.')
105
96
 
106
- expect(mockHandleError).toHaveBeenCalledWith(expect.any(WebexError))
97
+ expect(handleErrorSpy).toHaveBeenCalledWith(expect.any(WebexError))
107
98
  })
108
99
 
109
100
  it('calls sendDirectMessage with email and text', async () => {
@@ -1,14 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:test'
2
2
 
3
- import { WebexError } from '../types'
4
-
5
- const mockHandleError = mock((err: Error) => {
6
- throw err
7
- })
3
+ import * as errorHandler from '@/shared/utils/error-handler'
8
4
 
9
- mock.module('@/shared/utils/error-handler', () => ({
10
- handleError: mockHandleError,
11
- }))
5
+ import { WebexClient } from '../client'
6
+ import { WebexError } from '../types'
12
7
 
13
8
  const mockSpaces = [
14
9
  {
@@ -51,31 +46,27 @@ const mockClient = {
51
46
  listMyMemberships: mockListMyMemberships,
52
47
  }
53
48
 
54
- const mockLogin = mock(() => Promise.resolve(mockClient))
55
-
56
- mock.module('../client', () => ({
57
- WebexClient: class {
58
- login = mockLogin
59
- },
60
- }))
61
-
62
49
  import { snapshotAction } from './snapshot'
63
50
 
64
51
  describe('snapshot command', () => {
65
52
  let consoleSpy: ReturnType<typeof spyOn>
53
+ let loginSpy: ReturnType<typeof spyOn>
54
+ let handleErrorSpy: ReturnType<typeof spyOn>
66
55
 
67
56
  beforeEach(() => {
68
57
  mockListSpaces.mockReset().mockImplementation(() => Promise.resolve(mockSpaces as any))
69
58
  mockListMyMemberships.mockReset().mockImplementation(() => Promise.resolve(mockMyMemberships as any))
70
- mockLogin.mockReset().mockImplementation(() => Promise.resolve(mockClient))
71
- mockHandleError.mockReset().mockImplementation((err: Error) => {
59
+ handleErrorSpy = spyOn(errorHandler, 'handleError').mockImplementation((err: Error) => {
72
60
  throw err
73
61
  })
74
62
 
63
+ loginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue(Object.assign(new WebexClient(), mockClient))
75
64
  consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
76
65
  })
77
66
 
78
67
  afterEach(() => {
68
+ loginSpy.mockRestore()
69
+ handleErrorSpy.mockRestore()
79
70
  consoleSpy.mockRestore()
80
71
  })
81
72
 
@@ -113,12 +104,12 @@ describe('snapshot command', () => {
113
104
  })
114
105
 
115
106
  it('throws when not authenticated', async () => {
116
- mockLogin.mockImplementation(async () => {
107
+ loginSpy.mockImplementation(async () => {
117
108
  throw new WebexError('No Webex credentials found.', 'no_credentials')
118
109
  })
119
110
 
120
111
  await expect(snapshotAction({})).rejects.toThrow('No Webex credentials found.')
121
112
 
122
- expect(mockHandleError).toHaveBeenCalledWith(expect.any(WebexError))
113
+ expect(handleErrorSpy).toHaveBeenCalledWith(expect.any(WebexError))
123
114
  })
124
115
  })
@@ -1,14 +1,9 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, spyOn, it } from 'bun:test'
2
2
 
3
- import { WebexError } from '../types'
4
-
5
- const mockHandleError = mock((err: Error) => {
6
- throw err
7
- })
3
+ import * as errorHandler from '@/shared/utils/error-handler'
8
4
 
9
- mock.module('@/shared/utils/error-handler', () => ({
10
- handleError: mockHandleError,
11
- }))
5
+ import { WebexClient } from '../client'
6
+ import { WebexError } from '../types'
12
7
 
13
8
  const mockSpaces = [
14
9
  {
@@ -44,32 +39,29 @@ const mockSpace = {
44
39
 
45
40
  const mockListSpaces = mock(() => Promise.resolve(mockSpaces))
46
41
  const mockGetSpace = mock(() => Promise.resolve(mockSpace))
47
- const mockLogin = mock(() => Promise.resolve({ listSpaces: mockListSpaces, getSpace: mockGetSpace }))
48
-
49
- mock.module('../client', () => ({
50
- WebexClient: class {
51
- login = mockLogin
52
- },
53
- }))
54
42
 
55
43
  import { infoAction, listAction } from './space'
56
44
 
57
45
  let consoleLogSpy: ReturnType<typeof spyOn>
46
+ let loginSpy: ReturnType<typeof spyOn>
47
+ let handleErrorSpy: ReturnType<typeof spyOn>
58
48
 
59
49
  beforeEach(() => {
60
50
  mockListSpaces.mockReset().mockImplementation(() => Promise.resolve(mockSpaces))
61
51
  mockGetSpace.mockReset().mockImplementation(() => Promise.resolve(mockSpace))
62
- mockLogin
63
- .mockReset()
64
- .mockImplementation(() => Promise.resolve({ listSpaces: mockListSpaces, getSpace: mockGetSpace }))
65
- mockHandleError.mockReset().mockImplementation((err: Error) => {
52
+ handleErrorSpy = spyOn(errorHandler, 'handleError').mockImplementation((err: Error) => {
66
53
  throw err
67
54
  })
68
55
 
56
+ loginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue(
57
+ Object.assign(new WebexClient(), { listSpaces: mockListSpaces, getSpace: mockGetSpace }),
58
+ )
69
59
  consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
70
60
  })
71
61
 
72
62
  afterEach(() => {
63
+ loginSpy.mockRestore()
64
+ handleErrorSpy.mockRestore()
73
65
  consoleLogSpy.mockRestore()
74
66
  })
75
67
 
@@ -138,14 +130,14 @@ describe('listAction', () => {
138
130
  })
139
131
 
140
132
  it('throws when not authenticated', async () => {
141
- mockLogin.mockImplementation(async () => {
133
+ loginSpy.mockImplementation(async () => {
142
134
  throw new WebexError('No Webex credentials found.', 'no_credentials')
143
135
  })
144
136
 
145
137
  await expect(listAction({})).rejects.toThrow('No Webex credentials found.')
146
138
 
147
139
  expect(mockListSpaces).not.toHaveBeenCalled()
148
- expect(mockHandleError).toHaveBeenCalledWith(expect.any(WebexError))
140
+ expect(handleErrorSpy).toHaveBeenCalledWith(expect.any(WebexError))
149
141
  })
150
142
  })
151
143
 
@@ -201,13 +193,13 @@ describe('infoAction', () => {
201
193
  })
202
194
 
203
195
  it('throws when not authenticated', async () => {
204
- mockLogin.mockImplementation(async () => {
196
+ loginSpy.mockImplementation(async () => {
205
197
  throw new WebexError('No Webex credentials found.', 'no_credentials')
206
198
  })
207
199
 
208
200
  await expect(infoAction('space-1', {})).rejects.toThrow('No Webex credentials found.')
209
201
 
210
202
  expect(mockGetSpace).not.toHaveBeenCalled()
211
- expect(mockHandleError).toHaveBeenCalledWith(expect.any(WebexError))
203
+ expect(handleErrorSpy).toHaveBeenCalledWith(expect.any(WebexError))
212
204
  })
213
205
  })
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, expect, spyOn, it } from 'bun:test'
2
2
 
3
- import * as clientModule from '../client'
3
+ import { WebexClient } from '../client'
4
4
  import { WebexError } from '../types'
5
5
  import { whoamiCommand } from './whoami'
6
6
 
@@ -17,27 +17,21 @@ const mockUser = {
17
17
  created: '2024-01-01T00:00:00.000Z',
18
18
  }
19
19
 
20
- const makeFakeClient = () => ({
21
- login: async function (this: unknown) {
22
- return this
23
- },
24
- testAuth: async () => mockUser,
25
- })
26
-
27
- let webexClientSpy: ReturnType<typeof spyOn>
20
+ let loginSpy: ReturnType<typeof spyOn>
21
+ let testAuthSpy: ReturnType<typeof spyOn>
28
22
  let consoleLogSpy: ReturnType<typeof spyOn>
29
23
  let processExitSpy: ReturnType<typeof spyOn>
30
24
 
31
25
  beforeEach(() => {
32
- webexClientSpy = spyOn(clientModule, 'WebexClient').mockImplementation(
33
- makeFakeClient as unknown as typeof clientModule.WebexClient,
34
- )
26
+ loginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
27
+ testAuthSpy = spyOn(WebexClient.prototype, 'testAuth').mockResolvedValue(mockUser)
35
28
  consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
36
29
  processExitSpy = spyOn(process, 'exit').mockImplementation((_code?: number) => undefined as never)
37
30
  })
38
31
 
39
32
  afterEach(() => {
40
- webexClientSpy?.mockRestore()
33
+ loginSpy?.mockRestore()
34
+ testAuthSpy?.mockRestore()
41
35
  consoleLogSpy?.mockRestore()
42
36
  processExitSpy?.mockRestore()
43
37
  })
@@ -102,15 +96,7 @@ it('whoami outputs pretty-printed JSON when --pretty flag is passed', async () =
102
96
 
103
97
  it('whoami exits with code 1 when not authenticated', async () => {
104
98
  // given: no credentials
105
- webexClientSpy.mockImplementation(
106
- () =>
107
- ({
108
- login: async () => {
109
- throw new WebexError('No Webex credentials found.', 'no_credentials')
110
- },
111
- testAuth: async () => mockUser,
112
- }) as unknown as clientModule.WebexClient,
113
- )
99
+ loginSpy.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
114
100
 
115
101
  // when: running whoami
116
102
  await whoamiCommand.parseAsync([], { from: 'user' })
@@ -373,4 +373,82 @@ describe('WebexCredentialManager', () => {
373
373
  expect(loaded?.clientId).toBeUndefined()
374
374
  expect(loaded?.clientSecret).toBeUndefined()
375
375
  })
376
+
377
+ describe('exchangeDeviceCode', () => {
378
+ let originalFetch: typeof globalThis.fetch
379
+
380
+ beforeEach(() => {
381
+ originalFetch = globalThis.fetch
382
+ })
383
+
384
+ afterEach(() => {
385
+ globalThis.fetch = originalFetch
386
+ })
387
+
388
+ it('returns success with config on 200', async () => {
389
+ globalThis.fetch = mock(() =>
390
+ Promise.resolve(
391
+ new Response(JSON.stringify({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 }), {
392
+ status: 200,
393
+ headers: { 'Content-Type': 'application/json' },
394
+ }),
395
+ ),
396
+ ) as unknown as typeof globalThis.fetch
397
+
398
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
399
+ expect(result.status).toBe('success')
400
+ if (result.status === 'success') {
401
+ expect(result.config.accessToken).toBe('at')
402
+ expect(result.config.refreshToken).toBe('rt')
403
+ }
404
+ })
405
+
406
+ it('returns pending on 428', async () => {
407
+ globalThis.fetch = mock(() =>
408
+ Promise.resolve(new Response('', { status: 428 })),
409
+ ) as unknown as typeof globalThis.fetch
410
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
411
+ expect(result.status).toBe('pending')
412
+ })
413
+
414
+ it('returns pending when error description includes authorization_pending', async () => {
415
+ globalThis.fetch = mock(() =>
416
+ Promise.resolve(
417
+ new Response(JSON.stringify({ errors: [{ description: 'authorization_pending' }] }), {
418
+ status: 400,
419
+ headers: { 'Content-Type': 'application/json' },
420
+ }),
421
+ ),
422
+ ) as unknown as typeof globalThis.fetch
423
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
424
+ expect(result.status).toBe('pending')
425
+ })
426
+
427
+ it('returns expired when error description signals expiry', async () => {
428
+ globalThis.fetch = mock(() =>
429
+ Promise.resolve(
430
+ new Response(JSON.stringify({ errors: [{ description: 'expired_token' }] }), {
431
+ status: 400,
432
+ headers: { 'Content-Type': 'application/json' },
433
+ }),
434
+ ),
435
+ ) as unknown as typeof globalThis.fetch
436
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
437
+ expect(result.status).toBe('expired')
438
+ })
439
+
440
+ it('returns error on other failures', async () => {
441
+ globalThis.fetch = mock(() =>
442
+ Promise.resolve(
443
+ new Response(JSON.stringify({ errors: [{ description: 'access_denied' }] }), {
444
+ status: 403,
445
+ headers: { 'Content-Type': 'application/json' },
446
+ }),
447
+ ),
448
+ ) as unknown as typeof globalThis.fetch
449
+ const result = await credManager.exchangeDeviceCode('dc', 'cid', 'csec')
450
+ expect(result.status).toBe('error')
451
+ if (result.status === 'error') expect(result.message).toContain('access_denied')
452
+ })
453
+ })
376
454
  })
@@ -212,6 +212,65 @@ export class WebexCredentialManager {
212
212
  throw new Error('Device authorization timed out')
213
213
  }
214
214
 
215
+ async exchangeDeviceCode(
216
+ deviceCode: string,
217
+ clientId: string,
218
+ clientSecret?: string,
219
+ ): Promise<
220
+ | { status: 'success'; config: Pick<WebexConfig, 'accessToken' | 'refreshToken' | 'expiresAt'> }
221
+ | { status: 'pending' }
222
+ | { status: 'expired' }
223
+ | { status: 'error'; message: string }
224
+ > {
225
+ const basicAuth = btoa(`${clientId}:${clientSecret ?? ''}`)
226
+
227
+ const response = await fetch(OAUTH_DEVICE_TOKEN_URL, {
228
+ method: 'POST',
229
+ headers: {
230
+ Authorization: `Basic ${basicAuth}`,
231
+ 'Content-Type': 'application/x-www-form-urlencoded',
232
+ },
233
+ body: new URLSearchParams({
234
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
235
+ device_code: deviceCode,
236
+ client_id: clientId,
237
+ }),
238
+ })
239
+
240
+ if (response.ok) {
241
+ const data = (await response.json()) as {
242
+ access_token: string
243
+ refresh_token: string
244
+ expires_in: number
245
+ }
246
+ return {
247
+ status: 'success',
248
+ config: {
249
+ accessToken: data.access_token,
250
+ refreshToken: data.refresh_token,
251
+ expiresAt: Date.now() + data.expires_in * 1000,
252
+ },
253
+ }
254
+ }
255
+
256
+ if (response.status === 428) return { status: 'pending' }
257
+
258
+ const errorBody = (await response.json().catch(() => null)) as {
259
+ errors?: Array<{ description: string }>
260
+ } | null
261
+ const errorDesc = errorBody?.errors?.[0]?.description ?? ''
262
+
263
+ if (errorDesc.includes('authorization_pending') || errorDesc.includes('slow_down')) {
264
+ return { status: 'pending' }
265
+ }
266
+
267
+ if (errorDesc.includes('expired_token') || errorDesc.includes('expired')) {
268
+ return { status: 'expired' }
269
+ }
270
+
271
+ return { status: 'error', message: errorDesc || `http_${response.status}` }
272
+ }
273
+
215
274
  async clearCredentials(): Promise<void> {
216
275
  if (existsSync(this.credentialsPath)) {
217
276
  await rm(this.credentialsPath)