agent-messenger 2.0.0 → 2.1.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 (119) hide show
  1. package/.claude-plugin/marketplace.json +14 -1
  2. package/.claude-plugin/plugin.json +4 -2
  3. package/README.md +33 -29
  4. package/dist/package.json +10 -2
  5. package/dist/src/cli.d.ts.map +1 -1
  6. package/dist/src/cli.js +3 -0
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/platforms/webex/app-config.d.ts +7 -0
  9. package/dist/src/platforms/webex/app-config.d.ts.map +1 -0
  10. package/dist/src/platforms/webex/app-config.js +20 -0
  11. package/dist/src/platforms/webex/app-config.js.map +1 -0
  12. package/dist/src/platforms/webex/cli.d.ts +5 -0
  13. package/dist/src/platforms/webex/cli.d.ts.map +1 -0
  14. package/dist/src/platforms/webex/cli.js +32 -0
  15. package/dist/src/platforms/webex/cli.js.map +1 -0
  16. package/dist/src/platforms/webex/client.d.ts +45 -0
  17. package/dist/src/platforms/webex/client.d.ts.map +1 -0
  18. package/dist/src/platforms/webex/client.js +175 -0
  19. package/dist/src/platforms/webex/client.js.map +1 -0
  20. package/dist/src/platforms/webex/commands/auth.d.ts +15 -0
  21. package/dist/src/platforms/webex/commands/auth.d.ts.map +1 -0
  22. package/dist/src/platforms/webex/commands/auth.js +124 -0
  23. package/dist/src/platforms/webex/commands/auth.js.map +1 -0
  24. package/dist/src/platforms/webex/commands/index.d.ts +6 -0
  25. package/dist/src/platforms/webex/commands/index.d.ts.map +1 -0
  26. package/dist/src/platforms/webex/commands/index.js +6 -0
  27. package/dist/src/platforms/webex/commands/index.js.map +1 -0
  28. package/dist/src/platforms/webex/commands/member.d.ts +7 -0
  29. package/dist/src/platforms/webex/commands/member.d.ts.map +1 -0
  30. package/dist/src/platforms/webex/commands/member.js +34 -0
  31. package/dist/src/platforms/webex/commands/member.js.map +1 -0
  32. package/dist/src/platforms/webex/commands/message.d.ts +26 -0
  33. package/dist/src/platforms/webex/commands/message.d.ts.map +1 -0
  34. package/dist/src/platforms/webex/commands/message.js +153 -0
  35. package/dist/src/platforms/webex/commands/message.js.map +1 -0
  36. package/dist/src/platforms/webex/commands/snapshot.d.ts +9 -0
  37. package/dist/src/platforms/webex/commands/snapshot.d.ts.map +1 -0
  38. package/dist/src/platforms/webex/commands/snapshot.js +72 -0
  39. package/dist/src/platforms/webex/commands/snapshot.js.map +1 -0
  40. package/dist/src/platforms/webex/commands/space.d.ts +11 -0
  41. package/dist/src/platforms/webex/commands/space.d.ts.map +1 -0
  42. package/dist/src/platforms/webex/commands/space.js +59 -0
  43. package/dist/src/platforms/webex/commands/space.js.map +1 -0
  44. package/dist/src/platforms/webex/credential-manager.d.ts +23 -0
  45. package/dist/src/platforms/webex/credential-manager.d.ts.map +1 -0
  46. package/dist/src/platforms/webex/credential-manager.js +148 -0
  47. package/dist/src/platforms/webex/credential-manager.js.map +1 -0
  48. package/dist/src/platforms/webex/ensure-auth.d.ts +2 -0
  49. package/dist/src/platforms/webex/ensure-auth.d.ts.map +1 -0
  50. package/dist/src/platforms/webex/ensure-auth.js +20 -0
  51. package/dist/src/platforms/webex/ensure-auth.js.map +1 -0
  52. package/dist/src/platforms/webex/index.d.ts +6 -0
  53. package/dist/src/platforms/webex/index.d.ts.map +1 -0
  54. package/dist/src/platforms/webex/index.js +5 -0
  55. package/dist/src/platforms/webex/index.js.map +1 -0
  56. package/dist/src/platforms/webex/types.d.ts +124 -0
  57. package/dist/src/platforms/webex/types.d.ts.map +1 -0
  58. package/dist/src/platforms/webex/types.js +63 -0
  59. package/dist/src/platforms/webex/types.js.map +1 -0
  60. package/dist/src/tui/adapters/webex-adapter.d.ts +14 -0
  61. package/dist/src/tui/adapters/webex-adapter.d.ts.map +1 -0
  62. package/dist/src/tui/adapters/webex-adapter.js +79 -0
  63. package/dist/src/tui/adapters/webex-adapter.js.map +1 -0
  64. package/dist/src/tui/app.d.ts.map +1 -1
  65. package/dist/src/tui/app.js +2 -0
  66. package/dist/src/tui/app.js.map +1 -1
  67. package/docs/content/docs/cli/meta.json +1 -0
  68. package/docs/content/docs/cli/webex.mdx +291 -0
  69. package/docs/content/docs/sdk/meta.json +1 -1
  70. package/docs/content/docs/sdk/webex.mdx +260 -0
  71. package/docs/content/docs/tui.mdx +4 -3
  72. package/docs/src/app/page.tsx +2 -2
  73. package/package.json +10 -2
  74. package/skills/agent-channeltalk/SKILL.md +1 -1
  75. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  76. package/skills/agent-discord/SKILL.md +1 -1
  77. package/skills/agent-discordbot/SKILL.md +1 -1
  78. package/skills/agent-instagram/SKILL.md +1 -1
  79. package/skills/agent-kakaotalk/SKILL.md +1 -1
  80. package/skills/agent-line/SKILL.md +1 -1
  81. package/skills/agent-slack/SKILL.md +1 -1
  82. package/skills/agent-slackbot/SKILL.md +1 -1
  83. package/skills/agent-teams/SKILL.md +1 -1
  84. package/skills/agent-telegram/SKILL.md +1 -1
  85. package/skills/agent-webex/SKILL.md +386 -0
  86. package/skills/agent-webex/references/authentication.md +318 -0
  87. package/skills/agent-webex/references/common-patterns.md +723 -0
  88. package/skills/agent-webex/templates/monitor-space.sh +165 -0
  89. package/skills/agent-webex/templates/post-message.sh +170 -0
  90. package/skills/agent-whatsapp/SKILL.md +1 -1
  91. package/skills/agent-whatsappbot/SKILL.md +1 -1
  92. package/src/cli.ts +4 -0
  93. package/src/platforms/webex/app-config.test.ts +98 -0
  94. package/src/platforms/webex/app-config.ts +31 -0
  95. package/src/platforms/webex/cli.test.ts +58 -0
  96. package/src/platforms/webex/cli.ts +39 -0
  97. package/src/platforms/webex/client.test.ts +429 -0
  98. package/src/platforms/webex/client.ts +247 -0
  99. package/src/platforms/webex/commands/auth.test.ts +222 -0
  100. package/src/platforms/webex/commands/auth.ts +180 -0
  101. package/src/platforms/webex/commands/index.ts +5 -0
  102. package/src/platforms/webex/commands/member.test.ts +103 -0
  103. package/src/platforms/webex/commands/member.ts +45 -0
  104. package/src/platforms/webex/commands/message.test.ts +231 -0
  105. package/src/platforms/webex/commands/message.ts +204 -0
  106. package/src/platforms/webex/commands/snapshot.test.ts +96 -0
  107. package/src/platforms/webex/commands/snapshot.ts +91 -0
  108. package/src/platforms/webex/commands/space.test.ts +206 -0
  109. package/src/platforms/webex/commands/space.ts +74 -0
  110. package/src/platforms/webex/credential-manager.test.ts +314 -0
  111. package/src/platforms/webex/credential-manager.ts +197 -0
  112. package/src/platforms/webex/ensure-auth.test.ts +85 -0
  113. package/src/platforms/webex/ensure-auth.ts +19 -0
  114. package/src/platforms/webex/index.test.ts +25 -0
  115. package/src/platforms/webex/index.ts +17 -0
  116. package/src/platforms/webex/types.test.ts +307 -0
  117. package/src/platforms/webex/types.ts +127 -0
  118. package/src/tui/adapters/webex-adapter.ts +103 -0
  119. package/src/tui/app.ts +2 -0
@@ -0,0 +1,206 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
2
+
3
+ import { WebexClient } from '../client'
4
+ import { WebexError } from '../types'
5
+ import { infoAction, listAction } from './space'
6
+
7
+ const mockSpaces = [
8
+ {
9
+ id: 'space-1',
10
+ title: 'General',
11
+ type: 'group' as const,
12
+ isLocked: false,
13
+ lastActivity: '2024-01-02T00:00:00.000Z',
14
+ created: '2024-01-01T00:00:00.000Z',
15
+ creatorId: 'person-1',
16
+ },
17
+ {
18
+ id: 'space-2',
19
+ title: 'Direct with Alice',
20
+ type: 'direct' as const,
21
+ isLocked: false,
22
+ lastActivity: '2024-01-03T00:00:00.000Z',
23
+ created: '2024-01-01T00:00:00.000Z',
24
+ creatorId: 'person-2',
25
+ },
26
+ ]
27
+
28
+ const mockSpace = {
29
+ id: 'space-1',
30
+ title: 'General',
31
+ type: 'group' as const,
32
+ isLocked: false,
33
+ teamId: 'team-abc',
34
+ lastActivity: '2024-01-02T00:00:00.000Z',
35
+ created: '2024-01-01T00:00:00.000Z',
36
+ creatorId: 'person-1',
37
+ }
38
+
39
+ let clientLoginSpy: ReturnType<typeof spyOn>
40
+ let clientListSpacesSpy: ReturnType<typeof spyOn>
41
+ let clientGetSpaceSpy: ReturnType<typeof spyOn>
42
+ let consoleLogSpy: ReturnType<typeof spyOn>
43
+ let consoleErrorSpy: ReturnType<typeof spyOn>
44
+ let processExitSpy: ReturnType<typeof spyOn>
45
+
46
+ beforeEach(() => {
47
+ clientLoginSpy = spyOn(WebexClient.prototype, 'login').mockResolvedValue(
48
+ new WebexClient() as InstanceType<typeof WebexClient>,
49
+ )
50
+
51
+ clientListSpacesSpy = spyOn(WebexClient.prototype, 'listSpaces').mockResolvedValue(mockSpaces)
52
+
53
+ clientGetSpaceSpy = spyOn(WebexClient.prototype, 'getSpace').mockResolvedValue(mockSpace)
54
+
55
+ consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {})
56
+ consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
57
+
58
+ processExitSpy = spyOn(process, 'exit').mockImplementation((_code?: number) => {
59
+ throw new Error(`process.exit(${_code})`)
60
+ })
61
+ })
62
+
63
+ afterEach(() => {
64
+ clientLoginSpy?.mockRestore()
65
+ clientListSpacesSpy?.mockRestore()
66
+ clientGetSpaceSpy?.mockRestore()
67
+ consoleLogSpy?.mockRestore()
68
+ consoleErrorSpy?.mockRestore()
69
+ processExitSpy?.mockRestore()
70
+ })
71
+
72
+ describe('listAction', () => {
73
+ test('calls listSpaces and outputs mapped array', async () => {
74
+ await listAction({})
75
+
76
+ expect(clientListSpacesSpy).toHaveBeenCalled()
77
+ expect(consoleLogSpy).toHaveBeenCalledWith(
78
+ JSON.stringify([
79
+ {
80
+ id: 'space-1',
81
+ title: 'General',
82
+ type: 'group',
83
+ lastActivity: '2024-01-02T00:00:00.000Z',
84
+ created: '2024-01-01T00:00:00.000Z',
85
+ },
86
+ {
87
+ id: 'space-2',
88
+ title: 'Direct with Alice',
89
+ type: 'direct',
90
+ lastActivity: '2024-01-03T00:00:00.000Z',
91
+ created: '2024-01-01T00:00:00.000Z',
92
+ },
93
+ ]),
94
+ )
95
+ })
96
+
97
+ test('passes type and limit options to listSpaces', async () => {
98
+ await listAction({ type: 'group', limit: 10 })
99
+
100
+ expect(clientListSpacesSpy).toHaveBeenCalledWith({ type: 'group', max: 10 })
101
+ })
102
+
103
+ test('passes undefined type and limit when not provided', async () => {
104
+ await listAction({})
105
+
106
+ expect(clientListSpacesSpy).toHaveBeenCalledWith({ type: undefined, max: undefined })
107
+ })
108
+
109
+ test('outputs pretty-printed JSON when pretty is true', async () => {
110
+ await listAction({ pretty: true })
111
+
112
+ expect(consoleLogSpy).toHaveBeenCalledWith(
113
+ JSON.stringify(
114
+ [
115
+ {
116
+ id: 'space-1',
117
+ title: 'General',
118
+ type: 'group',
119
+ lastActivity: '2024-01-02T00:00:00.000Z',
120
+ created: '2024-01-01T00:00:00.000Z',
121
+ },
122
+ {
123
+ id: 'space-2',
124
+ title: 'Direct with Alice',
125
+ type: 'direct',
126
+ lastActivity: '2024-01-03T00:00:00.000Z',
127
+ created: '2024-01-01T00:00:00.000Z',
128
+ },
129
+ ],
130
+ null,
131
+ 2,
132
+ ),
133
+ )
134
+ })
135
+
136
+ test('not authenticated: outputs error and exits', async () => {
137
+ clientLoginSpy.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
138
+
139
+ await expect(listAction({})).rejects.toThrow('process.exit(1)')
140
+
141
+ expect(clientListSpacesSpy).not.toHaveBeenCalled()
142
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No Webex credentials found'))
143
+ })
144
+ })
145
+
146
+ describe('infoAction', () => {
147
+ test('calls getSpace with spaceId and outputs space details', async () => {
148
+ await infoAction('space-1', {})
149
+
150
+ expect(clientGetSpaceSpy).toHaveBeenCalledWith('space-1')
151
+ expect(consoleLogSpy).toHaveBeenCalledWith(
152
+ JSON.stringify({
153
+ id: 'space-1',
154
+ title: 'General',
155
+ type: 'group',
156
+ isLocked: false,
157
+ teamId: 'team-abc',
158
+ lastActivity: '2024-01-02T00:00:00.000Z',
159
+ created: '2024-01-01T00:00:00.000Z',
160
+ creatorId: 'person-1',
161
+ }),
162
+ )
163
+ })
164
+
165
+ test('outputs null for teamId when not present', async () => {
166
+ const spaceWithoutTeam = { ...mockSpace, teamId: undefined }
167
+ clientGetSpaceSpy.mockResolvedValue(spaceWithoutTeam)
168
+
169
+ await infoAction('space-1', {})
170
+
171
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string) as {
172
+ teamId: null
173
+ }
174
+ expect(output.teamId).toBeNull()
175
+ })
176
+
177
+ test('outputs pretty-printed JSON when pretty is true', async () => {
178
+ await infoAction('space-1', { pretty: true })
179
+
180
+ expect(consoleLogSpy).toHaveBeenCalledWith(
181
+ JSON.stringify(
182
+ {
183
+ id: 'space-1',
184
+ title: 'General',
185
+ type: 'group',
186
+ isLocked: false,
187
+ teamId: 'team-abc',
188
+ lastActivity: '2024-01-02T00:00:00.000Z',
189
+ created: '2024-01-01T00:00:00.000Z',
190
+ creatorId: 'person-1',
191
+ },
192
+ null,
193
+ 2,
194
+ ),
195
+ )
196
+ })
197
+
198
+ test('not authenticated: outputs error and exits', async () => {
199
+ clientLoginSpy.mockRejectedValue(new WebexError('No Webex credentials found.', 'no_credentials'))
200
+
201
+ await expect(infoAction('space-1', {})).rejects.toThrow('process.exit(1)')
202
+
203
+ expect(clientGetSpaceSpy).not.toHaveBeenCalled()
204
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('No Webex credentials found'))
205
+ })
206
+ })
@@ -0,0 +1,74 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { handleError } from '@/shared/utils/error-handler'
4
+ import { formatOutput } from '@/shared/utils/output'
5
+
6
+ import { WebexClient } from '../client'
7
+
8
+ export async function listAction(options: {
9
+ type?: string
10
+ limit?: number
11
+ pretty?: boolean
12
+ }): Promise<void> {
13
+ try {
14
+ const client = await new WebexClient().login()
15
+ const spaces = await client.listSpaces({ type: options.type, max: options.limit })
16
+ const output = spaces.map((s) => ({
17
+ id: s.id,
18
+ title: s.title,
19
+ type: s.type,
20
+ lastActivity: s.lastActivity,
21
+ created: s.created,
22
+ }))
23
+ console.log(formatOutput(output, options.pretty))
24
+ } catch (error) {
25
+ handleError(error as Error)
26
+ }
27
+ }
28
+
29
+ export async function infoAction(
30
+ spaceId: string,
31
+ options: { pretty?: boolean },
32
+ ): Promise<void> {
33
+ try {
34
+ const client = await new WebexClient().login()
35
+ const space = await client.getSpace(spaceId)
36
+ const output = {
37
+ id: space.id,
38
+ title: space.title,
39
+ type: space.type,
40
+ isLocked: space.isLocked,
41
+ teamId: space.teamId || null,
42
+ lastActivity: space.lastActivity,
43
+ created: space.created,
44
+ creatorId: space.creatorId,
45
+ }
46
+ console.log(formatOutput(output, options.pretty))
47
+ } catch (error) {
48
+ handleError(error as Error)
49
+ }
50
+ }
51
+
52
+ export const spaceCommand = new Command('space')
53
+ .description('Space commands')
54
+ .addCommand(
55
+ new Command('list')
56
+ .description('List spaces')
57
+ .option('--type <type>', 'Filter by type (group or direct)')
58
+ .option('--limit <n>', 'Number of spaces to retrieve', '50')
59
+ .option('--pretty', 'Pretty print JSON output')
60
+ .action((options) =>
61
+ listAction({
62
+ type: options.type,
63
+ limit: parseInt(options.limit, 10),
64
+ pretty: options.pretty,
65
+ }),
66
+ ),
67
+ )
68
+ .addCommand(
69
+ new Command('info')
70
+ .description('Get space details')
71
+ .argument('<space-id>', 'Space ID')
72
+ .option('--pretty', 'Pretty print JSON output')
73
+ .action(infoAction),
74
+ )
@@ -0,0 +1,314 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+
6
+ import { WebexCredentialManager } from './credential-manager'
7
+
8
+ describe('WebexCredentialManager', () => {
9
+ let tempDir: string
10
+ let credManager: WebexCredentialManager
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), 'webex-cred-test-'))
14
+ credManager = new WebexCredentialManager(tempDir)
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await rm(tempDir, { recursive: true, force: true })
19
+ mock.restore()
20
+ })
21
+
22
+ test('loadConfig returns null when no file exists', async () => {
23
+ expect(await credManager.loadConfig()).toBeNull()
24
+ })
25
+
26
+ test('saveConfig + loadConfig round trip with OAuth tokens', async () => {
27
+ const config = {
28
+ accessToken: 'test-access-token',
29
+ refreshToken: 'test-refresh-token',
30
+ expiresAt: Date.now() + 3600000,
31
+ }
32
+ await credManager.saveConfig(config)
33
+ const loaded = await credManager.loadConfig()
34
+ expect(loaded).toEqual(config)
35
+ })
36
+
37
+ test('getToken returns accessToken when not expired', async () => {
38
+ await credManager.saveConfig({
39
+ accessToken: 'valid-token',
40
+ refreshToken: 'refresh',
41
+ expiresAt: Date.now() + 3600000, // 1 hour from now
42
+ })
43
+ const token = await credManager.getToken()
44
+ expect(token).toBe('valid-token')
45
+ })
46
+
47
+ test('getToken returns null when expired and no refresh available', async () => {
48
+ await credManager.saveConfig({
49
+ accessToken: 'expired-token',
50
+ refreshToken: 'bad-refresh',
51
+ expiresAt: Date.now() - 1000, // Already expired
52
+ })
53
+ const token = await credManager.getToken()
54
+ expect(token).toBeNull()
55
+ })
56
+
57
+ test('getToken auto-refreshes expired token', async () => {
58
+ const originalFetch = globalThis.fetch
59
+ globalThis.fetch = mock(() =>
60
+ Promise.resolve(
61
+ new Response(
62
+ JSON.stringify({
63
+ access_token: 'new-access-token',
64
+ refresh_token: 'new-refresh-token',
65
+ expires_in: 3600,
66
+ }),
67
+ { status: 200 },
68
+ ),
69
+ ),
70
+ ) as typeof fetch
71
+
72
+ await credManager.saveConfig({
73
+ accessToken: 'expired-token',
74
+ refreshToken: 'valid-refresh',
75
+ expiresAt: Date.now() - 1000,
76
+ clientId: 'test-client-id',
77
+ clientSecret: 'test-client-secret',
78
+ })
79
+
80
+ const token = await credManager.getToken()
81
+ expect(token).toBe('new-access-token')
82
+
83
+ // Verify updated config was saved
84
+ const config = await credManager.loadConfig()
85
+ expect(config?.accessToken).toBe('new-access-token')
86
+ expect(config?.refreshToken).toBe('new-refresh-token')
87
+
88
+ globalThis.fetch = originalFetch
89
+ })
90
+
91
+ test('requestDeviceCode calls device authorize endpoint', async () => {
92
+ const originalFetch = globalThis.fetch
93
+ globalThis.fetch = mock(() =>
94
+ Promise.resolve(
95
+ new Response(
96
+ JSON.stringify({
97
+ device_code: 'device-123',
98
+ user_code: '123456',
99
+ verification_uri: 'https://login-k.webex.com/verify',
100
+ verification_uri_complete: 'https://login-k.webex.com/verify?userCode=abc',
101
+ expires_in: 300,
102
+ interval: 2,
103
+ }),
104
+ { status: 200 },
105
+ ),
106
+ ),
107
+ ) as typeof fetch
108
+
109
+ const result = await credManager.requestDeviceCode('test-client-id')
110
+ expect(result.deviceCode).toBe('device-123')
111
+ expect(result.userCode).toBe('123456')
112
+ expect(result.verificationUri).toBe('https://login-k.webex.com/verify')
113
+ expect(result.interval).toBe(2)
114
+
115
+ globalThis.fetch = originalFetch
116
+ })
117
+
118
+ test('requestDeviceCode throws on failure', async () => {
119
+ const originalFetch = globalThis.fetch
120
+ globalThis.fetch = mock(() =>
121
+ Promise.resolve(new Response('{"error":"invalid_client"}', { status: 400 })),
122
+ ) as typeof fetch
123
+
124
+ await expect(credManager.requestDeviceCode('test-client-id')).rejects.toThrow('Device authorization failed')
125
+
126
+ globalThis.fetch = originalFetch
127
+ })
128
+
129
+ test('pollDeviceToken polls until authorized', async () => {
130
+ const originalFetch = globalThis.fetch
131
+ let callCount = 0
132
+ globalThis.fetch = mock(() => {
133
+ callCount++
134
+ if (callCount <= 2) {
135
+ return Promise.resolve(new Response('', { status: 428 }))
136
+ }
137
+ return Promise.resolve(
138
+ new Response(
139
+ JSON.stringify({
140
+ access_token: 'device-access-token',
141
+ refresh_token: 'device-refresh-token',
142
+ expires_in: 3600,
143
+ }),
144
+ { status: 200 },
145
+ ),
146
+ )
147
+ }) as typeof fetch
148
+
149
+ const config = await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id', 'test-client-secret')
150
+ expect(config.accessToken).toBe('device-access-token')
151
+ expect(config.refreshToken).toBe('device-refresh-token')
152
+
153
+ globalThis.fetch = originalFetch
154
+ })
155
+
156
+ test('clearCredentials removes the file', async () => {
157
+ await credManager.saveConfig({
158
+ accessToken: 'token',
159
+ refreshToken: 'refresh',
160
+ expiresAt: Date.now() + 3600000,
161
+ })
162
+ await credManager.clearCredentials()
163
+ expect(await credManager.loadConfig()).toBeNull()
164
+ })
165
+
166
+ test('clearCredentials does nothing when no file', async () => {
167
+ await credManager.clearCredentials() // Should not throw
168
+ })
169
+
170
+ test('credentials file has 0o600 permissions', async () => {
171
+ await credManager.saveConfig({
172
+ accessToken: 'token',
173
+ refreshToken: 'refresh',
174
+ expiresAt: Date.now() + 3600000,
175
+ })
176
+ const { stat } = await import('node:fs/promises')
177
+ const credPath = join(tempDir, 'webex-credentials.json')
178
+ const stats = await stat(credPath)
179
+ const mode = stats.mode & 0o777
180
+ expect(mode).toBe(0o600)
181
+ })
182
+
183
+ test('pollDeviceToken with undefined clientSecret uses empty Basic auth', async () => {
184
+ const originalFetch = globalThis.fetch
185
+ let capturedAuth: string | null = null
186
+ globalThis.fetch = mock((url: string, init?: RequestInit) => {
187
+ capturedAuth = (init?.headers as Record<string, string>)?.Authorization ?? null
188
+ return Promise.resolve(
189
+ new Response(
190
+ JSON.stringify({
191
+ access_token: 'token',
192
+ refresh_token: 'refresh',
193
+ expires_in: 3600,
194
+ }),
195
+ { status: 200 },
196
+ ),
197
+ )
198
+ }) as typeof fetch
199
+
200
+ await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id')
201
+ expect(capturedAuth).toBe(`Basic ${btoa('test-client-id:')}`)
202
+
203
+ globalThis.fetch = originalFetch
204
+ })
205
+
206
+ test('pollDeviceToken does not auto-save config', async () => {
207
+ const originalFetch = globalThis.fetch
208
+ globalThis.fetch = mock(() =>
209
+ Promise.resolve(
210
+ new Response(
211
+ JSON.stringify({
212
+ access_token: 'token',
213
+ refresh_token: 'refresh',
214
+ expires_in: 3600,
215
+ }),
216
+ { status: 200 },
217
+ ),
218
+ ),
219
+ ) as typeof fetch
220
+
221
+ await credManager.pollDeviceToken('device-123', 0.01, 30, 'test-client-id', 'test-client-secret')
222
+
223
+ const loaded = await credManager.loadConfig()
224
+ expect(loaded).toBeNull()
225
+
226
+ globalThis.fetch = originalFetch
227
+ })
228
+
229
+ test('getToken returns null when expired and no client credentials available', async () => {
230
+ await credManager.saveConfig({
231
+ accessToken: 'expired-token',
232
+ refreshToken: 'valid-refresh',
233
+ expiresAt: Date.now() - 1000,
234
+ })
235
+
236
+ const token = await credManager.getToken()
237
+ expect(token).toBeNull()
238
+ })
239
+
240
+ test('getToken returns manual token without attempting refresh', async () => {
241
+ await credManager.saveConfig({
242
+ accessToken: 'my-bot-token',
243
+ refreshToken: '',
244
+ expiresAt: 0,
245
+ tokenType: 'manual',
246
+ })
247
+
248
+ const token = await credManager.getToken()
249
+ expect(token).toBe('my-bot-token')
250
+ })
251
+
252
+ test('getToken uses stored clientId/clientSecret for refresh', async () => {
253
+ const originalFetch = globalThis.fetch
254
+ globalThis.fetch = mock(() =>
255
+ Promise.resolve(
256
+ new Response(
257
+ JSON.stringify({
258
+ access_token: 'refreshed-token',
259
+ refresh_token: 'new-refresh',
260
+ expires_in: 3600,
261
+ }),
262
+ { status: 200 },
263
+ ),
264
+ ),
265
+ ) as typeof fetch
266
+
267
+ await credManager.saveConfig({
268
+ accessToken: 'expired-token',
269
+ refreshToken: 'valid-refresh',
270
+ expiresAt: Date.now() - 1000,
271
+ clientId: 'stored-client-id',
272
+ clientSecret: 'stored-client-secret',
273
+ })
274
+
275
+ const token = await credManager.getToken()
276
+ expect(token).toBe('refreshed-token')
277
+
278
+ globalThis.fetch = originalFetch
279
+ })
280
+
281
+ test('saveConfig persists clientId and clientSecret', async () => {
282
+ await credManager.saveConfig({
283
+ accessToken: 'token',
284
+ refreshToken: 'refresh',
285
+ expiresAt: Date.now() + 3600000,
286
+ clientId: 'my-client-id',
287
+ clientSecret: 'my-client-secret',
288
+ })
289
+
290
+ const loaded = await credManager.loadConfig()
291
+ expect(loaded?.clientId).toBe('my-client-id')
292
+ expect(loaded?.clientSecret).toBe('my-client-secret')
293
+ })
294
+
295
+ test('loadConfig backward compat — old config without clientId/clientSecret', async () => {
296
+ // Write raw JSON without clientId/clientSecret fields
297
+ const credPath = join(tempDir, 'webex-credentials.json')
298
+ await writeFile(
299
+ credPath,
300
+ JSON.stringify({
301
+ accessToken: 'old-token',
302
+ refreshToken: 'old-refresh',
303
+ expiresAt: Date.now() + 3600000,
304
+ }),
305
+ 'utf-8',
306
+ )
307
+
308
+ const loaded = await credManager.loadConfig()
309
+ expect(loaded).not.toBeNull()
310
+ expect(loaded?.accessToken).toBe('old-token')
311
+ expect(loaded?.clientId).toBeUndefined()
312
+ expect(loaded?.clientSecret).toBeUndefined()
313
+ })
314
+ })