Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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';
import { webhooksCommand } from './commands/webhooks/index';
import { whoamiCommand } from './commands/whoami';
import { errorMessage, outputError } from './lib/output';
Expand Down Expand Up @@ -90,6 +91,7 @@ ${pc.gray('Examples:')}
.addCommand(authCommand)
.addCommand(openCommand)
.addCommand(whoamiCommand)
.addCommand(updateCommand)
.addCommand(teamsDeprecatedCommand);

// Hide the deprecated --team option from help
Expand All @@ -100,7 +102,14 @@ if (teamOption) {

program
.parseAsync()
.then(() => checkForUpdates().catch(() => {}))
.then(() => {
// Skip the background update notice when the user explicitly ran `update`
const ran = program.args[0];
if (ran === 'update') {
return;
}
return checkForUpdates().catch(() => {});
})
.catch((err) => {
outputError({
message: errorMessage(err, 'An unexpected error occurred'),
Expand Down
70 changes: 70 additions & 0 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Command } from '@commander-js/extra-typings';
import type { GlobalOpts } from '../lib/client';
import { buildHelpText } from '../lib/help-text';
import { outputError, outputResult } from '../lib/output';
import { createSpinner } from '../lib/spinner';
import { isInteractive } from '../lib/tty';
import {
detectInstallMethod,
fetchLatestVersion,
isNewer,
} from '../lib/update-check';
import { VERSION } from '../lib/version';

export const updateCommand = new Command('update')
.description('Check for available CLI updates')
.addHelpText(
'after',
buildHelpText({
context: `Checks the latest release on GitHub (bypasses the cache).
Shows the current version, latest version, and how to upgrade.`,
output: ` {"current":"1.4.0","latest":"1.5.0","update_available":true,"upgrade_command":"npm install -g resend-cli"}
{"current":"1.5.0","latest":"1.5.0","update_available":false}`,
examples: ['resend update', 'resend update --json'],
}),
)
.action(async (_opts, cmd) => {
const globalOpts = cmd.optsWithGlobals() as GlobalOpts;
const interactive = isInteractive() && !globalOpts.json;

const spinner = interactive
? createSpinner('Checking for updates...')
: null;

const latest = await fetchLatestVersion();

if (!latest) {
spinner?.fail('Could not check for updates');
outputError(
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This switches update-check failures from stdout JSON to stderr, which breaks the documented machine-mode contract for non-interactive resend update calls.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/update.ts, line 38:

<comment>This switches update-check failures from stdout JSON to stderr, which breaks the documented machine-mode contract for non-interactive `resend update` calls.</comment>

<file context>
@@ -35,18 +35,10 @@ Shows the current version, latest version, and how to upgrade.`,
-        );
-      }
-      process.exit(1);
+      outputError(
+        { message: 'Could not reach GitHub releases', code: 'fetch_failed' },
+        { json: globalOpts.json },
</file context>
Fix with Cubic

{ message: 'Could not reach GitHub releases', code: 'fetch_failed' },
{ json: globalOpts.json },
);
return;
}

const updateAvailable = isNewer(VERSION, latest);
const upgrade = detectInstallMethod();

if (globalOpts.json || !isInteractive()) {
outputResult(
{
current: VERSION,
latest,
update_available: updateAvailable,
...(updateAvailable ? { upgrade_command: upgrade } : {}),
},
{ json: globalOpts.json },
);
return;
}

if (updateAvailable) {
const isUrl = upgrade.startsWith('http');
spinner?.warn(`Update available: v${VERSION} → v${latest}`);
console.log(
`\n ${isUrl ? 'Visit' : 'Run'}: \x1B[36m${upgrade}\x1B[0m\n`,
);
} else {
spinner?.stop(`Already up to date (v${VERSION})`);
}
});
25 changes: 16 additions & 9 deletions src/lib/update-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function writeState(state: UpdateState): void {
/**
* Compare two semver strings. Returns true if remote > local.
*/
function isNewer(local: string, remote: string): boolean {
export function isNewer(local: string, remote: string): boolean {
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number);
const [lMaj, lMin, lPat] = parse(local);
const [rMaj, rMin, rPat] = parse(remote);
Expand All @@ -45,7 +45,7 @@ function isNewer(local: string, remote: string): boolean {
return rPat > lPat;
}

async function fetchLatestVersion(): Promise<string | null> {
export async function fetchLatestVersion(): Promise<string | null> {
try {
const res = await fetch(GITHUB_RELEASES_URL, {
headers: { Accept: 'application/vnd.github.v3+json' },
Expand Down Expand Up @@ -89,19 +89,26 @@ function shouldSkipCheck(): boolean {
return false;
}

function detectInstallMethod(): string {
export function detectInstallMethod(): string {
const execPath = process.execPath || process.argv[0] || '';
const scriptPath = process.argv[1] || '';

// npm / npx global install — check first because npm_execpath and
// the script path inside node_modules are the most reliable signals,
// even when Node itself was installed via Homebrew.
if (
process.env.npm_execpath ||
/node_modules/.test(scriptPath) ||
/node_modules/.test(execPath)
) {
return 'npm install -g resend-cli';
}

// Homebrew
// Homebrew (direct tap install, not npm-via-brew)
if (/\/(Cellar|homebrew)\//i.test(execPath)) {
return 'brew update && brew upgrade resend';
}

// npm / npx global install
if (/node_modules/.test(execPath) || process.env.npm_execpath) {
return 'npm install -g resend-cli';
}

// Install script (default install location)
if (/[/\\]\.resend[/\\]bin[/\\]/.test(execPath)) {
if (process.platform === 'win32') {
Expand Down
62 changes: 61 additions & 1 deletion tests/lib/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import {
test,
vi,
} from 'vitest';
import { checkForUpdates } from '../../src/lib/update-check';
import {
checkForUpdates,
detectInstallMethod,
} from '../../src/lib/update-check';
import { VERSION } from '../../src/lib/version';
import { captureTestEnv } from '../helpers';

Expand Down Expand Up @@ -177,3 +180,60 @@ describe('checkForUpdates', () => {
expect(stderrOutput).toBe('');
});
});

describe('detectInstallMethod', () => {
const restoreEnv = captureTestEnv();
let origExecPath: string;
let origArgv1: string | undefined;

beforeEach(() => {
origExecPath = process.execPath;
origArgv1 = process.argv[1];
delete process.env.npm_execpath;
});

afterEach(() => {
Object.defineProperty(process, 'execPath', { value: origExecPath });
process.argv[1] = origArgv1 as string;
restoreEnv();
});

test('detects npm when script path contains node_modules', () => {
Object.defineProperty(process, 'execPath', {
value: '/opt/homebrew/bin/node',
});
process.argv[1] = '/opt/homebrew/lib/node_modules/resend-cli/dist/cli.js';

expect(detectInstallMethod()).toBe('npm install -g resend-cli');
});

test('detects npm when npm_execpath is set even with homebrew node', () => {
Object.defineProperty(process, 'execPath', {
value: '/opt/homebrew/bin/node',
});
process.argv[1] = '/opt/homebrew/bin/resend';
process.env.npm_execpath =
'/opt/homebrew/lib/node_modules/npm/bin/npm-cli.js';

expect(detectInstallMethod()).toBe('npm install -g resend-cli');
});

test('detects homebrew when no npm signals present', () => {
Object.defineProperty(process, 'execPath', {
value: '/opt/homebrew/Cellar/resend/1.4.0/bin/resend',
});
process.argv[1] = '/opt/homebrew/Cellar/resend/1.4.0/libexec/cli.js';

expect(detectInstallMethod()).toBe('brew update && brew upgrade resend');
});

test('detects install script', () => {
Object.defineProperty(process, 'execPath', {
value: '/Users/test/.resend/bin/resend',
});
process.argv[1] = '/Users/test/.resend/bin/resend';

const expected = process.platform === 'win32' ? 'irm' : 'curl';
expect(detectInstallMethod()).toContain(expected);
});
});