agent-messenger 2.17.0 → 2.18.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/dist/package.json +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/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/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/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/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 +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +1 -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/instagram/commands/auth.ts +1 -4
- package/src/platforms/kakaotalk/commands/auth.ts +2 -18
- 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 +154 -0
- package/src/platforms/webex/commands/auth.ts +102 -3
- 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/utils/interactive.test.ts +55 -0
- package/src/shared/utils/interactive.ts +15 -0
|
@@ -12,6 +12,8 @@ describe('auth commands', () => {
|
|
|
12
12
|
let consoleErrorSpy: ReturnType<typeof spyOn>
|
|
13
13
|
let execSpy: ReturnType<typeof spyOn>
|
|
14
14
|
const protoSpies: ReturnType<typeof spyOn>[] = []
|
|
15
|
+
let originalStdinTTY: boolean | undefined
|
|
16
|
+
let originalStdoutTTY: boolean | undefined
|
|
15
17
|
const mockPerson = {
|
|
16
18
|
id: 'person-1',
|
|
17
19
|
displayName: 'Test User',
|
|
@@ -27,10 +29,19 @@ describe('auth commands', () => {
|
|
|
27
29
|
return s
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function setTTY(value: boolean | undefined): void {
|
|
33
|
+
Object.defineProperty(process.stdin, 'isTTY', { value, writable: true, configurable: true })
|
|
34
|
+
Object.defineProperty(process.stdout, 'isTTY', { value, writable: true, configurable: true })
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
beforeEach(() => {
|
|
31
38
|
consoleSpy = spyOn(console, 'log').mockImplementation(() => {})
|
|
32
39
|
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
|
33
40
|
execSpy = spyOn(childProcess, 'exec').mockImplementation((() => {}) as any)
|
|
41
|
+
originalStdinTTY = process.stdin.isTTY
|
|
42
|
+
originalStdoutTTY = process.stdout.isTTY
|
|
43
|
+
// Default to interactive TTY for existing tests; non-interactive tests override.
|
|
44
|
+
setTTY(true)
|
|
34
45
|
})
|
|
35
46
|
|
|
36
47
|
afterEach(() => {
|
|
@@ -39,6 +50,12 @@ describe('auth commands', () => {
|
|
|
39
50
|
execSpy.mockRestore()
|
|
40
51
|
for (const s of protoSpies) s.mockRestore()
|
|
41
52
|
protoSpies.length = 0
|
|
53
|
+
setTTY(originalStdinTTY)
|
|
54
|
+
Object.defineProperty(process.stdout, 'isTTY', {
|
|
55
|
+
value: originalStdoutTTY,
|
|
56
|
+
writable: true,
|
|
57
|
+
configurable: true,
|
|
58
|
+
})
|
|
42
59
|
})
|
|
43
60
|
|
|
44
61
|
describe('loginAction with --token', () => {
|
|
@@ -151,6 +168,143 @@ describe('auth commands', () => {
|
|
|
151
168
|
})
|
|
152
169
|
})
|
|
153
170
|
|
|
171
|
+
describe('loginAction non-interactive (no TTY)', () => {
|
|
172
|
+
const device = {
|
|
173
|
+
deviceCode: 'webex-device-code-abc123',
|
|
174
|
+
userCode: 'USER-CODE',
|
|
175
|
+
verificationUri: 'https://webex.com/verify',
|
|
176
|
+
verificationUriComplete: 'https://webex.com/verify?user_code=USER-CODE',
|
|
177
|
+
expiresIn: 300,
|
|
178
|
+
interval: 1,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it('first call (no --device-code): requests device code and returns it in JSON', async () => {
|
|
182
|
+
setTTY(false)
|
|
183
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
|
|
184
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode')
|
|
185
|
+
const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
|
|
186
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
187
|
+
|
|
188
|
+
await loginAction({ pretty: false })
|
|
189
|
+
|
|
190
|
+
expect(requestSpy).toHaveBeenCalled()
|
|
191
|
+
expect(exchangeSpy).not.toHaveBeenCalled()
|
|
192
|
+
expect(pollSpy).not.toHaveBeenCalled()
|
|
193
|
+
|
|
194
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
195
|
+
const output = JSON.parse(lastCall)
|
|
196
|
+
expect(output.next_action).toBe('authorize_in_browser')
|
|
197
|
+
expect(output.verification_uri).toBe(device.verificationUri)
|
|
198
|
+
expect(output.verification_uri_complete).toBe(device.verificationUriComplete)
|
|
199
|
+
expect(output.user_code).toBe(device.userCode)
|
|
200
|
+
expect(output.device_code).toBe(device.deviceCode)
|
|
201
|
+
expect(output.message).toContain('--device-code')
|
|
202
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('does not open a browser on the first non-interactive call', async () => {
|
|
206
|
+
setTTY(false)
|
|
207
|
+
protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode').mockResolvedValue(device)
|
|
208
|
+
protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
209
|
+
|
|
210
|
+
await loginAction({ pretty: false })
|
|
211
|
+
|
|
212
|
+
expect(execSpy).not.toHaveBeenCalled()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('second call (--device-code, pending): returns still_pending and echoes back the device_code', async () => {
|
|
216
|
+
setTTY(false)
|
|
217
|
+
protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'pending' })
|
|
218
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
|
|
219
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
220
|
+
|
|
221
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
222
|
+
|
|
223
|
+
expect(requestSpy).not.toHaveBeenCalled()
|
|
224
|
+
|
|
225
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
226
|
+
const output = JSON.parse(lastCall)
|
|
227
|
+
expect(output.next_action).toBe('still_pending')
|
|
228
|
+
expect(output.device_code).toBe('webex-device-code-abc123')
|
|
229
|
+
expect(exitSpy).toHaveBeenCalledWith(0)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('second call (--device-code, success): saves token and returns authenticated=true', async () => {
|
|
233
|
+
setTTY(false)
|
|
234
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({
|
|
235
|
+
status: 'success',
|
|
236
|
+
config: { accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000 },
|
|
237
|
+
})
|
|
238
|
+
const saveConfigSpy = protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
239
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
240
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
241
|
+
|
|
242
|
+
await loginAction({
|
|
243
|
+
deviceCode: 'webex-device-code-abc123',
|
|
244
|
+
clientId: 'my-id',
|
|
245
|
+
clientSecret: 'my-secret',
|
|
246
|
+
pretty: false,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(exchangeSpy).toHaveBeenCalledWith('webex-device-code-abc123', 'my-id', 'my-secret')
|
|
250
|
+
const savedConfig = saveConfigSpy.mock.calls[0][0] as { tokenType: string; clientId: string }
|
|
251
|
+
expect(savedConfig.tokenType).toBe('oauth')
|
|
252
|
+
expect(savedConfig.clientId).toBe('my-id')
|
|
253
|
+
|
|
254
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
255
|
+
const output = JSON.parse(lastCall)
|
|
256
|
+
expect(output.authenticated).toBe(true)
|
|
257
|
+
expect(output.user.displayName).toBe('Test User')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('second call (--device-code, expired): returns next_action=restart, exits 1', async () => {
|
|
261
|
+
setTTY(false)
|
|
262
|
+
protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({ status: 'expired' })
|
|
263
|
+
const exitSpy = protoSpy(process, 'exit').mockImplementation(() => undefined as never)
|
|
264
|
+
|
|
265
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
266
|
+
|
|
267
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
268
|
+
const output = JSON.parse(lastCall)
|
|
269
|
+
expect(output.next_action).toBe('restart')
|
|
270
|
+
expect(output.error).toContain('expired')
|
|
271
|
+
expect(exitSpy).toHaveBeenCalledWith(1)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('--device-code works even with a TTY (interactive sessions can also resume)', async () => {
|
|
275
|
+
setTTY(true)
|
|
276
|
+
const exchangeSpy = protoSpy(WebexCredentialManager.prototype, 'exchangeDeviceCode').mockResolvedValue({
|
|
277
|
+
status: 'success',
|
|
278
|
+
config: { accessToken: 'at', refreshToken: 'rt', expiresAt: Date.now() + 3_600_000 },
|
|
279
|
+
})
|
|
280
|
+
const requestSpy = protoSpy(WebexCredentialManager.prototype, 'requestDeviceCode')
|
|
281
|
+
const pollSpy = protoSpy(WebexCredentialManager.prototype, 'pollDeviceToken')
|
|
282
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
283
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
284
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
285
|
+
|
|
286
|
+
await loginAction({ deviceCode: 'webex-device-code-abc123', pretty: false })
|
|
287
|
+
|
|
288
|
+
expect(exchangeSpy).toHaveBeenCalled()
|
|
289
|
+
expect(requestSpy).not.toHaveBeenCalled()
|
|
290
|
+
expect(pollSpy).not.toHaveBeenCalled()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('still allows --token login when non-interactive (bot/PAT path)', async () => {
|
|
294
|
+
setTTY(false)
|
|
295
|
+
protoSpy(WebexClient.prototype, 'login').mockResolvedValue(new WebexClient())
|
|
296
|
+
protoSpy(WebexClient.prototype, 'testAuth').mockResolvedValue(mockPerson)
|
|
297
|
+
protoSpy(WebexCredentialManager.prototype, 'saveConfig').mockResolvedValue(undefined)
|
|
298
|
+
|
|
299
|
+
await loginAction({ token: 'bot-token-123', pretty: false })
|
|
300
|
+
|
|
301
|
+
const lastCall = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0] as string
|
|
302
|
+
const output = JSON.parse(lastCall)
|
|
303
|
+
expect(output.authenticated).toBe(true)
|
|
304
|
+
expect(output.user.displayName).toBe('Test User')
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
154
308
|
describe('statusAction', () => {
|
|
155
309
|
it('shows authenticated status when token is valid', async () => {
|
|
156
310
|
protoSpy(WebexCredentialManager.prototype, 'loadConfig').mockResolvedValue(null)
|
|
@@ -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')
|
|
@@ -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)
|
|
@@ -3,6 +3,7 @@ import { rm } from 'node:fs/promises'
|
|
|
3
3
|
import { Command } from 'commander'
|
|
4
4
|
|
|
5
5
|
import { handleError } from '@/shared/utils/error-handler'
|
|
6
|
+
import { isInteractive } from '@/shared/utils/interactive'
|
|
6
7
|
import { formatOutput } from '@/shared/utils/output'
|
|
7
8
|
import { displayQR } from '@/shared/utils/qr'
|
|
8
9
|
import { info } from '@/shared/utils/stderr'
|
|
@@ -22,10 +23,6 @@ interface StatusOptions {
|
|
|
22
23
|
pretty?: boolean
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
function isInteractiveSession(): boolean {
|
|
26
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
26
|
async function loginWithPairingCode(options: LoginOptions & { phone: string }): Promise<void> {
|
|
30
27
|
const manager = new WhatsAppCredentialManager()
|
|
31
28
|
const accountId = createAccountId(options.phone)
|
|
@@ -95,7 +92,7 @@ async function loginWithQR(options: LoginOptions): Promise<void> {
|
|
|
95
92
|
await rm(existingPaths.auth_dir, { recursive: true, force: true })
|
|
96
93
|
const paths = await manager.ensureAccountPaths(accountId)
|
|
97
94
|
const client = await new WhatsAppClient().login({ authDir: paths.auth_dir })
|
|
98
|
-
const interactive =
|
|
95
|
+
const interactive = isInteractive()
|
|
99
96
|
|
|
100
97
|
let waitForAuth: () => Promise<void>
|
|
101
98
|
let browserOpened = false
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { hasTTY, isInteractive } from './interactive'
|
|
4
|
+
|
|
5
|
+
describe('isInteractive', () => {
|
|
6
|
+
let originalStdinTTY: boolean | undefined
|
|
7
|
+
let originalStdoutTTY: boolean | undefined
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
originalStdinTTY = process.stdin.isTTY
|
|
11
|
+
originalStdoutTTY = process.stdout.isTTY
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinTTY, writable: true, configurable: true })
|
|
16
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutTTY, writable: true, configurable: true })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
function setTTY(stdin: boolean | undefined, stdout: boolean | undefined): void {
|
|
20
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: stdin, writable: true, configurable: true })
|
|
21
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: stdout, writable: true, configurable: true })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
it('returns true when both stdin and stdout are TTY', () => {
|
|
25
|
+
setTTY(true, true)
|
|
26
|
+
expect(isInteractive()).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('returns false when stdin is not a TTY (piped input)', () => {
|
|
30
|
+
setTTY(undefined, true)
|
|
31
|
+
expect(isInteractive()).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('returns false when stdout is not a TTY (piped output)', () => {
|
|
35
|
+
setTTY(true, undefined)
|
|
36
|
+
expect(isInteractive()).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('returns false when neither is a TTY', () => {
|
|
40
|
+
setTTY(undefined, undefined)
|
|
41
|
+
expect(isInteractive()).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns false when stdin/stdout isTTY is explicitly false', () => {
|
|
45
|
+
setTTY(false, false)
|
|
46
|
+
expect(isInteractive()).toBe(false)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('hasTTY', () => {
|
|
51
|
+
it('returns a boolean reflecting whether a controlling TTY can be opened', () => {
|
|
52
|
+
const result = hasTTY()
|
|
53
|
+
expect(typeof result).toBe('boolean')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function isInteractive(): boolean {
|
|
2
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function hasTTY(): boolean {
|
|
6
|
+
try {
|
|
7
|
+
const { openSync, closeSync } = require('node:fs') as typeof import('node:fs')
|
|
8
|
+
const ttyDevice = process.platform === 'win32' ? 'CONIN$' : '/dev/tty'
|
|
9
|
+
const fd = openSync(ttyDevice, 'r')
|
|
10
|
+
closeSync(fd)
|
|
11
|
+
return true
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|