resend-cli 1.2.1 → 1.2.2

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 (191) hide show
  1. package/biome.json +1 -1
  2. package/bun.lock +0 -3
  3. package/package.json +2 -3
  4. package/src/cli.ts +11 -1
  5. package/src/commands/auth/login.ts +35 -8
  6. package/src/commands/doctor.ts +33 -115
  7. package/src/commands/teams/remove.ts +5 -2
  8. package/src/commands/teams/switch.ts +3 -0
  9. package/src/lib/config.ts +37 -31
  10. package/src/lib/spinner.ts +17 -10
  11. package/src/lib/update-check.ts +172 -0
  12. package/tests/commands/auth/login.test.ts +37 -0
  13. package/tests/lib/config.test.ts +38 -7
  14. package/tests/lib/update-check.test.ts +169 -0
  15. package/.claude/worktrees/emails-list/.claude/settings.local.json +0 -5
  16. package/.claude/worktrees/emails-list/.github/scripts/pr-title-check.js +0 -34
  17. package/.claude/worktrees/emails-list/.github/workflows/ci.yml +0 -32
  18. package/.claude/worktrees/emails-list/.github/workflows/pr-title-check.yml +0 -13
  19. package/.claude/worktrees/emails-list/.github/workflows/release.yml +0 -93
  20. package/.claude/worktrees/emails-list/CHANGELOG.md +0 -31
  21. package/.claude/worktrees/emails-list/LICENSE +0 -21
  22. package/.claude/worktrees/emails-list/README.md +0 -424
  23. package/.claude/worktrees/emails-list/biome.json +0 -36
  24. package/.claude/worktrees/emails-list/bun.lock +0 -76
  25. package/.claude/worktrees/emails-list/bunfig.toml +0 -2
  26. package/.claude/worktrees/emails-list/install.ps1 +0 -140
  27. package/.claude/worktrees/emails-list/install.sh +0 -301
  28. package/.claude/worktrees/emails-list/package.json +0 -43
  29. package/.claude/worktrees/emails-list/renovate.json +0 -6
  30. package/.claude/worktrees/emails-list/src/cli.ts +0 -74
  31. package/.claude/worktrees/emails-list/src/commands/api-keys/create.ts +0 -114
  32. package/.claude/worktrees/emails-list/src/commands/api-keys/delete.ts +0 -47
  33. package/.claude/worktrees/emails-list/src/commands/api-keys/index.ts +0 -26
  34. package/.claude/worktrees/emails-list/src/commands/api-keys/list.ts +0 -35
  35. package/.claude/worktrees/emails-list/src/commands/api-keys/utils.ts +0 -8
  36. package/.claude/worktrees/emails-list/src/commands/auth/index.ts +0 -20
  37. package/.claude/worktrees/emails-list/src/commands/auth/login.ts +0 -207
  38. package/.claude/worktrees/emails-list/src/commands/auth/logout.ts +0 -105
  39. package/.claude/worktrees/emails-list/src/commands/broadcasts/create.ts +0 -196
  40. package/.claude/worktrees/emails-list/src/commands/broadcasts/delete.ts +0 -46
  41. package/.claude/worktrees/emails-list/src/commands/broadcasts/get.ts +0 -59
  42. package/.claude/worktrees/emails-list/src/commands/broadcasts/index.ts +0 -43
  43. package/.claude/worktrees/emails-list/src/commands/broadcasts/list.ts +0 -60
  44. package/.claude/worktrees/emails-list/src/commands/broadcasts/send.ts +0 -56
  45. package/.claude/worktrees/emails-list/src/commands/broadcasts/update.ts +0 -95
  46. package/.claude/worktrees/emails-list/src/commands/broadcasts/utils.ts +0 -35
  47. package/.claude/worktrees/emails-list/src/commands/contact-properties/create.ts +0 -118
  48. package/.claude/worktrees/emails-list/src/commands/contact-properties/delete.ts +0 -48
  49. package/.claude/worktrees/emails-list/src/commands/contact-properties/get.ts +0 -46
  50. package/.claude/worktrees/emails-list/src/commands/contact-properties/index.ts +0 -48
  51. package/.claude/worktrees/emails-list/src/commands/contact-properties/list.ts +0 -68
  52. package/.claude/worktrees/emails-list/src/commands/contact-properties/update.ts +0 -88
  53. package/.claude/worktrees/emails-list/src/commands/contact-properties/utils.ts +0 -17
  54. package/.claude/worktrees/emails-list/src/commands/contacts/add-segment.ts +0 -78
  55. package/.claude/worktrees/emails-list/src/commands/contacts/create.ts +0 -122
  56. package/.claude/worktrees/emails-list/src/commands/contacts/delete.ts +0 -49
  57. package/.claude/worktrees/emails-list/src/commands/contacts/get.ts +0 -53
  58. package/.claude/worktrees/emails-list/src/commands/contacts/index.ts +0 -58
  59. package/.claude/worktrees/emails-list/src/commands/contacts/list.ts +0 -57
  60. package/.claude/worktrees/emails-list/src/commands/contacts/remove-segment.ts +0 -48
  61. package/.claude/worktrees/emails-list/src/commands/contacts/segments.ts +0 -39
  62. package/.claude/worktrees/emails-list/src/commands/contacts/topics.ts +0 -45
  63. package/.claude/worktrees/emails-list/src/commands/contacts/update-topics.ts +0 -90
  64. package/.claude/worktrees/emails-list/src/commands/contacts/update.ts +0 -77
  65. package/.claude/worktrees/emails-list/src/commands/contacts/utils.ts +0 -119
  66. package/.claude/worktrees/emails-list/src/commands/doctor.ts +0 -298
  67. package/.claude/worktrees/emails-list/src/commands/domains/create.ts +0 -83
  68. package/.claude/worktrees/emails-list/src/commands/domains/delete.ts +0 -42
  69. package/.claude/worktrees/emails-list/src/commands/domains/get.ts +0 -47
  70. package/.claude/worktrees/emails-list/src/commands/domains/index.ts +0 -35
  71. package/.claude/worktrees/emails-list/src/commands/domains/list.ts +0 -53
  72. package/.claude/worktrees/emails-list/src/commands/domains/update.ts +0 -75
  73. package/.claude/worktrees/emails-list/src/commands/domains/utils.ts +0 -44
  74. package/.claude/worktrees/emails-list/src/commands/domains/verify.ts +0 -38
  75. package/.claude/worktrees/emails-list/src/commands/emails/batch.ts +0 -140
  76. package/.claude/worktrees/emails-list/src/commands/emails/index.ts +0 -28
  77. package/.claude/worktrees/emails-list/src/commands/emails/list.ts +0 -73
  78. package/.claude/worktrees/emails-list/src/commands/emails/receiving/attachment.ts +0 -55
  79. package/.claude/worktrees/emails-list/src/commands/emails/receiving/attachments.ts +0 -68
  80. package/.claude/worktrees/emails-list/src/commands/emails/receiving/get.ts +0 -58
  81. package/.claude/worktrees/emails-list/src/commands/emails/receiving/index.ts +0 -28
  82. package/.claude/worktrees/emails-list/src/commands/emails/receiving/list.ts +0 -59
  83. package/.claude/worktrees/emails-list/src/commands/emails/receiving/utils.ts +0 -38
  84. package/.claude/worktrees/emails-list/src/commands/emails/send.ts +0 -189
  85. package/.claude/worktrees/emails-list/src/commands/open.ts +0 -24
  86. package/.claude/worktrees/emails-list/src/commands/segments/create.ts +0 -50
  87. package/.claude/worktrees/emails-list/src/commands/segments/delete.ts +0 -47
  88. package/.claude/worktrees/emails-list/src/commands/segments/get.ts +0 -38
  89. package/.claude/worktrees/emails-list/src/commands/segments/index.ts +0 -36
  90. package/.claude/worktrees/emails-list/src/commands/segments/list.ts +0 -58
  91. package/.claude/worktrees/emails-list/src/commands/segments/utils.ts +0 -7
  92. package/.claude/worktrees/emails-list/src/commands/teams/index.ts +0 -10
  93. package/.claude/worktrees/emails-list/src/commands/teams/list.ts +0 -35
  94. package/.claude/worktrees/emails-list/src/commands/teams/remove.ts +0 -83
  95. package/.claude/worktrees/emails-list/src/commands/teams/switch.ts +0 -73
  96. package/.claude/worktrees/emails-list/src/commands/topics/create.ts +0 -73
  97. package/.claude/worktrees/emails-list/src/commands/topics/delete.ts +0 -47
  98. package/.claude/worktrees/emails-list/src/commands/topics/get.ts +0 -42
  99. package/.claude/worktrees/emails-list/src/commands/topics/index.ts +0 -42
  100. package/.claude/worktrees/emails-list/src/commands/topics/list.ts +0 -34
  101. package/.claude/worktrees/emails-list/src/commands/topics/update.ts +0 -59
  102. package/.claude/worktrees/emails-list/src/commands/topics/utils.ts +0 -16
  103. package/.claude/worktrees/emails-list/src/commands/webhooks/create.ts +0 -128
  104. package/.claude/worktrees/emails-list/src/commands/webhooks/delete.ts +0 -49
  105. package/.claude/worktrees/emails-list/src/commands/webhooks/get.ts +0 -42
  106. package/.claude/worktrees/emails-list/src/commands/webhooks/index.ts +0 -44
  107. package/.claude/worktrees/emails-list/src/commands/webhooks/list.ts +0 -55
  108. package/.claude/worktrees/emails-list/src/commands/webhooks/update.ts +0 -83
  109. package/.claude/worktrees/emails-list/src/commands/webhooks/utils.ts +0 -36
  110. package/.claude/worktrees/emails-list/src/commands/whoami.ts +0 -71
  111. package/.claude/worktrees/emails-list/src/lib/actions.ts +0 -157
  112. package/.claude/worktrees/emails-list/src/lib/client.ts +0 -34
  113. package/.claude/worktrees/emails-list/src/lib/config.ts +0 -211
  114. package/.claude/worktrees/emails-list/src/lib/files.ts +0 -15
  115. package/.claude/worktrees/emails-list/src/lib/help-text.ts +0 -38
  116. package/.claude/worktrees/emails-list/src/lib/output.ts +0 -54
  117. package/.claude/worktrees/emails-list/src/lib/pagination.ts +0 -36
  118. package/.claude/worktrees/emails-list/src/lib/prompts.ts +0 -149
  119. package/.claude/worktrees/emails-list/src/lib/spinner.ts +0 -93
  120. package/.claude/worktrees/emails-list/src/lib/table.ts +0 -57
  121. package/.claude/worktrees/emails-list/src/lib/tty.ts +0 -28
  122. package/.claude/worktrees/emails-list/src/lib/version.ts +0 -4
  123. package/.claude/worktrees/emails-list/tests/commands/api-keys/create.test.ts +0 -195
  124. package/.claude/worktrees/emails-list/tests/commands/api-keys/delete.test.ts +0 -156
  125. package/.claude/worktrees/emails-list/tests/commands/api-keys/list.test.ts +0 -133
  126. package/.claude/worktrees/emails-list/tests/commands/auth/login.test.ts +0 -119
  127. package/.claude/worktrees/emails-list/tests/commands/auth/logout.test.ts +0 -146
  128. package/.claude/worktrees/emails-list/tests/commands/broadcasts/create.test.ts +0 -447
  129. package/.claude/worktrees/emails-list/tests/commands/broadcasts/delete.test.ts +0 -182
  130. package/.claude/worktrees/emails-list/tests/commands/broadcasts/get.test.ts +0 -146
  131. package/.claude/worktrees/emails-list/tests/commands/broadcasts/list.test.ts +0 -196
  132. package/.claude/worktrees/emails-list/tests/commands/broadcasts/send.test.ts +0 -161
  133. package/.claude/worktrees/emails-list/tests/commands/broadcasts/update.test.ts +0 -283
  134. package/.claude/worktrees/emails-list/tests/commands/contact-properties/create.test.ts +0 -250
  135. package/.claude/worktrees/emails-list/tests/commands/contact-properties/delete.test.ts +0 -183
  136. package/.claude/worktrees/emails-list/tests/commands/contact-properties/get.test.ts +0 -144
  137. package/.claude/worktrees/emails-list/tests/commands/contact-properties/list.test.ts +0 -180
  138. package/.claude/worktrees/emails-list/tests/commands/contact-properties/update.test.ts +0 -216
  139. package/.claude/worktrees/emails-list/tests/commands/contacts/add-segment.test.ts +0 -188
  140. package/.claude/worktrees/emails-list/tests/commands/contacts/create.test.ts +0 -270
  141. package/.claude/worktrees/emails-list/tests/commands/contacts/delete.test.ts +0 -192
  142. package/.claude/worktrees/emails-list/tests/commands/contacts/get.test.ts +0 -148
  143. package/.claude/worktrees/emails-list/tests/commands/contacts/list.test.ts +0 -175
  144. package/.claude/worktrees/emails-list/tests/commands/contacts/remove-segment.test.ts +0 -166
  145. package/.claude/worktrees/emails-list/tests/commands/contacts/segments.test.ts +0 -167
  146. package/.claude/worktrees/emails-list/tests/commands/contacts/topics.test.ts +0 -163
  147. package/.claude/worktrees/emails-list/tests/commands/contacts/update-topics.test.ts +0 -247
  148. package/.claude/worktrees/emails-list/tests/commands/contacts/update.test.ts +0 -205
  149. package/.claude/worktrees/emails-list/tests/commands/doctor.test.ts +0 -165
  150. package/.claude/worktrees/emails-list/tests/commands/domains/create.test.ts +0 -192
  151. package/.claude/worktrees/emails-list/tests/commands/domains/delete.test.ts +0 -156
  152. package/.claude/worktrees/emails-list/tests/commands/domains/get.test.ts +0 -137
  153. package/.claude/worktrees/emails-list/tests/commands/domains/list.test.ts +0 -164
  154. package/.claude/worktrees/emails-list/tests/commands/domains/update.test.ts +0 -223
  155. package/.claude/worktrees/emails-list/tests/commands/domains/verify.test.ts +0 -117
  156. package/.claude/worktrees/emails-list/tests/commands/emails/batch.test.ts +0 -313
  157. package/.claude/worktrees/emails-list/tests/commands/emails/list.test.ts +0 -196
  158. package/.claude/worktrees/emails-list/tests/commands/emails/receiving/attachment.test.ts +0 -140
  159. package/.claude/worktrees/emails-list/tests/commands/emails/receiving/attachments.test.ts +0 -168
  160. package/.claude/worktrees/emails-list/tests/commands/emails/receiving/get.test.ts +0 -140
  161. package/.claude/worktrees/emails-list/tests/commands/emails/receiving/list.test.ts +0 -181
  162. package/.claude/worktrees/emails-list/tests/commands/emails/send.test.ts +0 -309
  163. package/.claude/worktrees/emails-list/tests/commands/segments/create.test.ts +0 -163
  164. package/.claude/worktrees/emails-list/tests/commands/segments/delete.test.ts +0 -182
  165. package/.claude/worktrees/emails-list/tests/commands/segments/get.test.ts +0 -137
  166. package/.claude/worktrees/emails-list/tests/commands/segments/list.test.ts +0 -173
  167. package/.claude/worktrees/emails-list/tests/commands/teams/list.test.ts +0 -63
  168. package/.claude/worktrees/emails-list/tests/commands/teams/remove.test.ts +0 -103
  169. package/.claude/worktrees/emails-list/tests/commands/teams/switch.test.ts +0 -96
  170. package/.claude/worktrees/emails-list/tests/commands/topics/create.test.ts +0 -191
  171. package/.claude/worktrees/emails-list/tests/commands/topics/delete.test.ts +0 -156
  172. package/.claude/worktrees/emails-list/tests/commands/topics/get.test.ts +0 -125
  173. package/.claude/worktrees/emails-list/tests/commands/topics/list.test.ts +0 -124
  174. package/.claude/worktrees/emails-list/tests/commands/topics/update.test.ts +0 -177
  175. package/.claude/worktrees/emails-list/tests/commands/webhooks/create.test.ts +0 -224
  176. package/.claude/worktrees/emails-list/tests/commands/webhooks/delete.test.ts +0 -156
  177. package/.claude/worktrees/emails-list/tests/commands/webhooks/get.test.ts +0 -125
  178. package/.claude/worktrees/emails-list/tests/commands/webhooks/list.test.ts +0 -177
  179. package/.claude/worktrees/emails-list/tests/commands/webhooks/update.test.ts +0 -206
  180. package/.claude/worktrees/emails-list/tests/commands/whoami.test.ts +0 -99
  181. package/.claude/worktrees/emails-list/tests/helpers.ts +0 -93
  182. package/.claude/worktrees/emails-list/tests/lib/client.test.ts +0 -71
  183. package/.claude/worktrees/emails-list/tests/lib/config.test.ts +0 -414
  184. package/.claude/worktrees/emails-list/tests/lib/files.test.ts +0 -65
  185. package/.claude/worktrees/emails-list/tests/lib/help-text.test.ts +0 -97
  186. package/.claude/worktrees/emails-list/tests/lib/output.test.ts +0 -127
  187. package/.claude/worktrees/emails-list/tests/lib/prompts.test.ts +0 -178
  188. package/.claude/worktrees/emails-list/tests/lib/spinner.test.ts +0 -146
  189. package/.claude/worktrees/emails-list/tests/lib/table.test.ts +0 -63
  190. package/.claude/worktrees/emails-list/tests/lib/tty.test.ts +0 -85
  191. package/.claude/worktrees/emails-list/tsconfig.json +0 -14
@@ -0,0 +1,172 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { getConfigDir } from './config';
4
+ import { VERSION } from './version';
5
+
6
+ const CHECK_INTERVAL_MS = 1 * 60 * 60 * 1000; // 1 hour
7
+ export const GITHUB_RELEASES_URL =
8
+ 'https://api.github.com/repos/resend/resend-cli/releases/latest';
9
+
10
+ type UpdateState = {
11
+ lastChecked: number;
12
+ latestVersion: string;
13
+ };
14
+
15
+ function getStatePath(): string {
16
+ return join(getConfigDir(), 'update-state.json');
17
+ }
18
+
19
+ function readState(): UpdateState | null {
20
+ try {
21
+ return JSON.parse(readFileSync(getStatePath(), 'utf-8')) as UpdateState;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function writeState(state: UpdateState): void {
28
+ const dir = getConfigDir();
29
+ if (!existsSync(dir)) {
30
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ }
32
+ writeFileSync(getStatePath(), JSON.stringify(state), { mode: 0o600 });
33
+ }
34
+
35
+ /**
36
+ * Compare two semver strings. Returns true if remote > local.
37
+ */
38
+ function isNewer(local: string, remote: string): boolean {
39
+ const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
40
+ const [lMaj, lMin, lPat] = parse(local);
41
+ const [rMaj, rMin, rPat] = parse(remote);
42
+ if (rMaj !== lMaj) {
43
+ return rMaj > lMaj;
44
+ }
45
+ if (rMin !== lMin) {
46
+ return rMin > lMin;
47
+ }
48
+ return rPat > lPat;
49
+ }
50
+
51
+ async function fetchLatestVersion(): Promise<string | null> {
52
+ try {
53
+ const res = await fetch(GITHUB_RELEASES_URL, {
54
+ headers: { Accept: 'application/vnd.github.v3+json' },
55
+ signal: AbortSignal.timeout(5000),
56
+ });
57
+ if (!res.ok) {
58
+ return null;
59
+ }
60
+ const data = (await res.json()) as {
61
+ tag_name?: string;
62
+ prerelease?: boolean;
63
+ draft?: boolean;
64
+ };
65
+ // /releases/latest already excludes prereleases, but guard anyway
66
+ if (data.prerelease || data.draft) {
67
+ return null;
68
+ }
69
+ const version = data.tag_name?.replace(/^v/, '');
70
+ if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
71
+ return null;
72
+ }
73
+ return version;
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function shouldSkipCheck(): boolean {
80
+ if (process.env.RESEND_NO_UPDATE_NOTIFIER === '1') {
81
+ return true;
82
+ }
83
+ if (process.env.CI === 'true' || process.env.CI === '1') {
84
+ return true;
85
+ }
86
+ if (process.env.GITHUB_ACTIONS) {
87
+ return true;
88
+ }
89
+ if (!process.stdout.isTTY) {
90
+ return true;
91
+ }
92
+ return false;
93
+ }
94
+
95
+ function detectInstallMethod(): string {
96
+ const execPath = process.execPath || process.argv[0] || '';
97
+
98
+ // Homebrew
99
+ if (/\/(Cellar|homebrew)\//i.test(execPath)) {
100
+ return 'brew upgrade resend';
101
+ }
102
+
103
+ // npm / npx global install
104
+ if (/node_modules/.test(execPath) || process.env.npm_execpath) {
105
+ return 'npm install -g resend-cli';
106
+ }
107
+
108
+ // Install script (default install location)
109
+ if (/[/\\]\.resend[/\\]bin[/\\]/.test(execPath)) {
110
+ if (process.platform === 'win32') {
111
+ return 'irm https://resend.com/install.ps1 | iex';
112
+ }
113
+ return 'curl -fsSL https://resend.com/install.sh | bash';
114
+ }
115
+
116
+ // Default: install script
117
+ if (process.platform === 'win32') {
118
+ return 'irm https://resend.com/install.ps1 | iex';
119
+ }
120
+ return 'curl -fsSL https://resend.com/install.sh | bash';
121
+ }
122
+
123
+ function formatNotice(latestVersion: string): string {
124
+ const upgrade = detectInstallMethod();
125
+ const isUrl = upgrade.startsWith('http');
126
+
127
+ const dim = '\x1B[2m';
128
+ const yellow = '\x1B[33m';
129
+ const cyan = '\x1B[36m';
130
+ const reset = '\x1B[0m';
131
+
132
+ return [
133
+ '',
134
+ `${dim}Update available: ${yellow}v${VERSION}${reset}${dim} → ${cyan}v${latestVersion}${reset}`,
135
+ `${dim}${isUrl ? 'Visit' : 'Run'}: ${cyan}${upgrade}${reset}`,
136
+ '',
137
+ ].join('\n');
138
+ }
139
+
140
+ /**
141
+ * Check for updates and print a notice to stderr if one is available.
142
+ * Designed to be called after the main command completes — never blocks
143
+ * or throws.
144
+ */
145
+ export async function checkForUpdates(): Promise<void> {
146
+ if (shouldSkipCheck()) {
147
+ return;
148
+ }
149
+
150
+ const state = readState();
151
+ const now = Date.now();
152
+
153
+ // If we have a cached check that's still fresh, just use it
154
+ if (state && now - state.lastChecked < CHECK_INTERVAL_MS) {
155
+ if (isNewer(VERSION, state.latestVersion)) {
156
+ process.stderr.write(formatNotice(state.latestVersion));
157
+ }
158
+ return;
159
+ }
160
+
161
+ // Stale or missing — fetch in the background
162
+ const latest = await fetchLatestVersion();
163
+ if (!latest) {
164
+ return;
165
+ }
166
+
167
+ writeState({ lastChecked: now, latestVersion: latest });
168
+
169
+ if (isNewer(VERSION, latest)) {
170
+ process.stderr.write(formatNotice(latest));
171
+ }
172
+ }
@@ -116,4 +116,41 @@ describe('login command', () => {
116
116
  // Original team should still exist
117
117
  expect(data.teams.production.api_key).toBe('re_old_key_1234');
118
118
  });
119
+
120
+ test('auto-switches to team specified via --team flag', async () => {
121
+ spies = setupOutputSpies();
122
+
123
+ const { Command } = await import('@commander-js/extra-typings');
124
+ const { loginCommand } = await import('../../../src/commands/auth/login');
125
+ const program = new Command()
126
+ .option('--team <name>')
127
+ .option('--json')
128
+ .option('--api-key <key>')
129
+ .option('-q, --quiet')
130
+ .addCommand(loginCommand);
131
+
132
+ // First store a default key
133
+ const configDir = join(tmpDir, 'resend');
134
+ mkdirSync(configDir, { recursive: true });
135
+ writeFileSync(
136
+ join(configDir, 'credentials.json'),
137
+ JSON.stringify({
138
+ active_team: 'default',
139
+ teams: { default: { api_key: 're_old_key_1234' } },
140
+ }),
141
+ );
142
+
143
+ await program.parseAsync(
144
+ ['login', '--key', 're_staging_key_123', '--team', 'staging'],
145
+ { from: 'user' },
146
+ );
147
+
148
+ // @ts-expect-error — reset parent to avoid polluting the shared singleton
149
+ loginCommand.parent = null;
150
+
151
+ const configPath = join(tmpDir, 'resend', 'credentials.json');
152
+ const data = JSON.parse(readFileSync(configPath, 'utf-8'));
153
+ expect(data.active_team).toBe('staging');
154
+ expect(data.teams.staging.api_key).toBe('re_staging_key_123');
155
+ });
119
156
  });
@@ -5,11 +5,12 @@ import { join } from 'node:path';
5
5
  import {
6
6
  getConfigDir,
7
7
  listTeams,
8
- removeTeam,
8
+ removeApiKey,
9
9
  resolveApiKey,
10
10
  resolveTeamName,
11
11
  setActiveTeam,
12
12
  storeApiKey,
13
+ validateTeamName,
13
14
  } from '../../src/lib/config';
14
15
  import { captureTestEnv } from '../helpers';
15
16
 
@@ -354,7 +355,7 @@ describe('setActiveTeam', () => {
354
355
  });
355
356
  });
356
357
 
357
- describe('removeTeam', () => {
358
+ describe('removeApiKey', () => {
358
359
  const restoreEnv = captureTestEnv();
359
360
  let tmpDir: string;
360
361
 
@@ -375,7 +376,7 @@ describe('removeTeam', () => {
375
376
  storeApiKey('re_default', 'default');
376
377
  storeApiKey('re_staging', 'staging');
377
378
 
378
- removeTeam('staging');
379
+ removeApiKey('staging');
379
380
 
380
381
  const teams = listTeams();
381
382
  expect(teams).toEqual([{ name: 'default', active: true }]);
@@ -386,7 +387,7 @@ describe('removeTeam', () => {
386
387
  storeApiKey('re_staging', 'staging');
387
388
  setActiveTeam('staging');
388
389
 
389
- removeTeam('staging');
390
+ removeApiKey('staging');
390
391
 
391
392
  const configPath = join(tmpDir, 'resend', 'credentials.json');
392
393
  const data = JSON.parse(readFileSync(configPath, 'utf-8'));
@@ -396,7 +397,7 @@ describe('removeTeam', () => {
396
397
  test('deletes file when last team removed', () => {
397
398
  storeApiKey('re_only', 'only');
398
399
 
399
- removeTeam('only');
400
+ removeApiKey('only');
400
401
 
401
402
  const { existsSync } = require('node:fs');
402
403
  const configPath = join(tmpDir, 'resend', 'credentials.json');
@@ -405,10 +406,40 @@ describe('removeTeam', () => {
405
406
 
406
407
  test('throws when team does not exist', () => {
407
408
  storeApiKey('re_default');
408
- expect(() => removeTeam('nonexistent')).toThrow('not found');
409
+ expect(() => removeApiKey('nonexistent')).toThrow('not found');
409
410
  });
410
411
 
411
412
  test('throws when no credentials file', () => {
412
- expect(() => removeTeam('any')).toThrow('No credentials file');
413
+ expect(() => removeApiKey('any')).toThrow('No credentials file');
414
+ });
415
+ });
416
+
417
+ describe('validateTeamName', () => {
418
+ test('accepts valid names', () => {
419
+ expect(validateTeamName('default')).toBeUndefined();
420
+ expect(validateTeamName('my-team')).toBeUndefined();
421
+ expect(validateTeamName('team_1')).toBeUndefined();
422
+ expect(validateTeamName('prod-2024')).toBeUndefined();
423
+ expect(validateTeamName('Production')).toBeUndefined();
424
+ expect(validateTeamName('MyTeam')).toBeUndefined();
425
+ });
426
+
427
+ test('rejects spaces and special characters', () => {
428
+ expect(validateTeamName('my team')).toContain('letters');
429
+ expect(validateTeamName('team@org')).toContain('letters');
430
+ });
431
+
432
+ test('rejects empty name', () => {
433
+ expect(validateTeamName('')).toContain('empty');
434
+ });
435
+
436
+ test('rejects names longer than 64 characters', () => {
437
+ const longName = 'a'.repeat(65);
438
+ expect(validateTeamName(longName)).toContain('64');
439
+ });
440
+
441
+ test('accepts name exactly 64 characters', () => {
442
+ const maxName = 'a'.repeat(64);
443
+ expect(validateTeamName(maxName)).toBeUndefined();
413
444
  });
414
445
  });
@@ -0,0 +1,169 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { checkForUpdates } from '../../src/lib/update-check';
12
+ import { VERSION } from '../../src/lib/version';
13
+ import { captureTestEnv } from '../helpers';
14
+
15
+ // Use a version guaranteed to be "newer" than whatever VERSION is
16
+ const NEWER_VERSION = '99.0.0';
17
+
18
+ const testConfigDir = join(tmpdir(), `resend-update-check-test-${process.pid}`);
19
+ const testResendDir = join(testConfigDir, 'resend');
20
+ const statePath = join(testResendDir, 'update-state.json');
21
+
22
+ describe('checkForUpdates', () => {
23
+ const restoreEnv = captureTestEnv();
24
+ let stderrOutput: string;
25
+ let stderrSpy: ReturnType<typeof spyOn>;
26
+ let fetchSpy: ReturnType<typeof spyOn>;
27
+
28
+ beforeEach(() => {
29
+ mkdirSync(testResendDir, { recursive: true });
30
+ stderrOutput = '';
31
+ stderrSpy = spyOn(process.stderr, 'write').mockImplementation((chunk) => {
32
+ stderrOutput += String(chunk);
33
+ return true;
34
+ });
35
+
36
+ // Point getConfigDir() at our temp dir via XDG_CONFIG_HOME
37
+ process.env.XDG_CONFIG_HOME = testConfigDir;
38
+
39
+ // Ensure TTY and no CI
40
+ Object.defineProperty(process.stdout, 'isTTY', {
41
+ value: true,
42
+ writable: true,
43
+ });
44
+ delete process.env.CI;
45
+ delete process.env.GITHUB_ACTIONS;
46
+ delete process.env.RESEND_NO_UPDATE_NOTIFIER;
47
+ });
48
+
49
+ afterEach(() => {
50
+ stderrSpy.mockRestore();
51
+ if (fetchSpy) {
52
+ fetchSpy.mockRestore();
53
+ }
54
+ restoreEnv();
55
+ if (existsSync(testConfigDir)) {
56
+ rmSync(testConfigDir, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ function mockFetch(tagName: string, extra: Record<string, unknown> = {}) {
61
+ fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({
62
+ ok: true,
63
+ json: async () => ({ tag_name: tagName, ...extra }),
64
+ } as Response);
65
+ }
66
+
67
+ function mockFetchFailure() {
68
+ fetchSpy = spyOn(globalThis, 'fetch').mockRejectedValue(
69
+ new Error('network error'),
70
+ );
71
+ }
72
+
73
+ test('skips check when RESEND_NO_UPDATE_NOTIFIER=1', async () => {
74
+ process.env.RESEND_NO_UPDATE_NOTIFIER = '1';
75
+ await checkForUpdates();
76
+ expect(stderrOutput).toBe('');
77
+ });
78
+
79
+ test('skips check when CI=true', async () => {
80
+ process.env.CI = 'true';
81
+ await checkForUpdates();
82
+ expect(stderrOutput).toBe('');
83
+ });
84
+
85
+ test('skips check when not a TTY', async () => {
86
+ Object.defineProperty(process.stdout, 'isTTY', {
87
+ value: undefined,
88
+ writable: true,
89
+ });
90
+ await checkForUpdates();
91
+ expect(stderrOutput).toBe('');
92
+ });
93
+
94
+ test('prints notice when newer version available from fresh fetch', async () => {
95
+ mockFetch(`v${NEWER_VERSION}`);
96
+ await checkForUpdates();
97
+
98
+ expect(stderrOutput).toContain('Update available');
99
+ expect(stderrOutput).toContain(`v${VERSION}`);
100
+ expect(stderrOutput).toContain(`v${NEWER_VERSION}`);
101
+ });
102
+
103
+ test('prints nothing when already on latest', async () => {
104
+ mockFetch(`v${VERSION}`);
105
+ await checkForUpdates();
106
+ expect(stderrOutput).toBe('');
107
+ });
108
+
109
+ test('uses cached state when fresh (no fetch)', async () => {
110
+ writeFileSync(
111
+ statePath,
112
+ JSON.stringify({ lastChecked: Date.now(), latestVersion: NEWER_VERSION }),
113
+ );
114
+
115
+ mockFetch(`v${NEWER_VERSION}`);
116
+ await checkForUpdates();
117
+
118
+ expect(fetchSpy).not.toHaveBeenCalled();
119
+ expect(stderrOutput).toContain(`v${NEWER_VERSION}`);
120
+ });
121
+
122
+ test('refetches when cache is stale', async () => {
123
+ const staleTime = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
124
+ writeFileSync(
125
+ statePath,
126
+ JSON.stringify({ lastChecked: staleTime, latestVersion: VERSION }),
127
+ );
128
+
129
+ mockFetch(`v${NEWER_VERSION}`);
130
+ await checkForUpdates();
131
+
132
+ expect(fetchSpy).toHaveBeenCalled();
133
+ expect(stderrOutput).toContain(`v${NEWER_VERSION}`);
134
+ // Verify cache was updated
135
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'));
136
+ expect(state.latestVersion).toBe(NEWER_VERSION);
137
+ });
138
+
139
+ test('handles fetch failure gracefully', async () => {
140
+ mockFetchFailure();
141
+ await checkForUpdates();
142
+ expect(stderrOutput).toBe('');
143
+ });
144
+
145
+ test('writes state file after successful fetch', async () => {
146
+ mockFetch(`v${VERSION}`);
147
+ await checkForUpdates();
148
+
149
+ expect(existsSync(statePath)).toBe(true);
150
+ const state = JSON.parse(readFileSync(statePath, 'utf-8'));
151
+ expect(state.latestVersion).toBe(VERSION);
152
+ });
153
+
154
+ test('ignores prerelease versions', async () => {
155
+ fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue({
156
+ ok: true,
157
+ json: async () => ({ tag_name: 'v99.0.0', prerelease: true }),
158
+ } as Response);
159
+
160
+ await checkForUpdates();
161
+ expect(stderrOutput).toBe('');
162
+ });
163
+
164
+ test('ignores non-semver tag names', async () => {
165
+ mockFetch('canary-20260311');
166
+ await checkForUpdates();
167
+ expect(stderrOutput).toBe('');
168
+ });
169
+ });
@@ -1,5 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": ["Bash(npx biome:*)"]
4
- }
5
- }
@@ -1,34 +0,0 @@
1
- import fs from 'node:fs';
2
-
3
- const eventPath = process.env.GITHUB_EVENT_PATH;
4
- if (!eventPath) {
5
- console.error(
6
- 'GITHUB_EVENT_PATH is not set. This script must run inside GitHub Actions.',
7
- );
8
- process.exit(1);
9
- }
10
-
11
- const eventJson = JSON.parse(fs.readFileSync(eventPath, 'utf8'));
12
- const prTitle = eventJson.pull_request?.title;
13
- if (!prTitle) {
14
- console.error('Could not read pull_request.title from event payload.');
15
- process.exit(1);
16
- }
17
-
18
- const isValidType = (title) =>
19
- /^(feat|fix|chore|refactor)(\([a-zA-Z0-9-]+\))?:\s[a-z0-9].*$/.test(title);
20
-
21
- const validateTitle = (title) => {
22
- if (!isValidType(title)) {
23
- console.error(
24
- `PR title does not follow the required format "[type]: [description]"
25
- Examples: "feat: add --cc flag" | "fix: domain fetch timeout"
26
- Types: feat · fix · chore · refactor
27
- Rules: lowercase after the colon`,
28
- );
29
- process.exit(1);
30
- }
31
- console.info(`PR title valid: "${title}"`);
32
- };
33
-
34
- validateTitle(prTitle);
@@ -1,32 +0,0 @@
1
- name: CI
2
- on:
3
- push:
4
- branches: [main]
5
- pull_request:
6
- concurrency:
7
- group: ci-${{ github.ref }}
8
- cancel-in-progress: true
9
- jobs:
10
- lint:
11
- runs-on: blacksmith-2vcpu-ubuntu-2204
12
- steps:
13
- - uses: actions/checkout@v4
14
- - uses: oven-sh/setup-bun@v2
15
- - run: bun install --frozen-lockfile
16
- - run: bun run lint
17
-
18
- typecheck:
19
- runs-on: blacksmith-2vcpu-ubuntu-2204
20
- steps:
21
- - uses: actions/checkout@v4
22
- - uses: oven-sh/setup-bun@v2
23
- - run: bun install --frozen-lockfile
24
- - run: bun run typecheck
25
-
26
- test:
27
- runs-on: blacksmith-2vcpu-ubuntu-2204
28
- steps:
29
- - uses: actions/checkout@v4
30
- - uses: oven-sh/setup-bun@v2
31
- - run: bun install --frozen-lockfile
32
- - run: bun run test
@@ -1,13 +0,0 @@
1
- name: PR Title Check
2
- on:
3
- pull_request:
4
- types: [opened, edited, synchronize, reopened]
5
- permissions:
6
- contents: read
7
- pull-requests: read
8
- jobs:
9
- pr-title-check:
10
- runs-on: ubuntu-latest
11
- steps:
12
- - uses: actions/checkout@v4
13
- - run: node .github/scripts/pr-title-check.js
@@ -1,93 +0,0 @@
1
- name: Release
2
- on:
3
- push:
4
- tags:
5
- - 'v*'
6
- permissions:
7
- contents: write
8
- concurrency:
9
- group: release
10
- cancel-in-progress: false
11
- jobs:
12
- release:
13
- runs-on: blacksmith-2vcpu-ubuntu-2204
14
- steps:
15
- - uses: actions/checkout@v4
16
-
17
- - uses: oven-sh/setup-bun@v2
18
-
19
- - name: Install dependencies
20
- run: bun install --frozen-lockfile
21
-
22
- - name: Build binaries
23
- run: |
24
- bun build --compile --minify --sourcemap --bytecode src/cli.ts --target=bun-darwin-arm64 --outfile dist/resend-darwin-arm64
25
- bun build --compile --minify --sourcemap --bytecode src/cli.ts --target=bun-darwin-x64 --outfile dist/resend-darwin-x64
26
- bun build --compile --minify --sourcemap --bytecode src/cli.ts --target=bun-linux-x64 --outfile dist/resend-linux-x64
27
- bun build --compile --minify --sourcemap --bytecode src/cli.ts --target=bun-linux-arm64 --outfile dist/resend-linux-arm64
28
- bun build --compile --minify --sourcemap --bytecode src/cli.ts --target=bun-windows-x64 --outfile dist/resend-windows-x64.exe
29
-
30
- - name: Verify binaries
31
- run: |
32
- for f in dist/resend-darwin-arm64 dist/resend-darwin-x64 \
33
- dist/resend-linux-x64 dist/resend-linux-arm64 \
34
- dist/resend-windows-x64.exe; do
35
- [ -s "$f" ] || { echo "Missing or empty: $f"; exit 1; }
36
- done
37
-
38
- - name: Package archives
39
- run: |
40
- cd dist
41
- for bin in resend-darwin-arm64 resend-darwin-x64 resend-linux-x64 resend-linux-arm64; do
42
- cp "$bin" resend
43
- chmod +x resend
44
- tar -czf "${bin}.tar.gz" resend
45
- rm resend
46
- done
47
- cp resend-windows-x64.exe resend.exe
48
- zip resend-windows-x64.zip resend.exe
49
- rm resend.exe
50
-
51
- - name: Create GitHub Release
52
- uses: softprops/action-gh-release@v2
53
- with:
54
- generate_release_notes: true
55
- body: |
56
- ## Install
57
-
58
- **macOS / Linux**
59
- ```sh
60
- curl -fsSL https://resend.com/install.sh | bash
61
- ```
62
-
63
- **Windows (PowerShell)**
64
- ```pwsh
65
- irm https://resend.com/install.ps1 | iex
66
- ```
67
-
68
- **GitHub CLI** _(use while repo is private)_
69
- ```sh
70
- gh release download --repo resend/resend-cli --pattern "resend-darwin-arm64.tar.gz"
71
- tar -xzf resend-darwin-arm64.tar.gz && sudo mv resend /usr/local/bin/ && rm resend-darwin-arm64.tar.gz
72
- ```
73
- files: |
74
- dist/resend-darwin-arm64.tar.gz
75
- dist/resend-darwin-x64.tar.gz
76
- dist/resend-linux-x64.tar.gz
77
- dist/resend-linux-arm64.tar.gz
78
- dist/resend-windows-x64.zip
79
-
80
- notify-tap:
81
- name: Trigger Homebrew Tap Update
82
- needs: release
83
- runs-on: ubuntu-latest
84
- permissions:
85
- contents: read
86
- steps:
87
- - name: Dispatch publish-release workflow on tap
88
- env:
89
- GH_TOKEN: ${{ secrets.RELEASE_CLI_ON_HOMEBREW }}
90
- run: |
91
- gh workflow run publish-release.yml \
92
- --repo resend/homebrew-cli \
93
- --field version="${GITHUB_REF_NAME}"
@@ -1,31 +0,0 @@
1
- # Changelog
2
-
3
- ## [0.2.0] - 2026-02-18
4
-
5
- ### Added
6
-
7
- - `resend domains` — create, verify, get, list, update, delete sending domains
8
- - `resend api-keys` — create, list, delete API keys
9
- - `resend broadcasts` — full broadcast lifecycle (create, send, get, list, update, delete)
10
- - `resend contacts` — manage contacts, segments, and topics across all CRUD operations
11
- - `resend emails batch` — send up to 100 emails in a single request from a JSON file
12
- - Shared pagination (`--limit`, `--after`, `--before`) on all list commands
13
- - `--html-file` flag on `emails send` and `broadcasts create` to read body from a file
14
-
15
- ### Fixed
16
-
17
- - `isInteractive()` now checks both `stdin` and `stdout` TTY — CI environments are correctly detected as non-interactive
18
- - `domains delete` now returns a consistent `{ id, deleted: true }` object instead of an empty `{}`
19
-
20
- ### Changed
21
-
22
- - All delete commands return a uniform `{ object, id, deleted: true }` response
23
- - `--help` improved across all commands with output shape, error codes, and usage examples
24
-
25
- ---
26
-
27
- ## [0.1.0] - 2026-02-18
28
-
29
- - Initial release: `auth login`, `emails send`, `doctor`
30
- - Auto JSON output when stdout is not a TTY (`--json`)
31
- - Cross-platform binaries for macOS, Linux, and Windows via GitHub Actions