Skip to content
Draft
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
75 changes: 59 additions & 16 deletions apps/cli/commands/config/set.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import path from 'node:path';
import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants';
import { SITE_EVENTS } from '@studio/common/lib/cli-events';
import { getDomainNameValidationError } from '@studio/common/lib/domains';
import { arePathsEqual } from '@studio/common/lib/fs-utils';
import { arePathsEqual, pathExists, recursiveCopyDirectory } from '@studio/common/lib/fs-utils';
import {
encodePassword,
validateAdminEmail,
Expand Down Expand Up @@ -38,9 +39,12 @@ import {
readCliConfig,
saveCliConfig,
unlockCliConfig,
type SiteData,
} from 'cli/lib/cli-config/core';
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client';
import { getWordPressVersionPath } from 'cli/lib/dependency-management/paths';
import { downloadWordPress } from 'cli/lib/dependency-management/wordpress';
import { updateDomainInHosts } from 'cli/lib/hosts-file';
import { validateSupportedPhpVersion } from 'cli/lib/php-versions';
import { runWpCliCommand } from 'cli/lib/run-wp-cli-command';
Expand Down Expand Up @@ -72,6 +76,59 @@ export interface SetCommandOptions {
debugDisplay?: boolean;
}

// `site create` puts the WordPress files in place but doesn't install WordPress — that happens
// on the first start, which is what writes wp-config.php and creates the database. Both being
// absent is what makes WP-CLI unable to boot the site at all.
//
// A missing wp-config.php alone isn't enough to conclude that: an imported site's config can
// live outside the site directory (its constants come from the runtime prepend instead, which
// is why the server start skips wp-config handling for it), and a site can simply have lost
// its config. Those have real content, so they must keep taking the WP-CLI path rather than
// having core files copied over them.
async function isUnprovisionedSite( site: SiteData ): Promise< boolean > {
if ( site.runtimeBlueprintPath ) {
return false;
}

const [ hasConfig, hasDatabase ] = await Promise.all( [
pathExists( path.join( site.path, 'wp-config.php' ) ),
pathExists( path.join( site.path, 'wp-content', 'database', '.ht.sqlite' ) ),
] );
return ! hasConfig && ! hasDatabase;
}

// Nothing but the core files on disk decides an unprovisioned site's version, so swap those in
// the way `site create --wp` does; the WordPress installer runs against them on the first start.
async function setWordPressVersion( site: SiteData, wp: string ): Promise< void > {
if ( await isUnprovisionedSite( site ) ) {
await downloadWordPress( wp );
await recursiveCopyDirectory( getWordPressVersionPath( wp ), site.path );
return;
}

await using command = await runWpCliCommand( site, [
'core',
'update',
getWordPressVersionUrl( wp ),
'--force',
'--skip-plugins',
'--skip-themes',
] );

if ( ( await command.response.exitCode ) === 0 ) {
return;
}

throw new LoggerError(
[
sprintf( __( 'Failed to update WordPress version to %s' ), wp ),
await command.response.outputText(),
]
.filter( Boolean )
.join( '\n' )
);
}

export async function runCommand( sitePath: string, options: SetCommandOptions ): Promise< void > {
const {
name,
Expand Down Expand Up @@ -314,21 +371,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions )

if ( wpChanged ) {
logger.reportStart( LoggerAction.SET_WP_VERSION, __( 'Updating WordPress version…' ) );
const zipUrl = getWordPressVersionUrl( wp );

await using command = await runWpCliCommand( site, [
'core',
'update',
zipUrl,
'--force',
'--skip-plugins',
'--skip-themes',
] );

const exitCode = await command.response.exitCode;
if ( exitCode !== 0 ) {
throw new LoggerError( sprintf( __( 'Failed to update WordPress version to %s' ), wp ) );
}
await setWordPressVersion( site, wp );
logger.reportSuccess( __( 'WordPress version updated' ) );

try {
Expand Down
112 changes: 97 additions & 15 deletions apps/cli/commands/config/tests/set.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Readable } from 'node:stream';
import { getDomainNameValidationError } from '@studio/common/lib/domains';
import { arePathsEqual } from '@studio/common/lib/fs-utils';
import { arePathsEqual, pathExists, recursiveCopyDirectory } from '@studio/common/lib/fs-utils';
import { encodePassword } from '@studio/common/lib/passwords';
import {
SITE_MODE_NATIVE,
Expand All @@ -11,6 +12,8 @@ import { vi } from 'vitest';
import { readCliConfig, saveCliConfig, unlockCliConfig, SiteData } from 'cli/lib/cli-config/core';
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getWordPressVersionPath } from 'cli/lib/dependency-management/paths';
import { downloadWordPress } from 'cli/lib/dependency-management/wordpress';
import { updateDomainInHosts } from 'cli/lib/hosts-file';
import { runWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command';
import { setupCustomDomain } from 'cli/lib/site-utils';
Expand All @@ -28,6 +31,8 @@ vi.mock( '@studio/common/lib/fs-utils', async () => {
return {
...actual,
arePathsEqual: vi.fn(),
pathExists: vi.fn(),
recursiveCopyDirectory: vi.fn(),
};
} );
vi.mock( 'cli/lib/cli-config/core', async () => {
Expand All @@ -49,9 +54,17 @@ vi.mock( 'cli/lib/cli-config/sites', async () => {
};
} );
vi.mock( 'cli/lib/certificate-manager' );
vi.mock( 'cli/lib/dependency-management/paths' );
vi.mock( 'cli/lib/dependency-management/wordpress' );
vi.mock( 'cli/lib/hosts-file' );
vi.mock( 'cli/lib/daemon-client' );
vi.mock( 'cli/lib/run-wp-cli-command' );
vi.mock( 'cli/lib/run-wp-cli-command', async () => {
const actual = await vi.importActual( 'cli/lib/run-wp-cli-command' );
return {
...actual,
runWpCliCommand: vi.fn(),
};
} );
vi.mock( 'cli/lib/site-utils' );
vi.mock( 'cli/lib/wordpress-server-manager' );

Expand All @@ -74,6 +87,13 @@ describe( 'CLI: studio config set', () => {
enableHttps: false,
} );

// Defaults to a provisioned site: WordPress has been installed, so both exist.
const mockSiteFiles = ( { wpConfig = true, database = true } = {} ) => {
vi.mocked( pathExists ).mockImplementation( async ( filePath: string ) =>
filePath.endsWith( 'wp-config.php' ) ? wpConfig : database
);
};

const testProcessDescription: ProcessDescription = {
name: 'test-site',
pmId: 0,
Expand All @@ -89,6 +109,12 @@ describe( 'CLI: studio config set', () => {
const testCliConfig = { version: 1 as const, sites: [ testSite ], snapshots: [] };

vi.mocked( arePathsEqual ).mockReturnValue( true );
mockSiteFiles();
vi.mocked( recursiveCopyDirectory ).mockResolvedValue( undefined );
vi.mocked( downloadWordPress ).mockResolvedValue( undefined );
vi.mocked( getWordPressVersionPath ).mockImplementation(
( version ) => `/wp-versions/${ version }`
);
vi.mocked( getSiteByFolder ).mockResolvedValue( getTestSite() );
vi.mocked( readCliConfig ).mockResolvedValue( testCliConfig );
vi.mocked( connectToDaemon ).mockResolvedValue( undefined );
Expand Down Expand Up @@ -287,14 +313,19 @@ describe( 'CLI: studio config set', () => {
} );

describe( 'WordPress version changes', () => {
beforeEach( () => {
const mockResponse: Partial< WpCliResponse > = {
exitCode: Promise.resolve( 0 ),
};
const mockWpCliResult = ( exitCode: number, stdout = '', stderr = '' ) => {
vi.mocked( runWpCliCommand ).mockResolvedValue( {
response: mockResponse as WpCliResponse,
[ Symbol.dispose ]: vi.fn().mockResolvedValue( undefined ),
response: new WpCliResponse(
Readable.from( [ stdout ] ),
Readable.from( [ stderr ] ),
Promise.resolve( exitCode )
),
[ Symbol.dispose ]: vi.fn(),
} );
};

beforeEach( () => {
mockWpCliResult( 0 );
} );

it( 'should run WP-CLI to update WordPress version', async () => {
Expand All @@ -317,19 +348,70 @@ describe( 'CLI: studio config set', () => {
} );

it( 'should throw when WP-CLI fails', async () => {
const mockResponse: Partial< WpCliResponse > = {
exitCode: Promise.resolve( 1 ),
};
vi.mocked( runWpCliCommand ).mockResolvedValue( {
response: mockResponse as WpCliResponse,
[ Symbol.dispose ]: vi.fn().mockResolvedValue( undefined ),
} );
mockWpCliResult( 1 );

await expect( runCommand( testSitePath, { wp: '6.7' } ) ).rejects.toThrow(
'Failed to update WordPress version to 6.7'
);
} );

it( 'should surface the WP-CLI output when it fails', async () => {
mockWpCliResult( 1, 'some stdout noise', "Error: 'wp-config.php' not found." );

await expect( runCommand( testSitePath, { wp: '6.7' } ) ).rejects.toThrow(
/Failed to update WordPress version to 6\.7\nError: 'wp-config\.php' not found\.\nsome stdout noise/
);
} );

it( 'should copy the WordPress files instead of running WP-CLI on a never-started site', async () => {
mockSiteFiles( { wpConfig: false, database: false } );

await runCommand( testSitePath, { wp: '6.7' } );

expect( runWpCliCommand ).not.toHaveBeenCalled();
expect( downloadWordPress ).toHaveBeenCalledWith( '6.7' );
expect( recursiveCopyDirectory ).toHaveBeenCalledWith( '/wp-versions/6.7', testSitePath );
expect( saveCliConfig ).toHaveBeenCalledWith(
expect.objectContaining( {
sites: expect.arrayContaining( [
expect.objectContaining( { isWpAutoUpdating: false } ),
] ),
} )
);
} );

// A site can lose its wp-config.php and still have all of its content, so the database
// is what decides whether copying core files over it would be destructive.
it( 'should still run WP-CLI when wp-config.php is missing but a database exists', async () => {
mockSiteFiles( { wpConfig: false, database: true } );

await runCommand( testSitePath, { wp: '6.7' } );

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

// An imported site keeps its config outside the site directory, so a missing
// wp-config.php there says nothing about whether WordPress is installed.
it( 'should still run WP-CLI for an imported site with no wp-config.php', async () => {
const importedSite = {
...getTestSite(),
runtimeBlueprintPath: '/pulls/site-1/blueprint.json',
};
vi.mocked( getSiteByFolder ).mockResolvedValue( importedSite );
vi.mocked( readCliConfig ).mockResolvedValue( {
sites: [ importedSite ],
version: 1,
snapshots: [],
} );
mockSiteFiles( { wpConfig: false, database: false } );

await runCommand( testSitePath, { wp: '6.7' } );

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

it( 'should update isWpAutoUpdating to false when using specific version', async () => {
await runCommand( testSitePath, { wp: '6.8' } );

Expand Down
17 changes: 14 additions & 3 deletions apps/cli/lib/import-export/export/export-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export async function exportDatabaseToFile(

const exitCode = await command.response.exitCode;
if ( exitCode !== 0 ) {
throw new Error( __( 'Database export failed' ) );
throw new Error(
sprintf( __( 'Database export failed: %s' ), await command.response.outputText() )
);
}

// Move the file to its final destination
Expand Down Expand Up @@ -57,7 +59,9 @@ export async function exportDatabaseToMultipleFiles(
const tablesStdout = await command.response.stdoutText;
const exitCode = await command.response.exitCode;
if ( exitCode !== 0 ) {
throw new Error( __( 'Database export failed' ) );
throw new Error(
sprintf( __( 'Database export failed: %s' ), await command.response.outputText() )
);
}

let tables;
Expand Down Expand Up @@ -101,7 +105,14 @@ export async function exportDatabaseToMultipleFiles(

const exitCode = await command.response.exitCode;
if ( exitCode !== 0 ) {
throw new Error( sprintf( __( 'Database export failed for table %s' ), table ) );
throw new Error(
sprintf(
/* translators: 1: database table name, 2: WP-CLI output */
__( 'Database export failed for table %1$s: %2$s' ),
table,
await command.response.outputText()
)
);
}

// Move the file to its final destination
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,9 @@ export class DefaultExporter extends ImportExportEventEmitter implements Exporte
const stdout = await command.response.stdoutText;

if ( exitCode !== 0 ) {
throw new Error( sprintf( __( 'Failed to get site plugins: %s' ), stderr ) );
throw new Error(
sprintf( __( 'Failed to get site plugins: %s' ), await command.response.outputText() )
);
}

try {
Expand Down Expand Up @@ -413,7 +415,9 @@ export class DefaultExporter extends ImportExportEventEmitter implements Exporte
const stdout = await command.response.stdoutText;

if ( exitCode !== 0 ) {
throw new Error( sprintf( __( 'Failed to get site themes: %s' ), stderr ) );
throw new Error(
sprintf( __( 'Failed to get site themes: %s' ), await command.response.outputText() )
);
}

try {
Expand Down
51 changes: 51 additions & 0 deletions apps/cli/lib/import-export/export/tests/export-database.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Readable } from 'node:stream';
import { vi } from 'vitest';
import { runWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command';
import { exportDatabaseToFile, exportDatabaseToMultipleFiles } from '../export-database';
import type { SiteData } from 'cli/lib/cli-config/core';

vi.mock( 'cli/lib/run-wp-cli-command', async () => {
const actual = await vi.importActual( 'cli/lib/run-wp-cli-command' );
return {
...actual,
runWpCliCommand: vi.fn(),
};
} );

describe( 'export-database', () => {
const site = { id: 'site-1', name: 'Test Site', path: '/test/site', port: 8080 } as SiteData;

const mockWpCliFailure = ( stdout: string, stderr: string ) => {
vi.mocked( runWpCliCommand ).mockResolvedValue( {
response: new WpCliResponse(
Readable.from( [ stdout ] ),
Readable.from( [ stderr ] ),
Promise.resolve( 1 )
),
[ Symbol.dispose ]: vi.fn(),
} );
};

beforeEach( () => {
vi.clearAllMocks();
} );

// A site that has never been started has no database, so WP-CLI can't boot it. The
// reason has to reach the user instead of a bare "Database export failed".
it( 'surfaces the WP-CLI error when exporting a site that was never started', async () => {
mockWpCliFailure( '', 'Error: The site you have requested is not installed.' );

await expect( exportDatabaseToFile( site, '/tmp/out.sql' ) ).rejects.toThrow(
'Database export failed: Error: The site you have requested is not installed.'
);
} );

// Which stream carries the message depends on the runtime, so both are reported.
it( 'surfaces WP-CLI output sent to stdout', async () => {
mockWpCliFailure( 'Error: could not read the database.', '' );

await expect( exportDatabaseToMultipleFiles( site, '/tmp/out' ) ).rejects.toThrow(
'Database export failed: Error: could not read the database.'
);
} );
} );
Loading
Loading