Skip to content
73 changes: 38 additions & 35 deletions apps/cli/commands/site/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
} from 'cli/lib/cli-config/core';
import { removeSiteFromConfig } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client';
import { withWordPressVersionsLock } from 'cli/lib/dependency-management/lock';
import {
getAiInstructionsPath,
getWordPressVersionPath,
Expand Down Expand Up @@ -151,12 +152,10 @@ export async function runCommand(
}
}
} catch ( error ) {
// Errors here aren't critical and likely relate to things outside the user's control,
// like network issues or bad API responses. Report them only in development.
if ( process.env.NODE_ENV !== 'production' ) {
const loggerError = new LoggerError( 'Failed to update dependencies', error );
logger.reportError( loggerError, false );
}
// Not fatal — the site still gets the cached copy. Reported in production
// too, or an auto-updating site starts on an older release for no visible
// reason.
logger.reportError( new LoggerError( 'Failed to update dependencies', error ), false );
Comment thread
gcsecsey marked this conversation as resolved.
Outdated
}

const createStartedAt = Date.now();
Expand Down Expand Up @@ -243,41 +242,45 @@ export async function runCommand(
logger.reportSuccess( __( 'Site directory created' ) );
}

if ( options.wpVersion === 'latest' ) {
const bundledWPPath = path.join( getServerFilesPath(), 'wordpress-versions', 'latest' );
// Locked because `wordpress-versions/latest` is shared mutable state: a
// concurrent refresh rewrites it in place while this copies out of it.
await withWordPressVersionsLock( async () => {
if ( options.wpVersion === 'latest' ) {
const bundledWPPath = path.join( getServerFilesPath(), 'wordpress-versions', 'latest' );
Comment thread
gcsecsey marked this conversation as resolved.
Outdated

if ( ! ( await pathExists( bundledWPPath ) ) ) {
throw new LoggerError(
__(
'Cannot set up WordPress. Bundled WordPress files not found. Please connect to the internet or reinstall Studio.'
)
);
}

if ( ! ( await pathExists( bundledWPPath ) ) ) {
logger.reportStart( LoggerAction.SETUP_WORDPRESS, __( 'Copying bundled WordPress…' ) );
await recursiveCopyDirectory( bundledWPPath, sitePath );
logger.reportSuccess( __( 'WordPress files copied' ) );
} else if ( ! isOnlineStatus ) {
throw new LoggerError(
__(
'Cannot set up WordPress. Bundled WordPress files not found. Please connect to the internet or reinstall Studio.'
'Cannot set up WordPress while offline. Specific WordPress versions require an internet connection. Try using "latest" version or ensure internet connectivity.'
)
);
}

logger.reportStart( LoggerAction.SETUP_WORDPRESS, __( 'Copying bundled WordPress…' ) );
await recursiveCopyDirectory( bundledWPPath, sitePath );
logger.reportSuccess( __( 'WordPress files copied' ) );
} else if ( ! isOnlineStatus ) {
throw new LoggerError(
__(
'Cannot set up WordPress while offline. Specific WordPress versions require an internet connection. Try using "latest" version or ensure internet connectivity.'
)
);
} else if ( siteRuntime === SITE_RUNTIME_NATIVE_PHP && ! isWordPressDirResult ) {
logger.reportStart(
LoggerAction.SETUP_WORDPRESS,
sprintf( __( 'Downloading WordPress %s…' ), options.wpVersion )
);
await downloadWordPress( options.wpVersion );
logger.reportSuccess( __( 'WordPress files downloaded' ) );
} else if ( siteRuntime === SITE_RUNTIME_NATIVE_PHP && ! isWordPressDirResult ) {
logger.reportStart(
LoggerAction.SETUP_WORDPRESS,
sprintf( __( 'Downloading WordPress %s…' ), options.wpVersion )
);
await downloadWordPress( options.wpVersion );
logger.reportSuccess( __( 'WordPress files downloaded' ) );

logger.reportStart(
LoggerAction.SETUP_WORDPRESS,
sprintf( __( 'Copying WordPress %s…' ), options.wpVersion )
);
await recursiveCopyDirectory( getWordPressVersionPath( options.wpVersion ), sitePath );
logger.reportSuccess( __( 'WordPress files copied' ) );
}
logger.reportStart(
LoggerAction.SETUP_WORDPRESS,
sprintf( __( 'Copying WordPress %s…' ), options.wpVersion )
);
await recursiveCopyDirectory( getWordPressVersionPath( options.wpVersion ), sitePath );
logger.reportSuccess( __( 'WordPress files copied' ) );
}
} );

logger.reportStart( LoggerAction.INSTALL_SQLITE, __( 'Setting up SQLite integration…' ) );
await keepSqliteIntegrationUpdated( sitePath );
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/commands/site/tests/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ vi.mock( 'cli/lib/language-packs' );
vi.mock( 'cli/lib/daemon-client' );
vi.mock( 'cli/lib/dependency-management/setup' );
vi.mock( 'cli/lib/dependency-management/wordpress' );
vi.mock( 'cli/lib/dependency-management/lock', () => ( {
withWordPressVersionsLock: vi.fn( ( run: () => Promise< unknown > ) => run() ),
} ) );
vi.mock( import( '@studio/common/lib/well-known-paths' ), async ( importOriginal ) => {
const actual = await importOriginal();
return {
Expand Down
31 changes: 31 additions & 0 deletions apps/cli/lib/dependency-management/lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { mkdir } from 'fs/promises';
import path from 'path';
import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile';
import { getServerFilesPath } from '@studio/common/lib/well-known-paths';

// Far more patient than the config-file locks: the guarded work downloads and
// copies a full WordPress release, which takes minutes on a slow connection.
const STALE_TIME = 10 * 60 * 1000;
const WAIT_TIME = 2 * 60 * 1000;
Comment thread
gcsecsey marked this conversation as resolved.
Outdated

function getLockfilePath(): string {
return path.join( getServerFilesPath(), 'wordpress-versions.lock' );
}

/**
* Serialize access to `wordpress-versions/`. The `latest` directory is shared
* mutable state: a refresh rewrites it in place, while a site create may be
* copying files out of it at the same time. Without this, a create started
* just as a new WordPress release lands can copy a half-written directory.
Comment thread
gcsecsey marked this conversation as resolved.
Outdated
*/
export async function withWordPressVersionsLock< T >( run: () => Promise< T > ): Promise< T > {
const lockfilePath = getLockfilePath();
await mkdir( path.dirname( lockfilePath ), { recursive: true } );
await lockFileAsync( lockfilePath, { stale: STALE_TIME, wait: WAIT_TIME } );
Comment thread
gcsecsey marked this conversation as resolved.
Outdated

try {
return await run();
} finally {
await unlockFileAsync( lockfilePath );
}
}
11 changes: 8 additions & 3 deletions apps/cli/lib/dependency-management/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';
import { recursiveCopyDirectory } from '@studio/common/lib/fs-utils';
import semver from 'semver';
import { readCliConfig, updateCliConfigWithPartial } from 'cli/lib/cli-config/core';
import { withWordPressVersionsLock } from './lock';
import { getWordPressVersionPath, getWpFilesPath } from './paths';
import { getWordPressVersionFromInstallation, updateLatestWordPressVersion } from './wordpress';

Expand Down Expand Up @@ -37,7 +38,7 @@ async function copyBundledLatestWpVersion() {

export async function setupServerFiles() {
const steps: [ string, () => Promise< void > ][] = [
[ 'WordPress version', copyBundledLatestWpVersion ],
[ 'WordPress version', () => withWordPressVersionsLock( copyBundledLatestWpVersion ) ],
];

for ( const [ name, step ] of steps ) {
Expand Down Expand Up @@ -78,17 +79,21 @@ async function markDependencyCheckTime(): Promise< void > {

/**
* Checks for and applies dependency updates (e.g. WordPress versions), throttled
* to at most once per 24 hours. Returns true if the check ran, false if skipped.
* by `DEPENDENCY_CHECK_INTERVAL_MS`. Returns true if the check ran and succeeded,
* false if it was skipped or failed.
Comment thread
gcsecsey marked this conversation as resolved.
Outdated
*/
export async function updateServerFiles(): Promise< boolean > {
if ( ! ( await shouldCheckDependencyUpdates() ) ) {
return false;
}

try {
await updateLatestWordPressVersion();
await withWordPressVersionsLock( updateLatestWordPressVersion );
} catch ( error ) {
// Leave the timestamp alone so the next attempt retries. Recording it here
// would let one network blip serve a stale WordPress for a whole interval.
console.error( 'Failed to update dependency WordPress version:', error );
return false;
}

await markDependencyCheckTime();
Expand Down
12 changes: 7 additions & 5 deletions apps/cli/lib/dependency-management/tests/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ vi.mock( 'cli/lib/cli-config/core', () => ( {

vi.mock( '../wordpress' );

vi.mock( '../lock', () => ( {
withWordPressVersionsLock: vi.fn( ( run: () => Promise< unknown > ) => run() ),
} ) );

describe( 'updateServerFiles', () => {
const NOW = 1_700_000_000_000;

Expand Down Expand Up @@ -100,15 +104,13 @@ describe( 'updateServerFiles', () => {
} );
} );

it( 'persists the timestamp even when the update throws', async () => {
it( 'does not persist the timestamp when the update throws, so the next attempt retries', async () => {
vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } );
vi.mocked( updateLatestWordPressVersion ).mockRejectedValue( new Error( 'network' ) );

await updateServerFiles();
await expect( updateServerFiles() ).resolves.toBe( false );

expect( updateCliConfigWithPartial ).toHaveBeenCalledWith( {
lastDependencyCheckTime: NOW,
} );
expect( updateCliConfigWithPartial ).not.toHaveBeenCalled();
} );

it( 'does not persist the timestamp when the check is skipped', async () => {
Expand Down
109 changes: 109 additions & 0 deletions apps/cli/lib/dependency-management/tests/wordpress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import fs from 'fs';
import { downloadFile } from '@studio/common/lib/download-file';
import { extractZip } from '@studio/common/lib/extract-zip';
import { recursiveCopyDirectory } from '@studio/common/lib/fs-utils';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { getWordPressVersionPath } from '../paths';
import { updateLatestWordPressVersion } from '../wordpress';

vi.mock( 'fs', () => ( {
default: {
existsSync: vi.fn(),
promises: {
readdir: vi.fn(),
readFile: vi.fn(),
mkdir: vi.fn(),
cp: vi.fn(),
rm: vi.fn(),
},
},
} ) );

vi.mock( '@studio/common/lib/download-file', () => ( { downloadFile: vi.fn() } ) );
vi.mock( '@studio/common/lib/extract-zip', () => ( { extractZip: vi.fn() } ) );
vi.mock( '@studio/common/lib/fs-utils', () => ( { recursiveCopyDirectory: vi.fn() } ) );
vi.mock( '../paths', () => ( {
getWordPressVersionPath: vi.fn(
( version: string ) => `/server-files/wordpress-versions/${ version }`
),
} ) );

const STABLE_CHECK_URL = 'https://api.wordpress.org/core/stable-check/1.0/';

function mockStableCheck( body: Record< string, string > ) {
vi.spyOn( global, 'fetch' ).mockResolvedValue( {
json: async () => body,
} as Response );
}

describe( 'updateLatestWordPressVersion', () => {
beforeEach( () => {
vi.clearAllMocks();
vi.mocked( fs.existsSync ).mockReturnValue( true );
vi.mocked( fs.promises.readdir ).mockResolvedValue( [ 'wp-includes' ] as never );
vi.mocked( fs.promises.readFile ).mockResolvedValue( "<?php $wp_version = '6.9.5';" as never );
mockStableCheck( { '6.9.5': 'latest' } );
} );

afterEach( () => {
vi.restoreAllMocks();
} );

it( 'replaces the cached copy when wordpress.org reports a newer release', async () => {
mockStableCheck( { '6.9.5': 'outdated', '6.9.7': 'latest' } );

await updateLatestWordPressVersion();

// The outgoing release is kept under its own version number, so a site
// pinned to it doesn't have to download it again.
expect( recursiveCopyDirectory ).toHaveBeenCalledWith(
getWordPressVersionPath( 'latest' ),
getWordPressVersionPath( '6.9.5' )
);
expect( downloadFile ).toHaveBeenCalledTimes( 1 );
expect( extractZip ).toHaveBeenCalledTimes( 1 );
} );

it( 'leaves the cached copy alone when it is already the current release', async () => {
mockStableCheck( { '6.9.5': 'latest' } );

await updateLatestWordPressVersion();

expect( recursiveCopyDirectory ).not.toHaveBeenCalled();
expect( downloadFile ).not.toHaveBeenCalled();
} );

it( 'throws when wordpress.org is unreachable instead of serving the stale copy', async () => {
vi.spyOn( global, 'fetch' ).mockRejectedValue( new Error( 'network' ) );

await expect( updateLatestWordPressVersion() ).rejects.toThrow( 'network' );
expect( downloadFile ).not.toHaveBeenCalled();
} );

it( 'throws when wordpress.org reports no latest release', async () => {
mockStableCheck( { '6.9.5': 'outdated' } );

await expect( updateLatestWordPressVersion() ).rejects.toThrow(
'did not report a latest WordPress version'
);
expect( downloadFile ).not.toHaveBeenCalled();
} );

it( 'downloads without a version check when nothing is cached yet', async () => {
vi.mocked( fs.promises.readdir ).mockResolvedValue( [] as never );
vi.mocked( fs.existsSync ).mockReturnValue( false );

await updateLatestWordPressVersion();

expect( global.fetch ).not.toHaveBeenCalled();
expect( downloadFile ).toHaveBeenCalledTimes( 1 );
} );

it( 'queries the documented stable-check endpoint', async () => {
mockStableCheck( { '6.9.5': 'latest' } );

await updateLatestWordPressVersion();

expect( global.fetch ).toHaveBeenCalledWith( STABLE_CHECK_URL );
} );
} );
48 changes: 18 additions & 30 deletions apps/cli/lib/dependency-management/wordpress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import { downloadFile } from '@studio/common/lib/download-file';
import { extractZip } from '@studio/common/lib/extract-zip';
import { recursiveCopyDirectory } from '@studio/common/lib/fs-utils';
import { getWordPressVersionUrl } from '@studio/common/lib/wordpress-version-utils';
import semver from 'semver';
import { getWordPressVersionPath } from './paths';

const MINIMUM_SUPPORTED_WP_VERSION = 6;
const DEFAULT_WORDPRESS_VERSION = 'latest';

export async function downloadWordPress(
Expand Down Expand Up @@ -50,32 +48,23 @@ export async function downloadWordPress(
}
}

async function fetchWordPressVersions() {
try {
const response = await fetch( 'https://api.wordpress.org/core/stable-check/1.0/' );
const versionsStatus: Record< string, string > = await response.json();
const versions = Object.keys( versionsStatus )
.filter( ( item ) => {
const version = semver.coerce( item );
const minVersion = semver.coerce( MINIMUM_SUPPORTED_WP_VERSION );
return version && minVersion && semver.gte( version, minVersion );
} )
.sort( ( a, b ) => {
const versionA = semver.coerce( a );
const versionB = semver.coerce( b );
if ( ! versionA || ! versionB ) {
return 0;
}
return semver.compare( versionA, versionB );
} )
.reverse();
const latestVersion = Object.keys( versionsStatus ).find(
( index ) => versionsStatus[ index ] === 'latest'
);
return { versions, latest: latestVersion };
} catch ( exception ) {
return { versions: [], latest: DEFAULT_WORDPRESS_VERSION };
/**
* Throws when the current release can't be determined. Callers must not treat
* an unreachable wordpress.org as "the cached copy is fine" — that silently
* hands a stale WordPress to a site the user asked to keep auto-updated.
*/
async function fetchLatestWordPressVersion(): Promise< string > {
const response = await fetch( 'https://api.wordpress.org/core/stable-check/1.0/' );
const versionsStatus: Record< string, string > = await response.json();
const latestVersion = Object.keys( versionsStatus ).find(
( index ) => versionsStatus[ index ] === 'latest'
);

if ( ! latestVersion ) {
throw new Error( 'wordpress.org did not report a latest WordPress version' );
}

return latestVersion;
}

export async function getWordPressVersionFromInstallation( installationPath: string ) {
Expand All @@ -102,10 +91,9 @@ export async function updateLatestWordPressVersion() {
if ( latestVersionFiles.length !== 0 ) {
const installedVersion = await getWordPressVersionFromInstallation( latestVersionPath );

const wordPressVersions = await fetchWordPressVersions();
const latestVersion = wordPressVersions.latest ?? DEFAULT_WORDPRESS_VERSION;
const latestVersion = await fetchLatestWordPressVersion();

if ( installedVersion && latestVersion !== 'latest' && installedVersion !== latestVersion ) {
if ( installedVersion && installedVersion !== latestVersion ) {
// We keep a copy of the latest installed version instead of removing it.
await recursiveCopyDirectory(
latestVersionPath,
Expand Down
Loading