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.
- package/.claude-plugin/plugin.json +1 -1
- package/bunfig.toml +1 -0
- package/dist/package.json +1 -1
- package/dist/src/platforms/discordbot/client.d.ts +1 -0
- package/dist/src/platforms/discordbot/client.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/client.js +3 -0
- package/dist/src/platforms/discordbot/client.js.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.d.ts +1 -0
- package/dist/src/platforms/discordbot/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/discordbot/commands/message.js +2 -0
- package/dist/src/platforms/discordbot/commands/message.js.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/instagram/commands/auth.js +1 -3
- package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +4 -2
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +16 -2
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/auth.js +2 -17
- package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/message.js +23 -1
- package/dist/src/platforms/kakaotalk/commands/message.js.map +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts +1 -1
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +2 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +15 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +18 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +1 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/line/commands/auth.js +2 -4
- package/dist/src/platforms/line/commands/auth.js.map +1 -1
- package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/telegram/commands/auth.js +5 -7
- package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/commands/auth.d.ts +5 -2
- package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/webex/commands/auth.js +59 -1
- package/dist/src/platforms/webex/commands/auth.js.map +1 -1
- package/dist/src/platforms/webex/credential-manager.d.ts +11 -0
- package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
- package/dist/src/platforms/webex/credential-manager.js +37 -0
- package/dist/src/platforms/webex/credential-manager.js.map +1 -1
- package/dist/src/platforms/whatsapp/commands/auth.d.ts.map +1 -1
- package/dist/src/platforms/whatsapp/commands/auth.js +2 -4
- package/dist/src/platforms/whatsapp/commands/auth.js.map +1 -1
- package/dist/src/shared/chromium/browsers.js +1 -1
- package/dist/src/shared/chromium/browsers.js.map +1 -1
- package/dist/src/shared/utils/interactive.d.ts +3 -0
- package/dist/src/shared/utils/interactive.d.ts.map +1 -0
- package/dist/src/shared/utils/interactive.js +16 -0
- package/dist/src/shared/utils/interactive.js.map +1 -0
- package/docs/content/docs/cli/discordbot.mdx +6 -0
- package/docs/content/docs/sdk/discordbot.mdx +4 -0
- package/package.json +1 -1
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +9 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +24 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +32 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/discord/commands/dm.test.ts +28 -20
- package/src/platforms/discord/commands/reaction.test.ts +12 -7
- package/src/platforms/discordbot/client.test.ts +17 -0
- package/src/platforms/discordbot/client.ts +9 -2
- package/src/platforms/discordbot/commands/message.test.ts +28 -11
- package/src/platforms/discordbot/commands/message.ts +4 -2
- package/src/platforms/instagram/commands/auth.test.ts +11 -9
- package/src/platforms/instagram/commands/auth.ts +1 -4
- package/src/platforms/instagram/commands/chat.test.ts +8 -6
- package/src/platforms/instagram/commands/message.test.ts +8 -6
- package/src/platforms/kakaotalk/client.test.ts +57 -0
- package/src/platforms/kakaotalk/client.ts +23 -2
- package/src/platforms/kakaotalk/commands/auth.ts +2 -18
- package/src/platforms/kakaotalk/commands/message.test.ts +42 -0
- package/src/platforms/kakaotalk/commands/message.ts +33 -2
- package/src/platforms/kakaotalk/index.ts +2 -0
- package/src/platforms/kakaotalk/protocol/session.ts +15 -1
- package/src/platforms/kakaotalk/types.ts +24 -0
- package/src/platforms/line/commands/auth.ts +2 -5
- package/src/platforms/telegram/commands/auth.ts +5 -8
- package/src/platforms/webex/commands/auth.test.ts +178 -14
- package/src/platforms/webex/commands/auth.ts +102 -3
- package/src/platforms/webex/commands/member.test.ts +14 -20
- package/src/platforms/webex/commands/message.test.ts +11 -20
- package/src/platforms/webex/commands/snapshot.test.ts +11 -20
- package/src/platforms/webex/commands/space.test.ts +15 -23
- package/src/platforms/webex/commands/whoami.test.ts +8 -22
- package/src/platforms/webex/credential-manager.test.ts +78 -0
- package/src/platforms/webex/credential-manager.ts +59 -0
- package/src/platforms/whatsapp/commands/auth.ts +2 -5
- package/src/shared/chromium/browsers.ts +1 -1
- package/src/shared/utils/interactive.test.ts +55 -0
- package/src/shared/utils/interactive.ts +15 -0
- package/src/test-setup.ts +5 -0
- 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
|
-
|
|
47
|
+
interface LoginOptions {
|
|
47
48
|
token?: string
|
|
49
|
+
deviceCode?: string
|
|
48
50
|
clientId?: string
|
|
49
51
|
clientSecret?: string
|
|
50
52
|
pretty?: boolean
|
|
51
|
-
}
|
|
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(
|
|
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
|
|
4
|
-
|
|
5
|
-
const mockHandleError = mock((err: Error) => {
|
|
6
|
-
throw err
|
|
7
|
-
})
|
|
3
|
+
import * as errorHandler from '@/shared/utils/error-handler'
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
4
|
-
|
|
5
|
-
const mockHandleError = mock((err: Error) => {
|
|
6
|
-
throw err
|
|
7
|
-
})
|
|
3
|
+
import * as errorHandler from '@/shared/utils/error-handler'
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
4
|
-
|
|
5
|
-
const mockHandleError = mock((err: Error) => {
|
|
6
|
-
throw err
|
|
7
|
-
})
|
|
3
|
+
import * as errorHandler from '@/shared/utils/error-handler'
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
4
|
-
|
|
5
|
-
const mockHandleError = mock((err: Error) => {
|
|
6
|
-
throw err
|
|
7
|
-
})
|
|
3
|
+
import * as errorHandler from '@/shared/utils/error-handler'
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|