Skip to content

Commit 7199fc4

Browse files
authored
feat: add oauth-grants list and revoke commands (#340)
1 parent 5824671 commit 7199fc4

11 files changed

Lines changed: 522 additions & 8 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "resend-cli",
3-
"version": "2.7.0",
3+
"version": "2.8.0",
44
"description": "The official CLI for Resend",
55
"license": "MIT",
66
"repository": {
@@ -46,7 +46,7 @@
4646
"esbuild": "0.28.1",
4747
"esbuild-wasm": "0.28.0",
4848
"picocolors": "1.1.1",
49-
"resend": "6.14.0"
49+
"resend": "6.17.0"
5050
},
5151
"pkg": {
5252
"scripts": [

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ allowBuilds:
22
esbuild: true
33
'@biomejs/biome': true
44
minimumReleaseAgeExclude:
5-
- resend@6.14.0
5+
- resend@6.17.0

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { domainsCommand } from './commands/domains/index';
1717
import { emailsCommand } from './commands/emails/index';
1818
import { eventsCommand } from './commands/events/index';
1919
import { logsCommand } from './commands/logs/index';
20+
import { oauthGrantsCommand } from './commands/oauth-grants/index';
2021
import { openCommand } from './commands/open';
2122
import { segmentsCommand } from './commands/segments/index';
2223
import { templatesCommand } from './commands/templates/index';
@@ -141,6 +142,7 @@ ${pc.gray('Examples:')}
141142
.addCommand(logsCommand)
142143
.addCommand(apiKeysCommand)
143144
.addCommand(webhooksCommand)
145+
.addCommand(oauthGrantsCommand)
144146
.addCommand(authCommand)
145147
.addCommand(logoutCommand)
146148
.addCommand(whoamiCommand)

src/commands/oauth-grants/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Command } from '@commander-js/extra-typings';
2+
import { buildHelpText } from '../../lib/help-text';
3+
import { listOAuthGrantsCommand } from './list';
4+
import { revokeOAuthGrantCommand } from './revoke';
5+
6+
export const oauthGrantsCommand = new Command('oauth-grants')
7+
.description('Manage OAuth grants (apps authorized to act on the team)')
8+
.addHelpText(
9+
'after',
10+
buildHelpText({
11+
context: `An OAuth grant is a durable record of a client (app) authorized by the team.
12+
- Listing returns every grant, active and revoked. revoked_at/revoked_reason are null while active.
13+
- Revoking is immediate: all access and refresh tokens issued under the grant stop working.`,
14+
examples: [
15+
'resend oauth-grants list',
16+
'resend oauth-grants list --limit 25 --json',
17+
'resend oauth-grants revoke <id> --yes',
18+
],
19+
}),
20+
)
21+
.addCommand(listOAuthGrantsCommand, { isDefault: true })
22+
.addCommand(revokeOAuthGrantCommand);

src/commands/oauth-grants/list.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Command } from '@commander-js/extra-typings';
2+
import { runList } from '../../lib/actions';
3+
import type { GlobalOpts } from '../../lib/client';
4+
import { buildHelpText } from '../../lib/help-text';
5+
import {
6+
buildPaginationOpts,
7+
parseLimitOpt,
8+
printPaginationHint,
9+
} from '../../lib/pagination';
10+
import { renderOAuthGrantsTable } from './utils';
11+
12+
export const listOAuthGrantsCommand = new Command('list')
13+
.alias('ls')
14+
.description(
15+
"List OAuth grants for the team (both active and revoked), including each grant's client, scopes, and revocation status",
16+
)
17+
.option(
18+
'--limit <n>',
19+
'Maximum number of OAuth grants to return (1-100)',
20+
'10',
21+
)
22+
.option(
23+
'--after <cursor>',
24+
'Cursor for forward pagination — list items after this ID',
25+
)
26+
.option(
27+
'--before <cursor>',
28+
'Cursor for backward pagination — list items before this ID',
29+
)
30+
.addHelpText(
31+
'after',
32+
buildHelpText({
33+
output: ` {"object":"list","has_more":false,"data":[{"id":"<id>","client_id":"<id>","scopes":["<scope>"],"resource":"<url>|null","created_at":"<date>","revoked_at":"<date>|null","revoked_reason":"<reason>|null","client":{"name":"<name>","logo_uri":"<url>|null"}}]}
34+
Revoked grants have non-null revoked_at and revoked_reason.`,
35+
errorCodes: ['auth_error', 'invalid_limit', 'list_error'],
36+
examples: [
37+
'resend oauth-grants list',
38+
'resend oauth-grants list --limit 25 --json',
39+
'resend oauth-grants list --after <cursor> --json',
40+
],
41+
}),
42+
)
43+
.action(async (opts, cmd) => {
44+
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
45+
const limit = parseLimitOpt(opts.limit, globalOpts);
46+
const paginationOpts = buildPaginationOpts(
47+
limit,
48+
opts.after,
49+
opts.before,
50+
globalOpts,
51+
);
52+
await runList(
53+
{
54+
loading: 'Fetching OAuth grants...',
55+
sdkCall: (resend) => resend.oauthGrants.list(paginationOpts),
56+
onInteractive: (list) => {
57+
console.log(renderOAuthGrantsTable(list.data));
58+
printPaginationHint(list, 'oauth-grants list', {
59+
limit,
60+
before: opts.before,
61+
apiKey: globalOpts.apiKey,
62+
profile: globalOpts.profile,
63+
});
64+
},
65+
},
66+
globalOpts,
67+
);
68+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Command } from '@commander-js/extra-typings';
2+
import { runWrite } from '../../lib/actions';
3+
import type { GlobalOpts } from '../../lib/client';
4+
import { buildHelpText } from '../../lib/help-text';
5+
import { confirmDelete, pickItem } from '../../lib/prompts';
6+
import { oauthGrantPickerConfig } from './utils';
7+
8+
export const revokeOAuthGrantCommand = new Command('revoke')
9+
.description(
10+
'Revoke an OAuth grant — every access and refresh token issued under it stops working immediately',
11+
)
12+
.argument('[id]', 'OAuth grant ID')
13+
.option('--yes', 'Skip confirmation prompt')
14+
.addHelpText(
15+
'after',
16+
buildHelpText({
17+
context: `Non-interactive: --yes is required to confirm revocation when stdin/stdout is not a TTY.
18+
19+
Any team API key can revoke any of the team's grants. Revocation is immediate and
20+
irreversible — the client would need to re-authorize to regain access.`,
21+
output: ` {"object":"oauth_grant","id":"<id>","revoked_at":"<date>","revoked_reason":"<reason>"}`,
22+
errorCodes: ['auth_error', 'confirmation_required', 'revoke_error'],
23+
examples: [
24+
'resend oauth-grants revoke 650e8400-e29b-41d4-a716-446655440001 --yes',
25+
'resend oauth-grants revoke 650e8400-e29b-41d4-a716-446655440001 --yes --json',
26+
],
27+
}),
28+
)
29+
.action(async (idArg, opts, cmd) => {
30+
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
31+
const picked = await pickItem(idArg, oauthGrantPickerConfig, globalOpts);
32+
if (!opts.yes) {
33+
await confirmDelete(
34+
picked.id,
35+
`Revoke OAuth grant for "${picked.label}"?\nID: ${picked.id}\nEvery access and refresh token issued under it will stop working.`,
36+
globalOpts,
37+
);
38+
}
39+
await runWrite(
40+
{
41+
loading: 'Revoking OAuth grant...',
42+
sdkCall: (resend) => resend.oauthGrants.revoke(picked.id),
43+
errorCode: 'revoke_error',
44+
successMsg: 'OAuth grant revoked',
45+
},
46+
globalOpts,
47+
);
48+
});

src/commands/oauth-grants/utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { OAuthGrant } from 'resend';
2+
import type { PickerConfig } from '../../lib/prompts';
3+
import { renderTable } from '../../lib/table';
4+
5+
export const oauthGrantPickerConfig: PickerConfig<OAuthGrant> = {
6+
resource: 'OAuth grant',
7+
resourcePlural: 'OAuth grants',
8+
fetchItems: (resend, { limit, after }) =>
9+
resend.oauthGrants.list({ limit, ...(after && { after }) }),
10+
display: (g) => ({ label: g.client.name, hint: g.id }),
11+
};
12+
13+
export function renderOAuthGrantsTable(grants: OAuthGrant[]): string {
14+
const rows = grants.map((g) => [
15+
g.client.name,
16+
g.id,
17+
g.scopes.join(', '),
18+
g.created_at,
19+
g.revoked_at ?? '',
20+
]);
21+
return renderTable(
22+
['Client', 'ID', 'Scopes', 'Created', 'Revoked'],
23+
rows,
24+
'(no OAuth grants)',
25+
);
26+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import { captureTestEnv, setupOutputSpies } from '../../helpers';
3+
4+
const mockList = vi.fn(async () => ({
5+
data: {
6+
object: 'list',
7+
data: [
8+
{
9+
id: 'grant-id-1',
10+
client_id: 'client-id-1',
11+
scopes: ['emails:send'],
12+
resource: null,
13+
created_at: '2026-01-01T00:00:00.000Z',
14+
revoked_at: null,
15+
revoked_reason: null,
16+
client: { name: 'Resend CLI', logo_uri: null },
17+
},
18+
{
19+
id: 'grant-id-2',
20+
client_id: 'client-id-1',
21+
scopes: ['emails:send', 'domains:read'],
22+
resource: 'https://api.resend.com',
23+
created_at: '2026-01-02T00:00:00.000Z',
24+
revoked_at: '2026-01-03T00:00:00.000Z',
25+
revoked_reason: 'revoked_from_api',
26+
client: { name: 'Resend CLI', logo_uri: null },
27+
},
28+
],
29+
has_more: false,
30+
},
31+
error: null,
32+
}));
33+
34+
vi.mock('resend', () => ({
35+
Resend: class MockResend {
36+
constructor(public key: string) {}
37+
oauthGrants = { list: mockList };
38+
},
39+
}));
40+
41+
describe('oauth-grants list command', () => {
42+
const restoreEnv = captureTestEnv();
43+
let spies: ReturnType<typeof setupOutputSpies> | undefined;
44+
45+
beforeEach(() => {
46+
process.env.RESEND_API_KEY = 're_test_key';
47+
mockList.mockClear();
48+
});
49+
50+
afterEach(() => {
51+
restoreEnv();
52+
spies = undefined;
53+
});
54+
55+
function getFirstCallArgs(): unknown {
56+
const firstCall = mockList.mock.calls.at(0);
57+
if (!firstCall) {
58+
throw new Error('Expected mockList to be called at least once');
59+
}
60+
return firstCall[0];
61+
}
62+
63+
it('uses default limit of 10 when not specified', async () => {
64+
spies = setupOutputSpies();
65+
66+
const { listOAuthGrantsCommand } = await import(
67+
'../../../src/commands/oauth-grants/list'
68+
);
69+
await listOAuthGrantsCommand.parseAsync([], { from: 'user' });
70+
71+
expect(mockList).toHaveBeenCalledTimes(1);
72+
expect(getFirstCallArgs()).toMatchObject({ limit: 10 });
73+
});
74+
75+
it('outputs JSON list when non-interactive', async () => {
76+
spies = setupOutputSpies();
77+
78+
const { listOAuthGrantsCommand } = await import(
79+
'../../../src/commands/oauth-grants/list'
80+
);
81+
await listOAuthGrantsCommand.parseAsync([], { from: 'user' });
82+
83+
const output = (spies.logSpy.mock.calls[0] as unknown[])[0] as string;
84+
const parsed = JSON.parse(output);
85+
expect(parsed.object).toBe('list');
86+
expect(parsed.data).toHaveLength(2);
87+
expect(parsed.data[0].id).toBe('grant-id-1');
88+
expect(parsed.data[0].client.name).toBe('Resend CLI');
89+
expect(parsed.data[1].revoked_reason).toBe('revoked_from_api');
90+
});
91+
92+
it('passes limit to SDK', async () => {
93+
spies = setupOutputSpies();
94+
95+
const { listOAuthGrantsCommand } = await import(
96+
'../../../src/commands/oauth-grants/list'
97+
);
98+
await listOAuthGrantsCommand.parseAsync(['--limit', '25'], {
99+
from: 'user',
100+
});
101+
102+
expect(getFirstCallArgs()).toMatchObject({ limit: 25 });
103+
});
104+
105+
it('passes after cursor to SDK', async () => {
106+
spies = setupOutputSpies();
107+
108+
const { listOAuthGrantsCommand } = await import(
109+
'../../../src/commands/oauth-grants/list'
110+
);
111+
await listOAuthGrantsCommand.parseAsync(['--after', 'some-cursor'], {
112+
from: 'user',
113+
});
114+
115+
expect(getFirstCallArgs()).toMatchObject({ after: 'some-cursor' });
116+
});
117+
118+
it('passes before cursor to SDK', async () => {
119+
spies = setupOutputSpies();
120+
121+
const { listOAuthGrantsCommand } = await import(
122+
'../../../src/commands/oauth-grants/list'
123+
);
124+
await listOAuthGrantsCommand.parseAsync(['--before', 'some-cursor'], {
125+
from: 'user',
126+
});
127+
128+
expect(getFirstCallArgs()).toMatchObject({ before: 'some-cursor' });
129+
});
130+
});

0 commit comments

Comments
 (0)