diff --git a/package.json b/package.json index e1f870ab..b62e375f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "resend-cli", - "version": "1.11.0", + "version": "2.0.0", "description": "The official CLI for Resend", "license": "MIT", "repository": { diff --git a/skills/resend-cli/SKILL.md b/skills/resend-cli/SKILL.md index 54036f9d..b5351aca 100644 --- a/skills/resend-cli/SKILL.md +++ b/skills/resend-cli/SKILL.md @@ -11,7 +11,7 @@ description: > license: MIT metadata: author: resend - version: "1.12.0" + version: "2.0.0" homepage: https://resend.com/docs/cli-agents source: https://github.com/resend/resend-cli openclaw: diff --git a/src/cli.ts b/src/cli.ts index cfee3986..d08741b9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -19,7 +19,6 @@ import { eventsCommand } from './commands/events/index'; import { logsCommand } from './commands/logs/index'; import { openCommand } from './commands/open'; import { segmentsCommand } from './commands/segments/index'; -import { teamsDeprecatedCommand } from './commands/teams-deprecated'; import { templatesCommand } from './commands/templates/index'; import { topicsCommand } from './commands/topics/index'; import { updateCommand } from './commands/update'; @@ -58,7 +57,6 @@ const program = new Command() ) .option('--api-key ', 'Resend API key (overrides env/config)') .option('-p, --profile ', 'Profile to use (overrides RESEND_PROFILE)') - .option('--team ', 'Deprecated: use --profile instead') .option('--json', 'Force JSON output') .option('-q, --quiet', 'Suppress spinners and status output (implies --json)') .option( @@ -149,7 +147,6 @@ ${pc.gray('Examples:')} .addCommand(openCommand) .addCommand(docsCommand) .addCommand(updateCommand) - .addCommand(teamsDeprecatedCommand) .addCommand(listCommandsCommand) .addCommand(completionCommand); @@ -167,12 +164,6 @@ telemetryCommand program.addCommand(telemetryCommand, { hidden: true }); -// Hide the deprecated --team option from help -const teamOption = program.options.find((o) => o.long === '--team'); -if (teamOption) { - teamOption.hidden = true; -} - program .parseAsync() .then(() => { diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 47a13248..782399e0 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -157,8 +157,7 @@ export const loginCommand = new Command('login') ); } - let profileName = - (globalOpts.profile ?? globalOpts.team)?.trim() || undefined; + let profileName = globalOpts.profile?.trim() || undefined; if (profileName) { const profileError = validateProfileName(profileName); diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index e91ea94e..e3bf2db1 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -53,7 +53,7 @@ If no credentials file exists, exits cleanly with no error.`, return; } - const profileFlag = globalOpts.profile ?? globalOpts.team; + const profileFlag = globalOpts.profile; const logoutAll = !profileFlag; const profileLabel = profileFlag || resolveProfileName(); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 52dae9e7..082035eb 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -238,8 +238,7 @@ export const doctorCommand = new Command('doctor') ...(!usingSecure && process.env.RESEND_CREDENTIAL_STORE !== 'file' && (creds?.storage === 'secure_storage' || - process.env.RESEND_CREDENTIAL_STORE === 'secure_storage' || - process.env.RESEND_CREDENTIAL_STORE === 'keychain') + process.env.RESEND_CREDENTIAL_STORE === 'secure_storage') ? { detail: 'Secure backend unavailable despite secure storage preference — falling back to plaintext', diff --git a/src/commands/teams-deprecated.ts b/src/commands/teams-deprecated.ts deleted file mode 100644 index a56c2934..00000000 --- a/src/commands/teams-deprecated.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Command } from '@commander-js/extra-typings'; -import type { GlobalOpts } from '../lib/client'; -import { listAction } from './auth/list'; -import { removeAction } from './auth/remove'; -import { switchAction } from './auth/switch'; - -function warnDeprecated(globalOpts: GlobalOpts) { - if (globalOpts.json || globalOpts.quiet) { - return; - } - process.stderr.write( - 'Warning: "resend teams" is deprecated. Use "resend auth" instead.\n', - ); -} - -const deprecatedListCommand = new Command('list') - .description('List all profiles') - .action((_opts, cmd) => { - const globalOpts = cmd.optsWithGlobals() as GlobalOpts; - warnDeprecated(globalOpts); - listAction(globalOpts); - }); - -const deprecatedSwitchCommand = new Command('switch') - .description('Switch the active profile') - .argument('[name]', 'Profile name to switch to') - .action(async (name, _opts, cmd) => { - const globalOpts = cmd.optsWithGlobals() as GlobalOpts; - warnDeprecated(globalOpts); - await switchAction(name, globalOpts); - }); - -const deprecatedRemoveCommand = new Command('remove') - .description('Remove a profile') - .argument('[name]', 'Profile name to remove') - .action(async (name, _opts, cmd) => { - const globalOpts = cmd.optsWithGlobals() as GlobalOpts; - warnDeprecated(globalOpts); - await removeAction(name, globalOpts); - }); - -export const teamsDeprecatedCommand = new Command('teams') - .description('(deprecated) Use "resend auth" instead') - .addCommand(deprecatedListCommand) - .addCommand(deprecatedSwitchCommand) - .addCommand(deprecatedRemoveCommand); - -// Hide from --help output (Commander's extra-typings doesn't expose .hidden()) -(teamsDeprecatedCommand as unknown as { _hidden: boolean })._hidden = true; diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 49b97dc1..79b90a60 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -31,7 +31,7 @@ Shows which profile is active and where the API key comes from.`, ) .action(async (_opts, cmd) => { const globalOpts = cmd.optsWithGlobals() as GlobalOpts; - const profileFlag = globalOpts.profile ?? globalOpts.team; + const profileFlag = globalOpts.profile; const resolved = await resolveApiKeyAsync(globalOpts.apiKey, profileFlag); if (!resolved) { @@ -40,8 +40,7 @@ Shows which profile is active and where the API key comes from.`, : resolveProfileName(profileFlag); const profiles = listProfiles(); const profileExists = profiles.some((p) => p.name === requestedProfile); - const explicitProfile = - profileFlag || process.env.RESEND_PROFILE || process.env.RESEND_TEAM; + const explicitProfile = profileFlag || process.env.RESEND_PROFILE; // If a specific profile was requested but doesn't exist, show a targeted error const message = diff --git a/src/lib/client.ts b/src/lib/client.ts index 0235c1ff..75d07c1a 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -13,8 +13,6 @@ export type GlobalOpts = { json?: boolean; quiet?: boolean; profile?: string; - /** @deprecated Use `profile` instead */ - team?: string; }; export type RequireClientOpts = { @@ -59,7 +57,7 @@ export async function requireClient( opts: GlobalOpts, clientOpts?: RequireClientOpts, ): Promise { - const profileName = opts.profile ?? opts.team; + const profileName = opts.profile; try { const resolved = await resolveApiKeyAsync(opts.apiKey, profileName); diff --git a/src/lib/config.ts b/src/lib/config.ts index 6a5d6303..aad6f08a 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -34,9 +34,6 @@ export type CredentialsFile = { profiles: Record; }; -/** @deprecated Use `Profile` instead */ -export type TeamProfile = Profile; - export function getConfigDir(): string { if (process.env.XDG_CONFIG_HOME) { return join(process.env.XDG_CONFIG_HOME, 'resend'); @@ -54,30 +51,13 @@ export function getCredentialsPath(): string { export function readCredentials(): CredentialsFile | null { try { const data = JSON.parse(readFileSync(getCredentialsPath(), 'utf-8')); - // Support legacy format: { api_key: "re_xxx" } - if (data.api_key && !data.profiles && !data.teams) { - return { - active_profile: 'default', - profiles: { default: { api_key: data.api_key } }, - }; - } - // New format: { profiles, active_profile } if (data.profiles) { - const storage = - data.storage === 'keychain' ? 'secure_storage' : data.storage; return { active_profile: data.active_profile ?? 'default', - ...(storage ? { storage } : {}), + ...(data.storage ? { storage: data.storage } : {}), profiles: data.profiles, }; } - // Old format: { teams, active_team } - if (data.teams) { - return { - active_profile: data.active_team ?? 'default', - profiles: data.teams, - }; - } return null; } catch { return null; @@ -102,8 +82,7 @@ export function resolveProfileName(flagValue?: string): string { return flagValue; } - // Check RESEND_PROFILE first, fall back to deprecated RESEND_TEAM - const envProfile = process.env.RESEND_PROFILE || process.env.RESEND_TEAM; + const envProfile = process.env.RESEND_PROFILE; if (envProfile) { return envProfile; } @@ -116,9 +95,6 @@ export function resolveProfileName(flagValue?: string): string { return 'default'; } -/** @deprecated Use `resolveProfileName` instead */ -export const resolveTeamName = resolveProfileName; - export function resolveApiKey( flagValue?: string, profileName?: string, @@ -190,7 +166,7 @@ export function removeApiKey(profileName?: string): string { if (!existsSync(configPath)) { throw new Error('No credentials file found.'); } - // Try to delete legacy file + // File exists but is not valid credentials — delete it unlinkSync(configPath); return configPath; } @@ -237,9 +213,6 @@ export function setActiveProfile(profileName: string): void { writeCredentials(creds); } -/** @deprecated Use `setActiveProfile` instead */ -export const setActiveTeam = setActiveProfile; - export function listProfiles(): Array<{ name: string; active: boolean }> { const creds = readCredentials(); if (!creds) { @@ -251,9 +224,6 @@ export function listProfiles(): Array<{ name: string; active: boolean }> { })); } -/** @deprecated Use `listProfiles` instead */ -export const listTeams = listProfiles; - export function validateProfileName(name: string): string | undefined { if (!name || name.length === 0) { return 'Profile name must not be empty'; @@ -267,9 +237,6 @@ export function validateProfileName(name: string): string | undefined { return undefined; } -/** @deprecated Use `validateProfileName` instead */ -export const validateTeamName = validateProfileName; - export function renameProfile(oldName: string, newName: string): void { if (oldName === newName) { return; @@ -324,7 +291,6 @@ export async function resolveApiKeyAsync( const profile = profileName || process.env.RESEND_PROFILE || - process.env.RESEND_TEAM || creds?.active_profile || 'default'; @@ -337,27 +303,10 @@ export async function resolveApiKeyAsync( } } - // File-based storage (or unmigrated profile in mixed state) + // File-based storage if (creds) { const entry = creds.profiles[profile]; if (entry?.api_key) { - // Auto-migrate: move plaintext key to secure storage if available - const backend = await getCredentialBackend(); - if (backend.isSecure) { - try { - await backend.set(SERVICE_NAME, profile, entry.api_key); - creds.profiles[profile] = { - ...(entry.permission && { permission: entry.permission }), - }; - creds.storage = 'secure_storage'; - writeCredentials(creds); - process.stderr.write( - `Notice: API key for profile "${profile}" has been moved to ${backend.name}\n`, - ); - } catch { - // Non-fatal — plaintext key still works - } - } return { key: entry.api_key, source: 'config', @@ -417,7 +366,6 @@ export async function removeApiKeyAsync(profileName?: string): Promise { const profile = profileName || process.env.RESEND_PROFILE || - process.env.RESEND_TEAM || creds?.active_profile || 'default'; diff --git a/src/lib/credential-store.ts b/src/lib/credential-store.ts index e93ec6ac..71224c86 100644 --- a/src/lib/credential-store.ts +++ b/src/lib/credential-store.ts @@ -23,13 +23,13 @@ export async function getCredentialBackend(): Promise { return cachedBackend; } - if (override === 'secure_storage' || override === 'keychain') { + if (override === 'secure_storage') { const backend = await getOsBackend(); if (backend) { cachedBackend = backend; return cachedBackend; } - // Fall through to file if keychain forced but unavailable + // Fall through to file if secure storage forced but unavailable } // Auto-detect: try OS backend first diff --git a/tests/commands/auth/login.test.ts b/tests/commands/auth/login.test.ts index e9b7336b..0693bb7b 100644 --- a/tests/commands/auth/login.test.ts +++ b/tests/commands/auth/login.test.ts @@ -263,30 +263,6 @@ describe('login command', () => { expect(data.profiles.staging.api_key).toBe('re_staging_key_123'); }); - it('deprecated --team alias works like --profile', async () => { - setupOutputSpies(); - - const { Command } = await import('@commander-js/extra-typings'); - const { loginCommand } = await import('../../../src/commands/auth/login'); - const program = new Command() - .option('--profile ') - .option('--team ') - .option('--json') - .option('--api-key ') - .option('-q, --quiet') - .addCommand(loginCommand); - - await program.parseAsync( - ['login', '--key', 're_team_alias_key_123', '--team', 'legacy'], - { from: 'user' }, - ); - - const configPath = join(tmpDir, 'resend', 'credentials.json'); - const data = JSON.parse(readFileSync(configPath, 'utf-8')); - expect(data.active_profile).toBe('legacy'); - expect(data.profiles.legacy.api_key).toBe('re_team_alias_key_123'); - }); - it('rejects invalid profile name with invalid_profile_name', async () => { setNonInteractive(); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/tests/commands/whoami.test.ts b/tests/commands/whoami.test.ts index 56b68730..ebf68f45 100644 --- a/tests/commands/whoami.test.ts +++ b/tests/commands/whoami.test.ts @@ -33,7 +33,6 @@ describe('whoami command', () => { process.env.XDG_CONFIG_HOME = tmpDir; delete process.env.RESEND_API_KEY; delete process.env.RESEND_PROFILE; - delete process.env.RESEND_TEAM; }); afterEach(() => { diff --git a/tests/lib/client.test.ts b/tests/lib/client.test.ts index 531e709d..c3fa0a5c 100644 --- a/tests/lib/client.test.ts +++ b/tests/lib/client.test.ts @@ -72,8 +72,8 @@ describe('createClient', () => { writeFileSync( join(configDir, 'credentials.json'), JSON.stringify({ - active_team: 'default', - teams: { + active_profile: 'default', + profiles: { default: { api_key: 're_default_key' }, staging: { api_key: 're_staging_key' }, }, @@ -81,7 +81,7 @@ describe('createClient', () => { ); const { createClient } = await import('../../src/lib/client'); - // Should not throw — resolves staging team's key + // Should not throw — resolves staging profile's key const client = await createClient(undefined, 'staging'); expect(client).toBeInstanceOf(Resend); diff --git a/tests/lib/config-async.test.ts b/tests/lib/config-async.test.ts index aef97b2c..5c9e16e5 100644 --- a/tests/lib/config-async.test.ts +++ b/tests/lib/config-async.test.ts @@ -17,7 +17,6 @@ describe('resolveApiKeyAsync', () => { process.env.XDG_CONFIG_HOME = tmpDir; delete process.env.RESEND_API_KEY; delete process.env.RESEND_PROFILE; - delete process.env.RESEND_TEAM; process.env.RESEND_CREDENTIAL_STORE = 'file'; }); @@ -40,7 +39,7 @@ describe('resolveApiKeyAsync', () => { expect(result).toEqual({ key: 're_env_key', source: 'env' }); }); - it('reads from file when storage is not keychain', async () => { + it('reads from file when storage is not secure_storage', async () => { const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( @@ -60,7 +59,7 @@ describe('resolveApiKeyAsync', () => { }); }); - it('reads from credential backend when storage is keychain', async () => { + it('reads from credential backend when storage is secure_storage', async () => { const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( @@ -98,7 +97,7 @@ describe('resolveApiKeyAsync', () => { expect(mockBackend.get).toHaveBeenCalledWith('resend-cli', 'default'); }); - it('falls back to file api_key when keychain has no entry but file does (mixed state)', async () => { + it('falls back to file api_key when secure storage has no entry but file does', async () => { const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( @@ -135,7 +134,7 @@ describe('resolveApiKeyAsync', () => { }); }); - it('returns null when keychain has no entry', async () => { + it('returns null when secure storage has no entry', async () => { const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( @@ -223,7 +222,7 @@ describe('storeApiKeyAsync', () => { vi.restoreAllMocks(); }); - it('stores key in file backend when keychain unavailable', async () => { + it('stores key in file backend when secure storage unavailable', async () => { vi.resetModules(); vi.doUnmock('../../src/lib/credential-store'); const { storeApiKeyAsync } = await import('../../src/lib/config'); @@ -383,7 +382,7 @@ describe('removeAllApiKeysAsync', () => { vi.restoreAllMocks(); }); - it('deletes all profiles from keychain when secure', async () => { + it('deletes all profiles from secure storage when secure', async () => { const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( @@ -419,7 +418,7 @@ describe('removeAllApiKeysAsync', () => { expect(mockBackend.delete).toHaveBeenCalledWith('resend-cli', 'prod'); }); - it('skips keychain deletion when not secure', async () => { + it('skips secure storage deletion when not secure', async () => { const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( @@ -519,7 +518,7 @@ describe('renameProfileAsync', () => { vi.restoreAllMocks(); }); - it('renames in keychain when secure', async () => { + it('renames in secure storage when secure', async () => { const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( @@ -564,7 +563,7 @@ describe('renameProfileAsync', () => { expect(creds?.active_profile).toBe('new-name'); }); - it('skips keychain when not secure', async () => { + it('skips secure storage when not secure', async () => { const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts index d21cde0a..6713d313 100644 --- a/tests/lib/config.test.ts +++ b/tests/lib/config.test.ts @@ -91,45 +91,6 @@ describe('resolveApiKey', () => { }); }); - it('config file is third priority (legacy teams format)', () => { - delete process.env.RESEND_API_KEY; - process.env.XDG_CONFIG_HOME = tmpDir; - const configDir = join(tmpDir, 'resend'); - mkdirSync(configDir, { recursive: true }); - writeFileSync( - join(configDir, 'credentials.json'), - JSON.stringify({ - active_team: 'default', - teams: { default: { api_key: 're_config_key' } }, - }), - ); - - const result = resolveApiKey(); - expect(result).toEqual({ - key: 're_config_key', - source: 'config', - profile: 'default', - }); - }); - - it('reads legacy format (api_key at root)', () => { - delete process.env.RESEND_API_KEY; - process.env.XDG_CONFIG_HOME = tmpDir; - const configDir = join(tmpDir, 'resend'); - mkdirSync(configDir, { recursive: true }); - writeFileSync( - join(configDir, 'credentials.json'), - JSON.stringify({ api_key: 're_legacy_key' }), - ); - - const result = resolveApiKey(); - expect(result).toEqual({ - key: 're_legacy_key', - source: 'config', - profile: 'default', - }); - }); - it('resolves specific profile from config', () => { delete process.env.RESEND_API_KEY; process.env.XDG_CONFIG_HOME = tmpDir; @@ -218,15 +179,8 @@ describe('resolveProfileName', () => { expect(resolveProfileName()).toBe('env_profile'); }); - it('RESEND_TEAM env var is fallback for RESEND_PROFILE', () => { - delete process.env.RESEND_PROFILE; - process.env.RESEND_TEAM = 'env_team'; - expect(resolveProfileName()).toBe('env_team'); - }); - it('active_profile from config is third priority', () => { delete process.env.RESEND_PROFILE; - delete process.env.RESEND_TEAM; const configDir = join(tmpDir, 'resend'); mkdirSync(configDir, { recursive: true }); writeFileSync( @@ -242,7 +196,6 @@ describe('resolveProfileName', () => { it('defaults to "default" when nothing configured', () => { delete process.env.RESEND_PROFILE; - delete process.env.RESEND_TEAM; expect(resolveProfileName()).toBe('default'); }); });