resend-cli 1.1.0 → 1.2.1

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 (198) hide show
  1. package/.claude/settings.local.json +1 -10
  2. package/.claude/worktrees/emails-list/.claude/settings.local.json +5 -0
  3. package/.claude/worktrees/emails-list/.github/scripts/pr-title-check.js +34 -0
  4. package/.claude/worktrees/emails-list/.github/workflows/ci.yml +32 -0
  5. package/.claude/worktrees/emails-list/.github/workflows/pr-title-check.yml +13 -0
  6. package/.claude/worktrees/emails-list/.github/workflows/release.yml +93 -0
  7. package/.claude/worktrees/emails-list/CHANGELOG.md +31 -0
  8. package/.claude/worktrees/emails-list/LICENSE +21 -0
  9. package/.claude/worktrees/emails-list/README.md +424 -0
  10. package/.claude/worktrees/emails-list/biome.json +36 -0
  11. package/.claude/worktrees/emails-list/bun.lock +76 -0
  12. package/.claude/worktrees/emails-list/bunfig.toml +2 -0
  13. package/.claude/worktrees/emails-list/install.ps1 +140 -0
  14. package/.claude/worktrees/emails-list/install.sh +301 -0
  15. package/.claude/worktrees/emails-list/package.json +43 -0
  16. package/.claude/worktrees/emails-list/renovate.json +6 -0
  17. package/.claude/worktrees/emails-list/src/cli.ts +74 -0
  18. package/.claude/worktrees/emails-list/src/commands/api-keys/create.ts +114 -0
  19. package/.claude/worktrees/emails-list/src/commands/api-keys/delete.ts +47 -0
  20. package/.claude/worktrees/emails-list/src/commands/api-keys/index.ts +26 -0
  21. package/.claude/worktrees/emails-list/src/commands/api-keys/list.ts +35 -0
  22. package/.claude/worktrees/emails-list/src/commands/api-keys/utils.ts +8 -0
  23. package/.claude/worktrees/emails-list/src/commands/auth/index.ts +20 -0
  24. package/.claude/worktrees/emails-list/src/commands/auth/login.ts +207 -0
  25. package/.claude/worktrees/emails-list/src/commands/auth/logout.ts +105 -0
  26. package/.claude/worktrees/emails-list/src/commands/broadcasts/create.ts +196 -0
  27. package/.claude/worktrees/emails-list/src/commands/broadcasts/delete.ts +46 -0
  28. package/.claude/worktrees/emails-list/src/commands/broadcasts/get.ts +59 -0
  29. package/.claude/worktrees/emails-list/src/commands/broadcasts/index.ts +43 -0
  30. package/.claude/worktrees/emails-list/src/commands/broadcasts/list.ts +60 -0
  31. package/.claude/worktrees/emails-list/src/commands/broadcasts/send.ts +56 -0
  32. package/.claude/worktrees/emails-list/src/commands/broadcasts/update.ts +95 -0
  33. package/.claude/worktrees/emails-list/src/commands/broadcasts/utils.ts +35 -0
  34. package/.claude/worktrees/emails-list/src/commands/contact-properties/create.ts +118 -0
  35. package/.claude/worktrees/emails-list/src/commands/contact-properties/delete.ts +48 -0
  36. package/.claude/worktrees/emails-list/src/commands/contact-properties/get.ts +46 -0
  37. package/.claude/worktrees/emails-list/src/commands/contact-properties/index.ts +48 -0
  38. package/.claude/worktrees/emails-list/src/commands/contact-properties/list.ts +68 -0
  39. package/.claude/worktrees/emails-list/src/commands/contact-properties/update.ts +88 -0
  40. package/.claude/worktrees/emails-list/src/commands/contact-properties/utils.ts +17 -0
  41. package/.claude/worktrees/emails-list/src/commands/contacts/add-segment.ts +78 -0
  42. package/.claude/worktrees/emails-list/src/commands/contacts/create.ts +122 -0
  43. package/.claude/worktrees/emails-list/src/commands/contacts/delete.ts +49 -0
  44. package/.claude/worktrees/emails-list/src/commands/contacts/get.ts +53 -0
  45. package/.claude/worktrees/emails-list/src/commands/contacts/index.ts +58 -0
  46. package/.claude/worktrees/emails-list/src/commands/contacts/list.ts +57 -0
  47. package/.claude/worktrees/emails-list/src/commands/contacts/remove-segment.ts +48 -0
  48. package/.claude/worktrees/emails-list/src/commands/contacts/segments.ts +39 -0
  49. package/.claude/worktrees/emails-list/src/commands/contacts/topics.ts +45 -0
  50. package/.claude/worktrees/emails-list/src/commands/contacts/update-topics.ts +90 -0
  51. package/.claude/worktrees/emails-list/src/commands/contacts/update.ts +77 -0
  52. package/.claude/worktrees/emails-list/src/commands/contacts/utils.ts +119 -0
  53. package/.claude/worktrees/emails-list/src/commands/doctor.ts +298 -0
  54. package/.claude/worktrees/emails-list/src/commands/domains/create.ts +83 -0
  55. package/.claude/worktrees/emails-list/src/commands/domains/delete.ts +42 -0
  56. package/.claude/worktrees/emails-list/src/commands/domains/get.ts +47 -0
  57. package/.claude/worktrees/emails-list/src/commands/domains/index.ts +35 -0
  58. package/.claude/worktrees/emails-list/src/commands/domains/list.ts +53 -0
  59. package/.claude/worktrees/emails-list/src/commands/domains/update.ts +75 -0
  60. package/.claude/worktrees/emails-list/src/commands/domains/utils.ts +44 -0
  61. package/.claude/worktrees/emails-list/src/commands/domains/verify.ts +38 -0
  62. package/.claude/worktrees/emails-list/src/commands/emails/batch.ts +140 -0
  63. package/.claude/worktrees/emails-list/src/commands/emails/index.ts +28 -0
  64. package/.claude/worktrees/emails-list/src/commands/emails/list.ts +73 -0
  65. package/.claude/worktrees/emails-list/src/commands/emails/receiving/attachment.ts +55 -0
  66. package/.claude/worktrees/emails-list/src/commands/emails/receiving/attachments.ts +68 -0
  67. package/.claude/worktrees/emails-list/src/commands/emails/receiving/get.ts +58 -0
  68. package/.claude/worktrees/emails-list/src/commands/emails/receiving/index.ts +28 -0
  69. package/.claude/worktrees/emails-list/src/commands/emails/receiving/list.ts +59 -0
  70. package/.claude/worktrees/emails-list/src/commands/emails/receiving/utils.ts +38 -0
  71. package/.claude/worktrees/emails-list/src/commands/emails/send.ts +189 -0
  72. package/.claude/worktrees/emails-list/src/commands/open.ts +24 -0
  73. package/.claude/worktrees/emails-list/src/commands/segments/create.ts +50 -0
  74. package/.claude/worktrees/emails-list/src/commands/segments/delete.ts +47 -0
  75. package/.claude/worktrees/emails-list/src/commands/segments/get.ts +38 -0
  76. package/.claude/worktrees/emails-list/src/commands/segments/index.ts +36 -0
  77. package/.claude/worktrees/emails-list/src/commands/segments/list.ts +58 -0
  78. package/.claude/worktrees/emails-list/src/commands/segments/utils.ts +7 -0
  79. package/.claude/worktrees/emails-list/src/commands/teams/index.ts +10 -0
  80. package/.claude/worktrees/emails-list/src/commands/teams/list.ts +35 -0
  81. package/.claude/worktrees/emails-list/src/commands/teams/remove.ts +83 -0
  82. package/.claude/worktrees/emails-list/src/commands/teams/switch.ts +73 -0
  83. package/.claude/worktrees/emails-list/src/commands/topics/create.ts +73 -0
  84. package/.claude/worktrees/emails-list/src/commands/topics/delete.ts +47 -0
  85. package/.claude/worktrees/emails-list/src/commands/topics/get.ts +42 -0
  86. package/.claude/worktrees/emails-list/src/commands/topics/index.ts +42 -0
  87. package/.claude/worktrees/emails-list/src/commands/topics/list.ts +34 -0
  88. package/.claude/worktrees/emails-list/src/commands/topics/update.ts +59 -0
  89. package/.claude/worktrees/emails-list/src/commands/topics/utils.ts +16 -0
  90. package/.claude/worktrees/emails-list/src/commands/webhooks/create.ts +128 -0
  91. package/.claude/worktrees/emails-list/src/commands/webhooks/delete.ts +49 -0
  92. package/.claude/worktrees/emails-list/src/commands/webhooks/get.ts +42 -0
  93. package/.claude/worktrees/emails-list/src/commands/webhooks/index.ts +44 -0
  94. package/.claude/worktrees/emails-list/src/commands/webhooks/list.ts +55 -0
  95. package/.claude/worktrees/emails-list/src/commands/webhooks/update.ts +83 -0
  96. package/.claude/worktrees/emails-list/src/commands/webhooks/utils.ts +36 -0
  97. package/.claude/worktrees/emails-list/src/commands/whoami.ts +71 -0
  98. package/.claude/worktrees/emails-list/src/lib/actions.ts +157 -0
  99. package/.claude/worktrees/emails-list/src/lib/client.ts +34 -0
  100. package/.claude/worktrees/emails-list/src/lib/config.ts +211 -0
  101. package/.claude/worktrees/emails-list/src/lib/files.ts +15 -0
  102. package/.claude/worktrees/emails-list/src/lib/help-text.ts +38 -0
  103. package/.claude/worktrees/emails-list/src/lib/output.ts +54 -0
  104. package/.claude/worktrees/emails-list/src/lib/pagination.ts +36 -0
  105. package/.claude/worktrees/emails-list/src/lib/prompts.ts +149 -0
  106. package/.claude/worktrees/emails-list/src/lib/spinner.ts +93 -0
  107. package/.claude/worktrees/emails-list/src/lib/table.ts +57 -0
  108. package/.claude/worktrees/emails-list/src/lib/tty.ts +28 -0
  109. package/.claude/worktrees/emails-list/src/lib/version.ts +4 -0
  110. package/.claude/worktrees/emails-list/tests/commands/api-keys/create.test.ts +195 -0
  111. package/.claude/worktrees/emails-list/tests/commands/api-keys/delete.test.ts +156 -0
  112. package/.claude/worktrees/emails-list/tests/commands/api-keys/list.test.ts +133 -0
  113. package/.claude/worktrees/emails-list/tests/commands/auth/login.test.ts +119 -0
  114. package/.claude/worktrees/emails-list/tests/commands/auth/logout.test.ts +146 -0
  115. package/.claude/worktrees/emails-list/tests/commands/broadcasts/create.test.ts +447 -0
  116. package/.claude/worktrees/emails-list/tests/commands/broadcasts/delete.test.ts +182 -0
  117. package/.claude/worktrees/emails-list/tests/commands/broadcasts/get.test.ts +146 -0
  118. package/.claude/worktrees/emails-list/tests/commands/broadcasts/list.test.ts +196 -0
  119. package/.claude/worktrees/emails-list/tests/commands/broadcasts/send.test.ts +161 -0
  120. package/.claude/worktrees/emails-list/tests/commands/broadcasts/update.test.ts +283 -0
  121. package/.claude/worktrees/emails-list/tests/commands/contact-properties/create.test.ts +250 -0
  122. package/.claude/worktrees/emails-list/tests/commands/contact-properties/delete.test.ts +183 -0
  123. package/.claude/worktrees/emails-list/tests/commands/contact-properties/get.test.ts +144 -0
  124. package/.claude/worktrees/emails-list/tests/commands/contact-properties/list.test.ts +180 -0
  125. package/.claude/worktrees/emails-list/tests/commands/contact-properties/update.test.ts +216 -0
  126. package/.claude/worktrees/emails-list/tests/commands/contacts/add-segment.test.ts +188 -0
  127. package/.claude/worktrees/emails-list/tests/commands/contacts/create.test.ts +270 -0
  128. package/.claude/worktrees/emails-list/tests/commands/contacts/delete.test.ts +192 -0
  129. package/.claude/worktrees/emails-list/tests/commands/contacts/get.test.ts +148 -0
  130. package/.claude/worktrees/emails-list/tests/commands/contacts/list.test.ts +175 -0
  131. package/.claude/worktrees/emails-list/tests/commands/contacts/remove-segment.test.ts +166 -0
  132. package/.claude/worktrees/emails-list/tests/commands/contacts/segments.test.ts +167 -0
  133. package/.claude/worktrees/emails-list/tests/commands/contacts/topics.test.ts +163 -0
  134. package/.claude/worktrees/emails-list/tests/commands/contacts/update-topics.test.ts +247 -0
  135. package/.claude/worktrees/emails-list/tests/commands/contacts/update.test.ts +205 -0
  136. package/.claude/worktrees/emails-list/tests/commands/doctor.test.ts +165 -0
  137. package/.claude/worktrees/emails-list/tests/commands/domains/create.test.ts +192 -0
  138. package/.claude/worktrees/emails-list/tests/commands/domains/delete.test.ts +156 -0
  139. package/.claude/worktrees/emails-list/tests/commands/domains/get.test.ts +137 -0
  140. package/.claude/worktrees/emails-list/tests/commands/domains/list.test.ts +164 -0
  141. package/.claude/worktrees/emails-list/tests/commands/domains/update.test.ts +223 -0
  142. package/.claude/worktrees/emails-list/tests/commands/domains/verify.test.ts +117 -0
  143. package/.claude/worktrees/emails-list/tests/commands/emails/batch.test.ts +313 -0
  144. package/.claude/worktrees/emails-list/tests/commands/emails/list.test.ts +196 -0
  145. package/.claude/worktrees/emails-list/tests/commands/emails/receiving/attachment.test.ts +140 -0
  146. package/.claude/worktrees/emails-list/tests/commands/emails/receiving/attachments.test.ts +168 -0
  147. package/.claude/worktrees/emails-list/tests/commands/emails/receiving/get.test.ts +140 -0
  148. package/.claude/worktrees/emails-list/tests/commands/emails/receiving/list.test.ts +181 -0
  149. package/.claude/worktrees/emails-list/tests/commands/emails/send.test.ts +309 -0
  150. package/.claude/worktrees/emails-list/tests/commands/segments/create.test.ts +163 -0
  151. package/.claude/worktrees/emails-list/tests/commands/segments/delete.test.ts +182 -0
  152. package/.claude/worktrees/emails-list/tests/commands/segments/get.test.ts +137 -0
  153. package/.claude/worktrees/emails-list/tests/commands/segments/list.test.ts +173 -0
  154. package/.claude/worktrees/emails-list/tests/commands/teams/list.test.ts +63 -0
  155. package/.claude/worktrees/emails-list/tests/commands/teams/remove.test.ts +103 -0
  156. package/.claude/worktrees/emails-list/tests/commands/teams/switch.test.ts +96 -0
  157. package/.claude/worktrees/emails-list/tests/commands/topics/create.test.ts +191 -0
  158. package/.claude/worktrees/emails-list/tests/commands/topics/delete.test.ts +156 -0
  159. package/.claude/worktrees/emails-list/tests/commands/topics/get.test.ts +125 -0
  160. package/.claude/worktrees/emails-list/tests/commands/topics/list.test.ts +124 -0
  161. package/.claude/worktrees/emails-list/tests/commands/topics/update.test.ts +177 -0
  162. package/.claude/worktrees/emails-list/tests/commands/webhooks/create.test.ts +224 -0
  163. package/.claude/worktrees/emails-list/tests/commands/webhooks/delete.test.ts +156 -0
  164. package/.claude/worktrees/emails-list/tests/commands/webhooks/get.test.ts +125 -0
  165. package/.claude/worktrees/emails-list/tests/commands/webhooks/list.test.ts +177 -0
  166. package/.claude/worktrees/emails-list/tests/commands/webhooks/update.test.ts +206 -0
  167. package/.claude/worktrees/emails-list/tests/commands/whoami.test.ts +99 -0
  168. package/.claude/worktrees/emails-list/tests/helpers.ts +93 -0
  169. package/.claude/worktrees/emails-list/tests/lib/client.test.ts +71 -0
  170. package/.claude/worktrees/emails-list/tests/lib/config.test.ts +414 -0
  171. package/.claude/worktrees/emails-list/tests/lib/files.test.ts +65 -0
  172. package/.claude/worktrees/emails-list/tests/lib/help-text.test.ts +97 -0
  173. package/.claude/worktrees/emails-list/tests/lib/output.test.ts +127 -0
  174. package/.claude/worktrees/emails-list/tests/lib/prompts.test.ts +178 -0
  175. package/.claude/worktrees/emails-list/tests/lib/spinner.test.ts +146 -0
  176. package/.claude/worktrees/emails-list/tests/lib/table.test.ts +63 -0
  177. package/.claude/worktrees/emails-list/tests/lib/tty.test.ts +85 -0
  178. package/.claude/worktrees/emails-list/tsconfig.json +14 -0
  179. package/.github/workflows/ci.yml +3 -3
  180. package/.github/workflows/pr-title-check.yml +1 -1
  181. package/.github/workflows/release.yml +37 -10
  182. package/.github/workflows/test-install-windows.yml +48 -0
  183. package/README.md +20 -1
  184. package/docs/agent-dx-gaps.md +167 -0
  185. package/docs/missing-commands.md +58 -0
  186. package/docs/production-readiness.md +99 -0
  187. package/docs/secure-key-storage.md +174 -0
  188. package/install.ps1 +1 -0
  189. package/install.sh +11 -4
  190. package/package.json +2 -2
  191. package/renovate.json +4 -0
  192. package/src/cli.ts +21 -4
  193. package/src/commands/auth/login.ts +6 -10
  194. package/src/commands/open.ts +24 -0
  195. package/src/lib/client.ts +9 -1
  196. package/src/lib/help-text.ts +4 -2
  197. package/src/lib/spinner.ts +7 -3
  198. package/tests/lib/help-text.test.ts +2 -1
@@ -0,0 +1,156 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from 'bun:test';
10
+ import {
11
+ captureTestEnv,
12
+ expectExit1,
13
+ mockExitThrow,
14
+ mockSdkError,
15
+ setNonInteractive,
16
+ setupOutputSpies,
17
+ } from '../../helpers';
18
+
19
+ const mockRemove = mock(async () => ({
20
+ data: {},
21
+ error: null,
22
+ }));
23
+
24
+ mock.module('resend', () => ({
25
+ Resend: class MockResend {
26
+ constructor(public key: string) {}
27
+ apiKeys = { remove: mockRemove };
28
+ },
29
+ }));
30
+
31
+ describe('api-keys delete command', () => {
32
+ const restoreEnv = captureTestEnv();
33
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
34
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
35
+ let stderrSpy: ReturnType<typeof spyOn> | undefined;
36
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
37
+
38
+ beforeEach(() => {
39
+ process.env.RESEND_API_KEY = 're_test_key';
40
+ mockRemove.mockClear();
41
+ });
42
+
43
+ afterEach(() => {
44
+ restoreEnv();
45
+ spies?.restore();
46
+ errorSpy?.mockRestore();
47
+ stderrSpy?.mockRestore();
48
+ exitSpy?.mockRestore();
49
+ spies = undefined;
50
+ errorSpy = undefined;
51
+ stderrSpy = undefined;
52
+ exitSpy = undefined;
53
+ });
54
+
55
+ test('deletes API key with --yes flag', async () => {
56
+ spies = setupOutputSpies();
57
+
58
+ const { deleteApiKeyCommand } = await import(
59
+ '../../../src/commands/api-keys/delete'
60
+ );
61
+ await deleteApiKeyCommand.parseAsync(['test-key-id', '--yes'], {
62
+ from: 'user',
63
+ });
64
+
65
+ expect(mockRemove).toHaveBeenCalledWith('test-key-id');
66
+ });
67
+
68
+ test('outputs synthesized deleted JSON on success', async () => {
69
+ spies = setupOutputSpies();
70
+
71
+ const { deleteApiKeyCommand } = await import(
72
+ '../../../src/commands/api-keys/delete'
73
+ );
74
+ await deleteApiKeyCommand.parseAsync(['test-key-id', '--yes'], {
75
+ from: 'user',
76
+ });
77
+
78
+ const output = spies.logSpy.mock.calls[0][0] as string;
79
+ const parsed = JSON.parse(output);
80
+ expect(parsed.deleted).toBe(true);
81
+ expect(parsed.id).toBe('test-key-id');
82
+ });
83
+
84
+ test('errors with confirmation_required when --yes absent in non-interactive mode', async () => {
85
+ setNonInteractive();
86
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
87
+ exitSpy = mockExitThrow();
88
+
89
+ const { deleteApiKeyCommand } = await import(
90
+ '../../../src/commands/api-keys/delete'
91
+ );
92
+ await expectExit1(() =>
93
+ deleteApiKeyCommand.parseAsync(['test-key-id'], { from: 'user' }),
94
+ );
95
+
96
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
97
+ expect(output).toContain('confirmation_required');
98
+ });
99
+
100
+ test('does not call SDK when confirmation is required but not given', async () => {
101
+ setNonInteractive();
102
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
103
+ exitSpy = mockExitThrow();
104
+
105
+ const { deleteApiKeyCommand } = await import(
106
+ '../../../src/commands/api-keys/delete'
107
+ );
108
+ await expectExit1(() =>
109
+ deleteApiKeyCommand.parseAsync(['test-key-id'], { from: 'user' }),
110
+ );
111
+
112
+ expect(mockRemove).not.toHaveBeenCalled();
113
+ });
114
+
115
+ test('errors with auth_error when no API key', async () => {
116
+ setNonInteractive();
117
+ delete process.env.RESEND_API_KEY;
118
+ process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
119
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
120
+ exitSpy = mockExitThrow();
121
+
122
+ const { deleteApiKeyCommand } = await import(
123
+ '../../../src/commands/api-keys/delete'
124
+ );
125
+ await expectExit1(() =>
126
+ deleteApiKeyCommand.parseAsync(['test-key-id', '--yes'], {
127
+ from: 'user',
128
+ }),
129
+ );
130
+
131
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
132
+ expect(output).toContain('auth_error');
133
+ });
134
+
135
+ test('errors with delete_error when SDK returns an error', async () => {
136
+ setNonInteractive();
137
+ mockRemove.mockResolvedValueOnce(
138
+ mockSdkError('API key not found', 'not_found'),
139
+ );
140
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
141
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
142
+ exitSpy = mockExitThrow();
143
+
144
+ const { deleteApiKeyCommand } = await import(
145
+ '../../../src/commands/api-keys/delete'
146
+ );
147
+ await expectExit1(() =>
148
+ deleteApiKeyCommand.parseAsync(['test-key-id', '--yes'], {
149
+ from: 'user',
150
+ }),
151
+ );
152
+
153
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
154
+ expect(output).toContain('delete_error');
155
+ });
156
+ });
@@ -0,0 +1,133 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from 'bun:test';
10
+ import {
11
+ captureTestEnv,
12
+ expectExit1,
13
+ mockExitThrow,
14
+ mockSdkError,
15
+ setNonInteractive,
16
+ setupOutputSpies,
17
+ } from '../../helpers';
18
+
19
+ const mockList = mock(async () => ({
20
+ data: {
21
+ object: 'list',
22
+ data: [
23
+ {
24
+ id: 'key-id-1',
25
+ name: 'Production Key',
26
+ created_at: '2026-01-01T00:00:00.000Z',
27
+ },
28
+ {
29
+ id: 'key-id-2',
30
+ name: 'Staging Key',
31
+ created_at: '2026-01-02T00:00:00.000Z',
32
+ },
33
+ ],
34
+ },
35
+ error: null,
36
+ }));
37
+
38
+ mock.module('resend', () => ({
39
+ Resend: class MockResend {
40
+ constructor(public key: string) {}
41
+ apiKeys = { list: mockList };
42
+ },
43
+ }));
44
+
45
+ describe('api-keys list command', () => {
46
+ const restoreEnv = captureTestEnv();
47
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
48
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
49
+ let stderrSpy: ReturnType<typeof spyOn> | undefined;
50
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
51
+
52
+ beforeEach(() => {
53
+ process.env.RESEND_API_KEY = 're_test_key';
54
+ mockList.mockClear();
55
+ });
56
+
57
+ afterEach(() => {
58
+ restoreEnv();
59
+ spies?.restore();
60
+ errorSpy?.mockRestore();
61
+ stderrSpy?.mockRestore();
62
+ exitSpy?.mockRestore();
63
+ spies = undefined;
64
+ errorSpy = undefined;
65
+ stderrSpy = undefined;
66
+ exitSpy = undefined;
67
+ });
68
+
69
+ test('calls SDK list with no arguments', async () => {
70
+ spies = setupOutputSpies();
71
+
72
+ const { listApiKeysCommand } = await import(
73
+ '../../../src/commands/api-keys/list'
74
+ );
75
+ await listApiKeysCommand.parseAsync([], { from: 'user' });
76
+
77
+ expect(mockList).toHaveBeenCalledTimes(1);
78
+ });
79
+
80
+ test('outputs JSON list when non-interactive', async () => {
81
+ spies = setupOutputSpies();
82
+
83
+ const { listApiKeysCommand } = await import(
84
+ '../../../src/commands/api-keys/list'
85
+ );
86
+ await listApiKeysCommand.parseAsync([], { from: 'user' });
87
+
88
+ const output = spies.logSpy.mock.calls[0][0] as string;
89
+ const parsed = JSON.parse(output);
90
+ expect(parsed.object).toBe('list');
91
+ expect(parsed.data).toHaveLength(2);
92
+ expect(parsed.data[0].id).toBe('key-id-1');
93
+ expect(parsed.data[0].name).toBe('Production Key');
94
+ });
95
+
96
+ test('errors with auth_error when no API key', async () => {
97
+ setNonInteractive();
98
+ delete process.env.RESEND_API_KEY;
99
+ process.env.XDG_CONFIG_HOME = '/tmp/nonexistent-resend';
100
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
101
+ exitSpy = mockExitThrow();
102
+
103
+ const { listApiKeysCommand } = await import(
104
+ '../../../src/commands/api-keys/list'
105
+ );
106
+ await expectExit1(() =>
107
+ listApiKeysCommand.parseAsync([], { from: 'user' }),
108
+ );
109
+
110
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
111
+ expect(output).toContain('auth_error');
112
+ });
113
+
114
+ test('errors with list_error when SDK returns an error', async () => {
115
+ setNonInteractive();
116
+ mockList.mockResolvedValueOnce(
117
+ mockSdkError('Unauthorized', 'unauthorized'),
118
+ );
119
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
120
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
121
+ exitSpy = mockExitThrow();
122
+
123
+ const { listApiKeysCommand } = await import(
124
+ '../../../src/commands/api-keys/list'
125
+ );
126
+ await expectExit1(() =>
127
+ listApiKeysCommand.parseAsync([], { from: 'user' }),
128
+ );
129
+
130
+ const output = errorSpy.mock.calls.map((c) => c[0]).join(' ');
131
+ expect(output).toContain('list_error');
132
+ });
133
+ });
@@ -0,0 +1,119 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ mock,
7
+ spyOn,
8
+ test,
9
+ } from 'bun:test';
10
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+ import {
14
+ captureTestEnv,
15
+ expectExit1,
16
+ mockExitThrow,
17
+ setupOutputSpies,
18
+ } from '../../helpers';
19
+
20
+ // Mock the Resend SDK
21
+ mock.module('resend', () => ({
22
+ Resend: class MockResend {
23
+ constructor(public key: string) {}
24
+ domains = {
25
+ list: mock(async () => ({ data: { data: [] }, error: null })),
26
+ };
27
+ },
28
+ }));
29
+
30
+ describe('login command', () => {
31
+ const restoreEnv = captureTestEnv();
32
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
33
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
34
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
35
+ let tmpDir: string;
36
+
37
+ beforeEach(() => {
38
+ tmpDir = join(
39
+ tmpdir(),
40
+ `resend-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
41
+ );
42
+ mkdirSync(tmpDir, { recursive: true });
43
+ process.env.XDG_CONFIG_HOME = tmpDir;
44
+ });
45
+
46
+ afterEach(() => {
47
+ restoreEnv();
48
+ spies?.restore();
49
+ spies = undefined;
50
+ errorSpy?.mockRestore();
51
+ errorSpy = undefined;
52
+ exitSpy?.mockRestore();
53
+ exitSpy = undefined;
54
+ rmSync(tmpDir, { recursive: true, force: true });
55
+ });
56
+
57
+ test('rejects key not starting with re_', async () => {
58
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
59
+ exitSpy = mockExitThrow();
60
+
61
+ const { loginCommand } = await import('../../../src/commands/auth/login');
62
+ await expectExit1(() =>
63
+ loginCommand.parseAsync(['--key', 'bad_key'], { from: 'user' }),
64
+ );
65
+ });
66
+
67
+ test('stores valid key to credentials.json', async () => {
68
+ spies = setupOutputSpies();
69
+
70
+ const { loginCommand } = await import('../../../src/commands/auth/login');
71
+ await loginCommand.parseAsync(['--key', 're_valid_test_key_123'], {
72
+ from: 'user',
73
+ });
74
+
75
+ const configPath = join(tmpDir, 'resend', 'credentials.json');
76
+ const data = JSON.parse(readFileSync(configPath, 'utf-8'));
77
+ expect(data.teams.default.api_key).toBe('re_valid_test_key_123');
78
+ });
79
+
80
+ test('requires --key in non-interactive mode', async () => {
81
+ spies = setupOutputSpies();
82
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
83
+ exitSpy = mockExitThrow();
84
+
85
+ const { loginCommand } = await import('../../../src/commands/auth/login');
86
+ await expectExit1(() => loginCommand.parseAsync([], { from: 'user' }));
87
+
88
+ expect(errorSpy).toBeDefined();
89
+ const output = errorSpy?.mock.calls[0][0] as string;
90
+ expect(output).toContain('missing_key');
91
+ });
92
+
93
+ test('non-interactive login stores as default when teams exist', async () => {
94
+ // Pre-populate credentials with an existing team
95
+ const configDir = join(tmpDir, 'resend');
96
+ mkdirSync(configDir, { recursive: true });
97
+ writeFileSync(
98
+ join(configDir, 'credentials.json'),
99
+ JSON.stringify({
100
+ active_team: 'production',
101
+ teams: { production: { api_key: 're_old_key_1234' } },
102
+ }),
103
+ );
104
+
105
+ spies = setupOutputSpies();
106
+
107
+ const { loginCommand } = await import('../../../src/commands/auth/login');
108
+ await loginCommand.parseAsync(['--key', 're_new_key_5678'], {
109
+ from: 'user',
110
+ });
111
+
112
+ const configPath = join(tmpDir, 'resend', 'credentials.json');
113
+ const data = JSON.parse(readFileSync(configPath, 'utf-8'));
114
+ // Non-interactive without --team flag stores as 'default' (no picker)
115
+ expect(data.teams.default.api_key).toBe('re_new_key_5678');
116
+ // Original team should still exist
117
+ expect(data.teams.production.api_key).toBe('re_old_key_1234');
118
+ });
119
+ });
@@ -0,0 +1,146 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import {
6
+ captureTestEnv,
7
+ expectExit1,
8
+ mockExitThrow,
9
+ setupOutputSpies,
10
+ } from '../../helpers';
11
+
12
+ describe('logout command', () => {
13
+ const restoreEnv = captureTestEnv();
14
+ let spies: ReturnType<typeof setupOutputSpies> | undefined;
15
+ let errorSpy: ReturnType<typeof spyOn> | undefined;
16
+ let exitSpy: ReturnType<typeof spyOn> | undefined;
17
+ let tmpDir: string;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = join(
21
+ tmpdir(),
22
+ `resend-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
23
+ );
24
+ mkdirSync(tmpDir, { recursive: true });
25
+ process.env.XDG_CONFIG_HOME = tmpDir;
26
+ });
27
+
28
+ afterEach(() => {
29
+ restoreEnv();
30
+ spies?.restore();
31
+ spies = undefined;
32
+ errorSpy?.mockRestore();
33
+ errorSpy = undefined;
34
+ exitSpy?.mockRestore();
35
+ exitSpy = undefined;
36
+ rmSync(tmpDir, { recursive: true, force: true });
37
+ });
38
+
39
+ function writeCredentials(
40
+ teams: Record<string, string> = { default: 're_test_key_123' },
41
+ ) {
42
+ const configDir = join(tmpDir, 'resend');
43
+ mkdirSync(configDir, { recursive: true });
44
+ const creds = {
45
+ active_team: Object.keys(teams)[0],
46
+ teams: Object.fromEntries(
47
+ Object.entries(teams).map(([name, key]) => [name, { api_key: key }]),
48
+ ),
49
+ };
50
+ writeFileSync(
51
+ join(configDir, 'credentials.json'),
52
+ `${JSON.stringify(creds, null, 2)}\n`,
53
+ );
54
+ }
55
+
56
+ test('removes credentials file when it exists (non-interactive)', async () => {
57
+ spies = setupOutputSpies();
58
+ writeCredentials();
59
+
60
+ const { logoutCommand } = await import('../../../src/commands/auth/logout');
61
+ await logoutCommand.parseAsync([], { from: 'user' });
62
+
63
+ const configPath = join(tmpDir, 'resend', 'credentials.json');
64
+ expect(existsSync(configPath)).toBe(false);
65
+
66
+ const output = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
67
+ expect(output.success).toBe(true);
68
+ expect(output.config_path).toContain('credentials.json');
69
+ });
70
+
71
+ test('exits cleanly when no credentials file exists (non-interactive)', async () => {
72
+ spies = setupOutputSpies();
73
+
74
+ const { logoutCommand } = await import('../../../src/commands/auth/logout');
75
+ await logoutCommand.parseAsync([], { from: 'user' });
76
+
77
+ const output = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
78
+ expect(output.success).toBe(true);
79
+ expect(output.already_logged_out).toBe(true);
80
+ });
81
+
82
+ test('logout without --team removes all teams', async () => {
83
+ spies = setupOutputSpies();
84
+ writeCredentials({ staging: 're_staging_key', production: 're_prod_key' });
85
+
86
+ const { logoutCommand } = await import('../../../src/commands/auth/logout');
87
+ await logoutCommand.parseAsync([], { from: 'user' });
88
+
89
+ const configPath = join(tmpDir, 'resend', 'credentials.json');
90
+ expect(existsSync(configPath)).toBe(false);
91
+
92
+ const output = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
93
+ expect(output.success).toBe(true);
94
+ expect(output.team).toBe('all');
95
+ });
96
+
97
+ test('logout with --team removes only that team', async () => {
98
+ spies = setupOutputSpies();
99
+ writeCredentials({ staging: 're_staging_key', production: 're_prod_key' });
100
+
101
+ // Use the full CLI program so --team global option is recognized
102
+ const { Command } = await import('@commander-js/extra-typings');
103
+ const { logoutCommand } = await import('../../../src/commands/auth/logout');
104
+ const program = new Command()
105
+ .option('--team <name>')
106
+ .option('--json')
107
+ .option('--api-key <key>')
108
+ .addCommand(logoutCommand);
109
+
110
+ await program.parseAsync(['logout', '--team', 'staging'], {
111
+ from: 'user',
112
+ });
113
+
114
+ const configPath = join(tmpDir, 'resend', 'credentials.json');
115
+ expect(existsSync(configPath)).toBe(true);
116
+
117
+ const remaining = JSON.parse(
118
+ require('node:fs').readFileSync(configPath, 'utf-8'),
119
+ );
120
+ expect(remaining.teams.staging).toBeUndefined();
121
+ expect(remaining.teams.production).toBeDefined();
122
+
123
+ const output = JSON.parse(spies.logSpy.mock.calls[0][0] as string);
124
+ expect(output.success).toBe(true);
125
+ expect(output.team).toBe('staging');
126
+ });
127
+
128
+ test('exits with error when file removal fails', async () => {
129
+ errorSpy = spyOn(console, 'error').mockImplementation(() => {});
130
+ exitSpy = mockExitThrow();
131
+ spies = setupOutputSpies();
132
+ writeCredentials();
133
+
134
+ // Make the credentials file a directory so unlinkSync throws
135
+ const configPath = join(tmpDir, 'resend', 'credentials.json');
136
+ rmSync(configPath);
137
+ mkdirSync(configPath); // replace file with a directory — unlinkSync will throw EISDIR
138
+
139
+ const { logoutCommand } = await import('../../../src/commands/auth/logout');
140
+ await expectExit1(() => logoutCommand.parseAsync([], { from: 'user' }));
141
+
142
+ expect(errorSpy).toBeDefined();
143
+ const output = JSON.parse(errorSpy?.mock.calls[0][0] as string);
144
+ expect(output.error.code).toBe('remove_failed');
145
+ });
146
+ });