Skip to content
Next Next commit
Add update notifier to CLI
Show a banner at the top of CLI output when a newer version of wp-studio
is available on npm. Includes a changelog link to the Studio docs page.
The check is cached for 24 hours and suppressed in IPC mode or with --json.

Fixes STU-1449

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
  • Loading branch information
youknowriad and claude committed Apr 15, 2026
commit 79fa700c0b4e54a2ffc1d2f284ad690e91f7dafc
5 changes: 5 additions & 0 deletions apps/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import { setupServerFiles } from 'cli/lib/dependency-management/setup';
import { loadTranslations } from 'cli/lib/i18n';
import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats';
import { setupUpdateNotifier } from 'cli/lib/update-notifier';
import { untildify } from 'cli/lib/utils';
import { StudioArgv } from 'cli/types';

Expand All @@ -25,6 +26,10 @@ const version = __STUDIO_CLI_VERSION__;
suppressPunycodeWarning();

async function main() {
// Start update check and register exit handler for the update banner.
// Uses a synchronous exit handler so the banner shows even for --version.
setupUpdateNotifier( version );

const yargsLocale = await loadTranslations();

if ( semver.lt( process.version, __MINIMUM_NODE_VERSION__ ) ) {
Expand Down
54 changes: 54 additions & 0 deletions apps/cli/lib/tests/update-notifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* eslint-disable no-control-regex */
import { type MockInstance, vi } from 'vitest';
import { formatUpdateBanner, setupUpdateNotifier } from 'cli/lib/update-notifier';

describe( 'formatUpdateBanner', () => {
it( 'should include version numbers', () => {
const banner = formatUpdateBanner( '1.7.8', '1.8.0' );
const plain = banner.replace( /\u001B\[[0-9;]*m/g, '' );
expect( plain ).toContain( '1.7.8' );
expect( plain ).toContain( '1.8.0' );
} );

it( 'should include the npm update command', () => {
const banner = formatUpdateBanner( '1.7.8', '1.8.0' );
const plain = banner.replace( /\u001B\[[0-9;]*m/g, '' );
expect( plain ).toContain( 'npm update -g wp-studio' );
} );

it( 'should include the changelog URL', () => {
const banner = formatUpdateBanner( '1.7.8', '1.8.0' );
const plain = banner.replace( /\u001B\[[0-9;]*m/g, '' );
expect( plain ).toContain(
'https://developer.wordpress.com/docs/developer-tools/studio/changelog/'
);
} );

it( 'should be wrapped in a box', () => {
const banner = formatUpdateBanner( '1.0.0', '2.0.0' );
const plain = banner.replace( /\u001B\[[0-9;]*m/g, '' );
expect( plain ).toContain( '╭' );
expect( plain ).toContain( '╰' );
expect( plain ).toContain( '│' );
} );
} );

describe( 'setupUpdateNotifier', () => {
const originalSend = process.send;
let stderrWriteSpy: MockInstance;

beforeEach( () => {
stderrWriteSpy = vi.spyOn( process.stderr, 'write' ).mockImplementation( () => true );
} );

afterEach( () => {
process.send = originalSend;
stderrWriteSpy.mockRestore();
} );

it( 'should not show banner when in IPC mode', () => {
process.send = vi.fn();
setupUpdateNotifier( '1.0.0' );
expect( stderrWriteSpy ).not.toHaveBeenCalled();
} );
} );
153 changes: 153 additions & 0 deletions apps/cli/lib/update-notifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import fs from 'fs';
import path from 'path';
import { getConfigDirectory } from '@studio/common/lib/well-known-paths';
import { __, sprintf } from '@wordpress/i18n';
import chalk from 'chalk';
import semver from 'semver';

const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const FETCH_TIMEOUT_MS = 5000;
const NPM_REGISTRY_URL = 'https://registry.npmjs.org/wp-studio/latest';
const CHANGELOG_URL = 'https://developer.wordpress.com/docs/developer-tools/studio/changelog/';
const CACHE_FILE_NAME = 'cli-update-check.json';

interface UpdateCheckCache {
lastChecked: number;
latestVersion: string;
}

function getCacheFilePath(): string {
return path.join( getConfigDirectory(), CACHE_FILE_NAME );
}

function readCache(): UpdateCheckCache | null {
try {
const content = fs.readFileSync( getCacheFilePath(), 'utf8' );
const data = JSON.parse( content );
if ( typeof data.lastChecked === 'number' && typeof data.latestVersion === 'string' ) {
return data as UpdateCheckCache;
}
} catch {
// Cache doesn't exist or is invalid
}
return null;
}

function writeCache( cache: UpdateCheckCache ): void {
try {
const configDir = getConfigDirectory();
if ( ! fs.existsSync( configDir ) ) {
fs.mkdirSync( configDir, { recursive: true } );
}
fs.writeFileSync( getCacheFilePath(), JSON.stringify( cache ), 'utf8' );
} catch {
// Non-critical, ignore write failures
}
}
Comment thread
youknowriad marked this conversation as resolved.
Outdated

async function fetchLatestVersion(): Promise< string | null > {
try {
const controller = new AbortController();
const timeout = setTimeout( () => controller.abort(), FETCH_TIMEOUT_MS );
Comment thread
youknowriad marked this conversation as resolved.

const response = await fetch( NPM_REGISTRY_URL, {
signal: controller.signal,
headers: { Accept: 'application/json' },
} );
clearTimeout( timeout );

if ( ! response.ok ) {
return null;
}

const data = ( await response.json() ) as { version?: string };
return typeof data.version === 'string' ? data.version : null;
Comment thread
youknowriad marked this conversation as resolved.
Outdated
} catch {
return null;
}
}

function hasJsonFlag(): boolean {
return process.argv.includes( '--json' );
}

/**
* Checks for available updates and displays a banner at the top of output.
*
* Reads from a local cache file synchronously so the banner prints immediately,
* before any command output. A background fetch refreshes the cache for the next
* invocation when it is stale or missing (same pattern as npm's update-notifier).
*
* The banner is suppressed in IPC mode or when --json flag is used.
*/
export function setupUpdateNotifier( currentVersion: string ): void {
if ( Boolean( process.send ) || hasJsonFlag() ) {
return;
}

const cache = readCache();
const now = Date.now();

// Start a background fetch if cache is stale or missing
if ( ! cache || now - cache.lastChecked >= UPDATE_CHECK_INTERVAL_MS ) {
// Fire and forget -- the result will be cached for the next invocation
void fetchLatestVersion().then( ( version ) => {
if ( version ) {
writeCache( { lastChecked: now, latestVersion: version } );
}
} );
}

// Show the banner immediately from cache (before any command output).
// On the first run the cache won't exist yet, so the banner won't show
// until the next invocation.
if (
cache &&
semver.valid( cache.latestVersion ) &&
semver.valid( currentVersion ) &&
semver.gt( cache.latestVersion, currentVersion )
) {
process.stderr.write( formatUpdateBanner( currentVersion, cache.latestVersion ) );
}
}

export function formatUpdateBanner( currentVersion: string, latestVersion: string ): string {
const updateLine = sprintf(
/* translators: 1: current version, 2: latest version */
__( 'Update available: %1$s → %2$s' ),
chalk.dim( currentVersion ),
chalk.green( latestVersion )
);
const commandLine = sprintf(
/* translators: %s is the npm command to run */
__( 'Run %s to update' ),
chalk.cyan( 'npm update -g wp-studio' )
);
const changelogLine = sprintf(
/* translators: %s is the changelog URL */
__( 'Changelog: %s' ),
chalk.cyan( CHANGELOG_URL )
);

const lines = [ '', updateLine, commandLine, '', changelogLine, '' ];

// Calculate box width based on longest line (strip ANSI for measurement)
// eslint-disable-next-line no-control-regex
const ansiPattern = new RegExp( '\u001B\\[[0-9;]*m', 'g' );
const stripAnsi = ( str: string ) => str.replace( ansiPattern, '' );
const maxLen = Math.max( ...lines.map( ( l ) => stripAnsi( l ).length ) );
const padding = 2;
const innerWidth = maxLen + padding * 2;

const top = chalk.yellow( `╭${ '─'.repeat( innerWidth ) }╮` );
const bottom = chalk.yellow( `╰${ '─'.repeat( innerWidth ) }╯` );
const side = chalk.yellow( '│' );

const paddedLines = lines.map( ( line ) => {
const visibleLen = stripAnsi( line ).length;
const rightPad = innerWidth - padding - visibleLen;
Comment thread
youknowriad marked this conversation as resolved.
Outdated
return `${ side }${ ' '.repeat( padding ) }${ line }${ ' '.repeat( rightPad ) }${ side }`;
} );

return [ '', top, ...paddedLines, bottom, '' ].join( '\n' );
}
Comment thread
youknowriad marked this conversation as resolved.
Loading