resend-cli 1.2.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.
@@ -3,13 +3,46 @@ on:
3
3
  push:
4
4
  tags:
5
5
  - 'v*'
6
- permissions:
7
- contents: write
8
6
  concurrency:
9
7
  group: release
10
8
  cancel-in-progress: false
11
9
  jobs:
10
+ test-binary:
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ include:
15
+ - os: macos-latest
16
+ target: bun-darwin-arm64
17
+ binary: dist/resend
18
+ flags: "--bytecode"
19
+ - os: ubuntu-latest
20
+ target: bun-linux-x64
21
+ binary: dist/resend
22
+ flags: "--bytecode"
23
+ - os: windows-latest
24
+ target: bun-windows-x64
25
+ binary: dist/resend.exe
26
+ flags: ""
27
+ runs-on: ${{ matrix.os }}
28
+ steps:
29
+ - uses: actions/checkout@v6
30
+
31
+ - uses: oven-sh/setup-bun@v2
32
+
33
+ - name: Install dependencies
34
+ run: bun install --frozen-lockfile
35
+
36
+ - name: Build binary
37
+ run: bun build --compile --minify --sourcemap ${{ matrix.flags }} src/cli.ts --target=${{ matrix.target }} --outfile ${{ matrix.binary }}
38
+
39
+ - name: Verify binary runs
40
+ run: ${{ matrix.binary }} --version
41
+
12
42
  release:
43
+ needs: test-binary
44
+ permissions:
45
+ contents: write
13
46
  runs-on: blacksmith-2vcpu-ubuntu-2204
14
47
  steps:
15
48
  - uses: actions/checkout@v6
@@ -64,12 +97,6 @@ jobs:
64
97
  ```pwsh
65
98
  irm https://resend.com/install.ps1 | iex
66
99
  ```
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
100
  files: |
74
101
  dist/resend-darwin-arm64.tar.gz
75
102
  dist/resend-darwin-x64.tar.gz
package/README.md CHANGED
@@ -1,6 +1,17 @@
1
1
  # Resend CLI
2
2
 
3
- Command-line interface for the [Resend](https://resend.com) email API. Works for humans, AI agents, and CI/CD pipelines.
3
+ The official CLI for [Resend](https://resend.com).
4
+
5
+ Built for humans, AI agents, and CI/CD pipelines.
6
+
7
+ ```
8
+ ██████╗ ███████╗███████╗███████╗███╗ ██╗██████╗
9
+ ██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔══██╗
10
+ ██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║██║ ██║
11
+ ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║██║ ██║
12
+ ██║ ██║███████╗███████║███████╗██║ ╚████║██████╔╝
13
+ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═══╝╚═════╝
14
+ ```
4
15
 
5
16
  ## Install
6
17
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "resend-cli",
3
- "version": "1.2.0",
4
- "description": "Resend CLI — email for developers",
3
+ "version": "1.2.1",
4
+ "description": "The official CLI for Resend",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/cli.ts CHANGED
@@ -17,9 +17,19 @@ import { webhooksCommand } from './commands/webhooks/index';
17
17
  import { whoamiCommand } from './commands/whoami';
18
18
  import { PACKAGE_NAME, VERSION } from './lib/version';
19
19
 
20
+ const BANNER = `
21
+ ██████╗ ███████╗███████╗███████╗███╗ ██╗██████╗
22
+ ██╔══██╗██╔════╝██╔════╝██╔════╝████╗ ██║██╔══██╗
23
+ ██████╔╝█████╗ ███████╗█████╗ ██╔██╗ ██║██║ ██║
24
+ ██╔══██╗██╔══╝ ╚════██║██╔══╝ ██║╚██╗██║██║ ██║
25
+ ██║ ██║███████╗███████║███████╗██║ ╚████║██████╔╝
26
+ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═══╝╚═════╝
27
+ `;
28
+
20
29
  const program = new Command()
21
30
  .name('resend')
22
31
  .description('Resend CLI — email for developers')
32
+ .addHelpText('beforeAll', BANNER)
23
33
  .version(
24
34
  `${PACKAGE_NAME} v${VERSION}`,
25
35
  '-v, --version',
@@ -50,10 +60,8 @@ Output:
50
60
  Errors always exit with code 1: {"error":{"message":"...","code":"..."}}
51
61
 
52
62
  Examples:
53
- $ resend login --key re_123456789
54
- $ resend emails send --from you@domain.com --to user@example.com --subject "Hi" --text "Hello"
55
- $ resend emails batch --file ./emails.json --json
56
- $ resend doctor --json`,
63
+ $ resend login
64
+ $ resend emails send`,
57
65
  )
58
66
  .addCommand(loginCommand)
59
67
  .addCommand(logoutCommand)
@@ -3,13 +3,7 @@ import * as p from '@clack/prompts';
3
3
  import { Command } from '@commander-js/extra-typings';
4
4
  import { Resend } from 'resend';
5
5
  import type { GlobalOpts } from '../../lib/client';
6
- import {
7
- listTeams,
8
- resolveApiKey,
9
- setActiveTeam,
10
- storeApiKey,
11
- validateTeamName,
12
- } from '../../lib/config';
6
+ import { listTeams, resolveApiKey, storeApiKey } from '../../lib/config';
13
7
  import { buildHelpText } from '../../lib/help-text';
14
8
  import { errorMessage, outputError, outputResult } from '../../lib/output';
15
9
  import { cancelAndExit } from '../../lib/prompts';
@@ -156,17 +150,6 @@ export const loginCommand = new Command('login')
156
150
 
157
151
  let teamName = globalOpts.team;
158
152
 
159
- if (teamName) {
160
- const teamError = validateTeamName(teamName);
161
- if (teamError) {
162
- outputError(
163
- { message: teamError, code: 'invalid_team_name' },
164
- { json: globalOpts.json },
165
- );
166
- return;
167
- }
168
- }
169
-
170
153
  if (!teamName && isInteractive()) {
171
154
  const existingTeams = listTeams();
172
155
  if (existingTeams.length > 0) {
@@ -190,7 +173,8 @@ export const loginCommand = new Command('login')
190
173
  if (choice === '__new__') {
191
174
  const newName = await p.text({
192
175
  message: 'Enter a name for the new team:',
193
- validate: (v) => validateTeamName(v),
176
+ validate: (v) =>
177
+ !v || v.length === 0 ? 'Team name is required' : undefined,
194
178
  });
195
179
  if (p.isCancel(newName)) {
196
180
  cancelAndExit('Login cancelled.');
@@ -207,15 +191,6 @@ export const loginCommand = new Command('login')
207
191
  const configPath = storeApiKey(apiKey, teamName);
208
192
  const teamLabel = teamName || 'default';
209
193
 
210
- // Auto-switch to the newly added team
211
- if (teamName) {
212
- try {
213
- setActiveTeam(teamName);
214
- } catch {
215
- // Team was just stored, so this should not fail
216
- }
217
- }
218
-
219
194
  if (globalOpts.json) {
220
195
  outputResult(
221
196
  { success: true, config_path: configPath, team: teamLabel },
@@ -66,8 +66,8 @@ async function checkCliVersion(): Promise<CheckResult> {
66
66
  }
67
67
  }
68
68
 
69
- function checkApiKeyPresence(flagValue?: string): CheckResult {
70
- const resolved = resolveApiKey(flagValue);
69
+ function checkApiKeyPresence(): CheckResult {
70
+ const resolved = resolveApiKey();
71
71
  if (!resolved) {
72
72
  return {
73
73
  name: 'API Key',
@@ -246,7 +246,7 @@ export const doctorCommand = new Command('doctor')
246
246
 
247
247
  // Check 2: API Key
248
248
  spinner = interactive ? createSpinner('Checking API key...', 'scan') : null;
249
- const keyCheck = checkApiKeyPresence(globalOpts.apiKey);
249
+ const keyCheck = checkApiKeyPresence();
250
250
  checks.push(keyCheck);
251
251
  if (keyCheck.status === 'fail') {
252
252
  spinner?.fail(keyCheck.message);
@@ -1,7 +1,7 @@
1
1
  import * as p from '@clack/prompts';
2
2
  import { Command } from '@commander-js/extra-typings';
3
3
  import type { GlobalOpts } from '../../lib/client';
4
- import { listTeams, removeApiKey } from '../../lib/config';
4
+ import { listTeams, removeTeam } from '../../lib/config';
5
5
  import { errorMessage, outputError, outputResult } from '../../lib/output';
6
6
  import { cancelAndExit } from '../../lib/prompts';
7
7
  import { isInteractive } from '../../lib/tty';
@@ -24,7 +24,6 @@ export const removeCommand = new Command('remove')
24
24
  },
25
25
  { json: globalOpts.json },
26
26
  );
27
- return;
28
27
  }
29
28
 
30
29
  const teams = listTeams();
@@ -36,7 +35,6 @@ export const removeCommand = new Command('remove')
36
35
  },
37
36
  { json: globalOpts.json },
38
37
  );
39
- return;
40
38
  }
41
39
 
42
40
  const choice = await p.select({
@@ -66,7 +64,7 @@ export const removeCommand = new Command('remove')
66
64
  }
67
65
 
68
66
  try {
69
- removeApiKey(teamName);
67
+ removeTeam(teamName);
70
68
  } catch (err) {
71
69
  outputError(
72
70
  {
@@ -75,7 +73,6 @@ export const removeCommand = new Command('remove')
75
73
  },
76
74
  { json: globalOpts.json },
77
75
  );
78
- return;
79
76
  }
80
77
 
81
78
  if (globalOpts.json) {
@@ -24,7 +24,6 @@ export const switchCommand = new Command('switch')
24
24
  },
25
25
  { json: globalOpts.json },
26
26
  );
27
- return;
28
27
  }
29
28
 
30
29
  const teams = listTeams();
@@ -36,7 +35,6 @@ export const switchCommand = new Command('switch')
36
35
  },
37
36
  { json: globalOpts.json },
38
37
  );
39
- return;
40
38
  }
41
39
 
42
40
  const choice = await p.select({
@@ -65,7 +63,6 @@ export const switchCommand = new Command('switch')
65
63
  },
66
64
  { json: globalOpts.json },
67
65
  );
68
- return;
69
66
  }
70
67
 
71
68
  if (globalOpts.json) {
package/src/lib/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Resend } from 'resend';
2
2
  import { resolveApiKey } from './config';
3
3
  import { errorMessage, outputError } from './output';
4
+ import { VERSION } from './version';
4
5
 
5
6
  export type GlobalOpts = {
6
7
  apiKey?: string;
@@ -9,6 +10,8 @@ export type GlobalOpts = {
9
10
  team?: string;
10
11
  };
11
12
 
13
+ process.env.RESEND_USER_AGENT = `resend-cli:${VERSION}`;
14
+
12
15
  export function createClient(flagValue?: string, teamName?: string): Resend {
13
16
  const resolved = resolveApiKey(flagValue, teamName);
14
17
  if (!resolved) {
package/src/lib/config.ts CHANGED
@@ -1,11 +1,4 @@
1
- import {
2
- chmodSync,
3
- existsSync,
4
- mkdirSync,
5
- readFileSync,
6
- unlinkSync,
7
- writeFileSync,
8
- } from 'node:fs';
1
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
2
  import { homedir } from 'node:os';
10
3
  import { join } from 'node:path';
11
4
 
@@ -109,10 +102,6 @@ export function resolveApiKey(
109
102
 
110
103
  export function storeApiKey(apiKey: string, teamName?: string): string {
111
104
  const team = teamName || 'default';
112
- const validationError = validateTeamName(team);
113
- if (validationError) {
114
- throw new Error(validationError);
115
- }
116
105
  const creds = readCredentials() || { active_team: 'default', teams: {} };
117
106
 
118
107
  creds.teams[team] = { api_key: apiKey };
@@ -127,6 +116,7 @@ export function storeApiKey(apiKey: string, teamName?: string): string {
127
116
 
128
117
  export function removeAllApiKeys(): string {
129
118
  const configPath = getCredentialsPath();
119
+ const { unlinkSync } = require('node:fs');
130
120
  unlinkSync(configPath);
131
121
  return configPath;
132
122
  }
@@ -135,20 +125,13 @@ export function removeApiKey(teamName?: string): string {
135
125
  const creds = readCredentials();
136
126
  if (!creds) {
137
127
  const configPath = getCredentialsPath();
138
- if (!existsSync(configPath)) {
139
- throw new Error('No credentials file found.');
140
- }
141
128
  // Try to delete legacy file
129
+ const { unlinkSync } = require('node:fs');
142
130
  unlinkSync(configPath);
143
131
  return configPath;
144
132
  }
145
133
 
146
134
  const team = teamName || resolveTeamName();
147
- if (!creds.teams[team]) {
148
- throw new Error(
149
- `Team "${team}" not found. Available teams: ${Object.keys(creds.teams).join(', ')}`,
150
- );
151
- }
152
135
  delete creds.teams[team];
153
136
 
154
137
  // If we removed the active team, switch to first available or "default"
@@ -159,6 +142,7 @@ export function removeApiKey(teamName?: string): string {
159
142
 
160
143
  // If no teams left, delete the file
161
144
  if (Object.keys(creds.teams).length === 0) {
145
+ const { unlinkSync } = require('node:fs');
162
146
  const configPath = getCredentialsPath();
163
147
  unlinkSync(configPath);
164
148
  return configPath;
@@ -168,10 +152,6 @@ export function removeApiKey(teamName?: string): string {
168
152
  }
169
153
 
170
154
  export function setActiveTeam(teamName: string): void {
171
- const validationError = validateTeamName(teamName);
172
- if (validationError) {
173
- throw new Error(validationError);
174
- }
175
155
  const creds = readCredentials();
176
156
  if (!creds) {
177
157
  throw new Error('No credentials file found. Run: resend login');
@@ -196,19 +176,6 @@ export function listTeams(): Array<{ name: string; active: boolean }> {
196
176
  }));
197
177
  }
198
178
 
199
- export function validateTeamName(name: string): string | undefined {
200
- if (!name || name.length === 0) {
201
- return 'Team name must not be empty';
202
- }
203
- if (name.length > 64) {
204
- return 'Team name must be 64 characters or fewer';
205
- }
206
- if (!/^[a-z0-9_-]+$/.test(name)) {
207
- return 'Team name must contain only lowercase letters, numbers, dashes, and underscores';
208
- }
209
- return undefined;
210
- }
211
-
212
179
  export function maskKey(key: string): string {
213
180
  if (key.length <= 7) {
214
181
  return `${key.slice(0, 3)}...`;
@@ -216,3 +183,29 @@ export function maskKey(key: string): string {
216
183
  return `${key.slice(0, 3)}...${key.slice(-4)}`;
217
184
  }
218
185
 
186
+ export function removeTeam(teamName: string): void {
187
+ const creds = readCredentials();
188
+ if (!creds) {
189
+ throw new Error('No credentials file found.');
190
+ }
191
+ if (!creds.teams[teamName]) {
192
+ throw new Error(
193
+ `Team "${teamName}" not found. Available teams: ${Object.keys(creds.teams).join(', ')}`,
194
+ );
195
+ }
196
+
197
+ delete creds.teams[teamName];
198
+
199
+ if (creds.active_team === teamName) {
200
+ const remaining = Object.keys(creds.teams);
201
+ creds.active_team = remaining[0] || 'default';
202
+ }
203
+
204
+ if (Object.keys(creds.teams).length === 0) {
205
+ const { unlinkSync } = require('node:fs');
206
+ unlinkSync(getCredentialsPath());
207
+ return;
208
+ }
209
+
210
+ writeCredentials(creds);
211
+ }
@@ -116,39 +116,4 @@ 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
- // This test must be last — addCommand permanently modifies the shared loginCommand singleton
121
- test('auto-switches to team specified via --team flag', async () => {
122
- spies = setupOutputSpies();
123
-
124
- const { Command } = await import('@commander-js/extra-typings');
125
- const { loginCommand } = await import('../../../src/commands/auth/login');
126
- const program = new Command()
127
- .option('--team <name>')
128
- .option('--json')
129
- .option('--api-key <key>')
130
- .option('-q, --quiet')
131
- .addCommand(loginCommand);
132
-
133
- // First store a default key
134
- const configDir = join(tmpDir, 'resend');
135
- mkdirSync(configDir, { recursive: true });
136
- writeFileSync(
137
- join(configDir, 'credentials.json'),
138
- JSON.stringify({
139
- active_team: 'default',
140
- teams: { default: { api_key: 're_old_key_1234' } },
141
- }),
142
- );
143
-
144
- await program.parseAsync(
145
- ['login', '--key', 're_staging_key_123', '--team', 'staging'],
146
- { from: 'user' },
147
- );
148
-
149
- const configPath = join(tmpDir, 'resend', 'credentials.json');
150
- const data = JSON.parse(readFileSync(configPath, 'utf-8'));
151
- expect(data.active_team).toBe('staging');
152
- expect(data.teams.staging.api_key).toBe('re_staging_key_123');
153
- });
154
119
  });
@@ -5,12 +5,11 @@ import { join } from 'node:path';
5
5
  import {
6
6
  getConfigDir,
7
7
  listTeams,
8
- removeApiKey,
8
+ removeTeam,
9
9
  resolveApiKey,
10
10
  resolveTeamName,
11
11
  setActiveTeam,
12
12
  storeApiKey,
13
- validateTeamName,
14
13
  } from '../../src/lib/config';
15
14
  import { captureTestEnv } from '../helpers';
16
15
 
@@ -355,7 +354,7 @@ describe('setActiveTeam', () => {
355
354
  });
356
355
  });
357
356
 
358
- describe('removeApiKey', () => {
357
+ describe('removeTeam', () => {
359
358
  const restoreEnv = captureTestEnv();
360
359
  let tmpDir: string;
361
360
 
@@ -376,7 +375,7 @@ describe('removeApiKey', () => {
376
375
  storeApiKey('re_default', 'default');
377
376
  storeApiKey('re_staging', 'staging');
378
377
 
379
- removeApiKey('staging');
378
+ removeTeam('staging');
380
379
 
381
380
  const teams = listTeams();
382
381
  expect(teams).toEqual([{ name: 'default', active: true }]);
@@ -387,7 +386,7 @@ describe('removeApiKey', () => {
387
386
  storeApiKey('re_staging', 'staging');
388
387
  setActiveTeam('staging');
389
388
 
390
- removeApiKey('staging');
389
+ removeTeam('staging');
391
390
 
392
391
  const configPath = join(tmpDir, 'resend', 'credentials.json');
393
392
  const data = JSON.parse(readFileSync(configPath, 'utf-8'));
@@ -397,7 +396,7 @@ describe('removeApiKey', () => {
397
396
  test('deletes file when last team removed', () => {
398
397
  storeApiKey('re_only', 'only');
399
398
 
400
- removeApiKey('only');
399
+ removeTeam('only');
401
400
 
402
401
  const { existsSync } = require('node:fs');
403
402
  const configPath = join(tmpDir, 'resend', 'credentials.json');
@@ -406,42 +405,10 @@ describe('removeApiKey', () => {
406
405
 
407
406
  test('throws when team does not exist', () => {
408
407
  storeApiKey('re_default');
409
- expect(() => removeApiKey('nonexistent')).toThrow('not found');
408
+ expect(() => removeTeam('nonexistent')).toThrow('not found');
410
409
  });
411
410
 
412
411
  test('throws when 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
- });
424
-
425
- test('rejects uppercase characters', () => {
426
- expect(validateTeamName('Production')).toContain('lowercase');
427
- });
428
-
429
- test('rejects spaces and special characters', () => {
430
- expect(validateTeamName('my team')).toContain('lowercase');
431
- expect(validateTeamName('team@org')).toContain('lowercase');
432
- });
433
-
434
- test('rejects empty name', () => {
435
- expect(validateTeamName('')).toContain('empty');
436
- });
437
-
438
- test('rejects names longer than 64 characters', () => {
439
- const longName = 'a'.repeat(65);
440
- expect(validateTeamName(longName)).toContain('64');
441
- });
442
-
443
- test('accepts name exactly 64 characters', () => {
444
- const maxName = 'a'.repeat(64);
445
- expect(validateTeamName(maxName)).toBeUndefined();
412
+ expect(() => removeTeam('any')).toThrow('No credentials file');
446
413
  });
447
414
  });
@@ -1,44 +0,0 @@
1
- name: Test Windows Build
2
-
3
- on:
4
- push:
5
- branches:
6
- - main
7
- pull_request:
8
- paths:
9
- - src/**
10
- - .github/workflows/test-build-windows.yml
11
- - .github/workflows/release.yml
12
- - bun.lock
13
- workflow_dispatch:
14
-
15
- jobs:
16
- build:
17
- runs-on: windows-latest
18
- steps:
19
- - uses: actions/checkout@v6
20
-
21
- - uses: oven-sh/setup-bun@v2
22
-
23
- - name: Install dependencies
24
- run: bun install --frozen-lockfile
25
-
26
- - name: Build Windows binary
27
- run: bun build --compile --minify --sourcemap src/cli.ts --target=bun-windows-x64 --outfile dist/resend-windows-x64.exe
28
-
29
- - name: Verify binary exists
30
- shell: pwsh
31
- run: |
32
- if (-not (Test-Path dist\resend-windows-x64.exe)) {
33
- Write-Error "Binary not found"
34
- exit 1
35
- }
36
-
37
- - name: Verify binary runs
38
- shell: pwsh
39
- run: |
40
- & dist\resend-windows-x64.exe --version
41
- if ($LASTEXITCODE -ne 0) {
42
- Write-Error "resend --version failed"
43
- exit 1
44
- }