resend-cli 1.0.3 → 1.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.
- package/.claude/settings.local.json +14 -0
- package/.github/scripts/pr-title-check.js +34 -0
- package/.github/workflows/ci.yml +32 -0
- package/.github/workflows/pr-title-check.yml +13 -0
- package/.github/workflows/release.yml +93 -0
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -21
- package/README.md +416 -19
- package/biome.json +36 -0
- package/bun.lock +76 -0
- package/bunfig.toml +2 -0
- package/install.ps1 +140 -0
- package/install.sh +294 -0
- package/package.json +43 -22
- package/src/cli.ts +65 -0
- package/src/commands/api-keys/create.ts +114 -0
- package/src/commands/api-keys/delete.ts +47 -0
- package/src/commands/api-keys/index.ts +26 -0
- package/src/commands/api-keys/list.ts +35 -0
- package/src/commands/api-keys/utils.ts +8 -0
- package/src/commands/auth/index.ts +20 -0
- package/src/commands/auth/login.ts +211 -0
- package/src/commands/auth/logout.ts +105 -0
- package/src/commands/broadcasts/create.ts +196 -0
- package/src/commands/broadcasts/delete.ts +46 -0
- package/src/commands/broadcasts/get.ts +59 -0
- package/src/commands/broadcasts/index.ts +43 -0
- package/src/commands/broadcasts/list.ts +60 -0
- package/src/commands/broadcasts/send.ts +56 -0
- package/src/commands/broadcasts/update.ts +95 -0
- package/src/commands/broadcasts/utils.ts +35 -0
- package/src/commands/contact-properties/create.ts +118 -0
- package/src/commands/contact-properties/delete.ts +48 -0
- package/src/commands/contact-properties/get.ts +46 -0
- package/src/commands/contact-properties/index.ts +48 -0
- package/src/commands/contact-properties/list.ts +68 -0
- package/src/commands/contact-properties/update.ts +88 -0
- package/src/commands/contact-properties/utils.ts +17 -0
- package/src/commands/contacts/add-segment.ts +78 -0
- package/src/commands/contacts/create.ts +122 -0
- package/src/commands/contacts/delete.ts +49 -0
- package/src/commands/contacts/get.ts +53 -0
- package/src/commands/contacts/index.ts +58 -0
- package/src/commands/contacts/list.ts +57 -0
- package/src/commands/contacts/remove-segment.ts +48 -0
- package/src/commands/contacts/segments.ts +39 -0
- package/src/commands/contacts/topics.ts +45 -0
- package/src/commands/contacts/update-topics.ts +90 -0
- package/src/commands/contacts/update.ts +77 -0
- package/src/commands/contacts/utils.ts +119 -0
- package/src/commands/doctor.ts +298 -0
- package/src/commands/domains/create.ts +83 -0
- package/src/commands/domains/delete.ts +42 -0
- package/src/commands/domains/get.ts +47 -0
- package/src/commands/domains/index.ts +35 -0
- package/src/commands/domains/list.ts +53 -0
- package/src/commands/domains/update.ts +75 -0
- package/src/commands/domains/utils.ts +44 -0
- package/src/commands/domains/verify.ts +38 -0
- package/src/commands/emails/batch.ts +140 -0
- package/src/commands/emails/index.ts +24 -0
- package/src/commands/emails/receiving/attachment.ts +55 -0
- package/src/commands/emails/receiving/attachments.ts +68 -0
- package/src/commands/emails/receiving/get.ts +58 -0
- package/src/commands/emails/receiving/index.ts +28 -0
- package/src/commands/emails/receiving/list.ts +59 -0
- package/src/commands/emails/receiving/utils.ts +38 -0
- package/src/commands/emails/send.ts +189 -0
- package/src/commands/segments/create.ts +50 -0
- package/src/commands/segments/delete.ts +47 -0
- package/src/commands/segments/get.ts +38 -0
- package/src/commands/segments/index.ts +36 -0
- package/src/commands/segments/list.ts +58 -0
- package/src/commands/segments/utils.ts +7 -0
- package/src/commands/teams/index.ts +10 -0
- package/src/commands/teams/list.ts +35 -0
- package/src/commands/teams/remove.ts +83 -0
- package/src/commands/teams/switch.ts +73 -0
- package/src/commands/topics/create.ts +73 -0
- package/src/commands/topics/delete.ts +47 -0
- package/src/commands/topics/get.ts +42 -0
- package/src/commands/topics/index.ts +42 -0
- package/src/commands/topics/list.ts +34 -0
- package/src/commands/topics/update.ts +59 -0
- package/src/commands/topics/utils.ts +16 -0
- package/src/commands/webhooks/create.ts +128 -0
- package/src/commands/webhooks/delete.ts +49 -0
- package/src/commands/webhooks/get.ts +42 -0
- package/src/commands/webhooks/index.ts +44 -0
- package/src/commands/webhooks/list.ts +55 -0
- package/src/commands/webhooks/update.ts +83 -0
- package/src/commands/webhooks/utils.ts +36 -0
- package/src/commands/whoami.ts +71 -0
- package/src/lib/actions.ts +157 -0
- package/src/lib/client.ts +29 -0
- package/src/lib/config.ts +211 -0
- package/src/lib/files.ts +15 -0
- package/src/lib/help-text.ts +36 -0
- package/src/lib/output.ts +54 -0
- package/src/lib/pagination.ts +36 -0
- package/src/lib/prompts.ts +149 -0
- package/src/lib/spinner.ts +89 -0
- package/src/lib/table.ts +57 -0
- package/src/lib/tty.ts +28 -0
- package/src/lib/version.ts +4 -0
- package/tests/commands/api-keys/create.test.ts +195 -0
- package/tests/commands/api-keys/delete.test.ts +156 -0
- package/tests/commands/api-keys/list.test.ts +133 -0
- package/tests/commands/auth/login.test.ts +119 -0
- package/tests/commands/auth/logout.test.ts +146 -0
- package/tests/commands/broadcasts/create.test.ts +447 -0
- package/tests/commands/broadcasts/delete.test.ts +182 -0
- package/tests/commands/broadcasts/get.test.ts +146 -0
- package/tests/commands/broadcasts/list.test.ts +196 -0
- package/tests/commands/broadcasts/send.test.ts +161 -0
- package/tests/commands/broadcasts/update.test.ts +283 -0
- package/tests/commands/contact-properties/create.test.ts +250 -0
- package/tests/commands/contact-properties/delete.test.ts +183 -0
- package/tests/commands/contact-properties/get.test.ts +144 -0
- package/tests/commands/contact-properties/list.test.ts +180 -0
- package/tests/commands/contact-properties/update.test.ts +216 -0
- package/tests/commands/contacts/add-segment.test.ts +188 -0
- package/tests/commands/contacts/create.test.ts +270 -0
- package/tests/commands/contacts/delete.test.ts +192 -0
- package/tests/commands/contacts/get.test.ts +148 -0
- package/tests/commands/contacts/list.test.ts +175 -0
- package/tests/commands/contacts/remove-segment.test.ts +166 -0
- package/tests/commands/contacts/segments.test.ts +167 -0
- package/tests/commands/contacts/topics.test.ts +163 -0
- package/tests/commands/contacts/update-topics.test.ts +247 -0
- package/tests/commands/contacts/update.test.ts +205 -0
- package/tests/commands/doctor.test.ts +165 -0
- package/tests/commands/domains/create.test.ts +192 -0
- package/tests/commands/domains/delete.test.ts +156 -0
- package/tests/commands/domains/get.test.ts +137 -0
- package/tests/commands/domains/list.test.ts +164 -0
- package/tests/commands/domains/update.test.ts +223 -0
- package/tests/commands/domains/verify.test.ts +117 -0
- package/tests/commands/emails/batch.test.ts +313 -0
- package/tests/commands/emails/receiving/attachment.test.ts +140 -0
- package/tests/commands/emails/receiving/attachments.test.ts +168 -0
- package/tests/commands/emails/receiving/get.test.ts +140 -0
- package/tests/commands/emails/receiving/list.test.ts +181 -0
- package/tests/commands/emails/send.test.ts +309 -0
- package/tests/commands/segments/create.test.ts +163 -0
- package/tests/commands/segments/delete.test.ts +182 -0
- package/tests/commands/segments/get.test.ts +137 -0
- package/tests/commands/segments/list.test.ts +173 -0
- package/tests/commands/teams/list.test.ts +63 -0
- package/tests/commands/teams/remove.test.ts +103 -0
- package/tests/commands/teams/switch.test.ts +96 -0
- package/tests/commands/topics/create.test.ts +191 -0
- package/tests/commands/topics/delete.test.ts +156 -0
- package/tests/commands/topics/get.test.ts +125 -0
- package/tests/commands/topics/list.test.ts +124 -0
- package/tests/commands/topics/update.test.ts +177 -0
- package/tests/commands/webhooks/create.test.ts +224 -0
- package/tests/commands/webhooks/delete.test.ts +156 -0
- package/tests/commands/webhooks/get.test.ts +125 -0
- package/tests/commands/webhooks/list.test.ts +177 -0
- package/tests/commands/webhooks/update.test.ts +206 -0
- package/tests/commands/whoami.test.ts +99 -0
- package/tests/helpers.ts +93 -0
- package/tests/lib/client.test.ts +71 -0
- package/tests/lib/config.test.ts +414 -0
- package/tests/lib/files.test.ts +65 -0
- package/tests/lib/help-text.test.ts +96 -0
- package/tests/lib/output.test.ts +127 -0
- package/tests/lib/prompts.test.ts +178 -0
- package/tests/lib/spinner.test.ts +146 -0
- package/tests/lib/table.test.ts +63 -0
- package/tests/lib/tty.test.ts +85 -0
- package/tsconfig.json +14 -0
- package/src/index.js +0 -72
- package/src/routes.js +0 -37
- package/src/sections/apikeys.js +0 -99
- package/src/sections/audiences.js +0 -84
- package/src/sections/contacts.js +0 -177
- package/src/sections/domain.js +0 -195
- package/src/sections/email.js +0 -132
|
@@ -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
|
+
});
|