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.
Files changed (57) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/instagram/commands/auth.d.ts.map +1 -1
  4. package/dist/src/platforms/instagram/commands/auth.js +1 -3
  5. package/dist/src/platforms/instagram/commands/auth.js.map +1 -1
  6. package/dist/src/platforms/kakaotalk/commands/auth.d.ts.map +1 -1
  7. package/dist/src/platforms/kakaotalk/commands/auth.js +2 -17
  8. package/dist/src/platforms/kakaotalk/commands/auth.js.map +1 -1
  9. package/dist/src/platforms/line/commands/auth.d.ts.map +1 -1
  10. package/dist/src/platforms/line/commands/auth.js +2 -4
  11. package/dist/src/platforms/line/commands/auth.js.map +1 -1
  12. package/dist/src/platforms/telegram/commands/auth.d.ts.map +1 -1
  13. package/dist/src/platforms/telegram/commands/auth.js +5 -7
  14. package/dist/src/platforms/telegram/commands/auth.js.map +1 -1
  15. package/dist/src/platforms/webex/commands/auth.d.ts +5 -2
  16. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -1
  17. package/dist/src/platforms/webex/commands/auth.js +59 -1
  18. package/dist/src/platforms/webex/commands/auth.js.map +1 -1
  19. package/dist/src/platforms/webex/credential-manager.d.ts +11 -0
  20. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -1
  21. package/dist/src/platforms/webex/credential-manager.js +37 -0
  22. package/dist/src/platforms/webex/credential-manager.js.map +1 -1
  23. package/dist/src/platforms/whatsapp/commands/auth.d.ts.map +1 -1
  24. package/dist/src/platforms/whatsapp/commands/auth.js +2 -4
  25. package/dist/src/platforms/whatsapp/commands/auth.js.map +1 -1
  26. package/dist/src/shared/utils/interactive.d.ts +3 -0
  27. package/dist/src/shared/utils/interactive.d.ts.map +1 -0
  28. package/dist/src/shared/utils/interactive.js +16 -0
  29. package/dist/src/shared/utils/interactive.js.map +1 -0
  30. package/package.json +1 -1
  31. package/skills/agent-channeltalk/SKILL.md +1 -1
  32. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  33. package/skills/agent-discord/SKILL.md +1 -1
  34. package/skills/agent-discordbot/SKILL.md +1 -1
  35. package/skills/agent-instagram/SKILL.md +1 -1
  36. package/skills/agent-kakaotalk/SKILL.md +1 -1
  37. package/skills/agent-line/SKILL.md +1 -1
  38. package/skills/agent-slack/SKILL.md +1 -1
  39. package/skills/agent-slackbot/SKILL.md +1 -1
  40. package/skills/agent-teams/SKILL.md +1 -1
  41. package/skills/agent-telegram/SKILL.md +1 -1
  42. package/skills/agent-telegrambot/SKILL.md +1 -1
  43. package/skills/agent-webex/SKILL.md +32 -1
  44. package/skills/agent-wechatbot/SKILL.md +1 -1
  45. package/skills/agent-whatsapp/SKILL.md +1 -1
  46. package/skills/agent-whatsappbot/SKILL.md +1 -1
  47. package/src/platforms/instagram/commands/auth.ts +1 -4
  48. package/src/platforms/kakaotalk/commands/auth.ts +2 -18
  49. package/src/platforms/line/commands/auth.ts +2 -5
  50. package/src/platforms/telegram/commands/auth.ts +5 -8
  51. package/src/platforms/webex/commands/auth.test.ts +154 -0
  52. package/src/platforms/webex/commands/auth.ts +102 -3
  53. package/src/platforms/webex/credential-manager.test.ts +78 -0
  54. package/src/platforms/webex/credential-manager.ts +59 -0
  55. package/src/platforms/whatsapp/commands/auth.ts +2 -5
  56. package/src/shared/utils/interactive.test.ts +55 -0
  57. 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
- 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')
@@ -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 = isInteractiveSession()
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
+ }