Skip to content
Open
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
36 changes: 36 additions & 0 deletions apps/cli/commands/site/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client';
import {
getAiInstructionsPath,
getWpFilesPath,
getWordPressVersionPath,
} from 'cli/lib/dependency-management/paths';
import { updateServerFiles } from 'cli/lib/dependency-management/setup';
Expand All @@ -81,6 +82,12 @@ import { runBlueprint, startWordPressServer } from 'cli/lib/wordpress-server-man
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

const PREINSTALLED_SQLITE_TEMPLATE_PATH = path.join(
'preinstalled-sqlite',
'latest',
'.ht.sqlite'
);

const logger = new Logger< LoggerAction >();

export type CreateCommandOptions = {
Expand All @@ -102,6 +109,34 @@ export type CreateCommandOptions = {
skipLogDetails: boolean;
};

async function copyPreinstalledSqliteTemplateIfAvailable(
sitePath: string,
wpVersion: string,
noStart: boolean
): Promise< boolean > {
if ( noStart ) {
return false;
}

if ( wpVersion !== DEFAULT_WORDPRESS_VERSION ) {
return false;
}

const templatePath = path.join( getWpFilesPath(), PREINSTALLED_SQLITE_TEMPLATE_PATH );
if ( ! fs.existsSync( templatePath ) ) {
return false;
}

const databasePath = path.join( sitePath, 'wp-content', 'database', '.ht.sqlite' );
if ( fs.existsSync( databasePath ) ) {
return false;
}

await fs.promises.mkdir( path.dirname( databasePath ), { recursive: true } );
await fs.promises.copyFile( templatePath, databasePath );
return true;
}

export async function runCommand(
sitePath: string,
options: CreateCommandOptions
Expand Down Expand Up @@ -246,6 +281,7 @@ export async function runCommand(

logger.reportStart( LoggerAction.INSTALL_SQLITE, __( 'Setting up SQLite integration…' ) );
const isSqliteUpdated = await keepSqliteIntegrationUpdated( sitePath );
await copyPreinstalledSqliteTemplateIfAvailable( sitePath, options.wpVersion, options.noStart );
logger.reportSuccess(
isSqliteUpdated ? __( 'SQLite integration configured' ) : __( 'SQLite integration skipped' )
);
Expand Down
64 changes: 64 additions & 0 deletions apps/cli/commands/site/tests/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from 'cli/lib/cli-config/core';
import { removeSiteFromConfig, updateSiteAutoStart } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getWpFilesPath } from 'cli/lib/dependency-management/paths';
import { updateServerFiles } from 'cli/lib/dependency-management/setup';
import { downloadWordPress } from 'cli/lib/dependency-management/wordpress';
import { copyLanguagePackToSite } from 'cli/lib/language-packs';
Expand Down Expand Up @@ -70,6 +71,13 @@ vi.mock( 'cli/lib/cli-config/sites', async () => {
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/paths', async () => {
const actual = await vi.importActual( 'cli/lib/dependency-management/paths' );
return {
...actual,
getWpFilesPath: vi.fn().mockReturnValue( '/test/wp-files' ),
};
} );
vi.mock( 'cli/lib/dependency-management/wordpress' );
vi.mock( import( '@studio/common/lib/well-known-paths' ), async ( importOriginal ) => {
const actual = await importOriginal();
Expand Down Expand Up @@ -309,6 +317,62 @@ describe( 'CLI: studio site create', () => {
expect( loggerReportSuccessSpy ).toHaveBeenCalledWith( 'SQLite integration skipped' );
} );

it( 'should copy bundled preinstalled SQLite template for latest WordPress sites', async () => {
const templatePath = path.join(
'/test/wp-files',
'preinstalled-sqlite',
'latest',
'.ht.sqlite'
);
const databasePath = path.join( mockSitePath, 'wp-content', 'database', '.ht.sqlite' );
const existsSyncSpy = vi.spyOn( fs, 'existsSync' ).mockImplementation( ( filePath ) => {
return filePath === templatePath;
} );
const mkdirSpy = vi.spyOn( fs.promises, 'mkdir' ).mockResolvedValue( undefined );
const copyFileSpy = vi.spyOn( fs.promises, 'copyFile' ).mockResolvedValue( undefined );

await runCommand( mockSitePath, { ...defaultTestOptions } );

expect( getWpFilesPath ).toHaveBeenCalled();
expect( mkdirSpy ).toHaveBeenCalledWith( path.dirname( databasePath ), {
recursive: true,
} );
expect( copyFileSpy ).toHaveBeenCalledWith( templatePath, databasePath );

existsSyncSpy.mockRestore();
} );

it( 'should not copy bundled preinstalled SQLite template for specific WordPress versions', async () => {
const copyFileSpy = vi.spyOn( fs.promises, 'copyFile' ).mockResolvedValue( undefined );

await runCommand( mockSitePath, {
...defaultTestOptions,
wpVersion: '6.8',
noStart: true,
} );

expect( copyFileSpy ).not.toHaveBeenCalled();
} );

it( 'should not copy bundled preinstalled SQLite template for no-start sites', async () => {
const templatePath = path.join(
'/test/wp-files',
'preinstalled-sqlite',
'latest',
'.ht.sqlite'
);
const existsSyncSpy = vi.spyOn( fs, 'existsSync' ).mockImplementation( ( filePath ) => {
return filePath === templatePath;
} );
const copyFileSpy = vi.spyOn( fs.promises, 'copyFile' ).mockResolvedValue( undefined );

await runCommand( mockSitePath, { ...defaultTestOptions, noStart: true } );

expect( copyFileSpy ).not.toHaveBeenCalled();

existsSyncSpy.mockRestore();
} );

it( 'should create site with custom name', async () => {
await runCommand( mockSitePath, {
...defaultTestOptions,
Expand Down
3 changes: 2 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@
"build": "vite build --config ./vite.config.dev.ts",
"build:prod": "vite build --config ./vite.config.prod.ts",
"build:npm": "vite build --config ./vite.config.npm.ts",
"generate:preinstalled-sqlite-template": "ts-node ../../scripts/generate-preinstalled-sqlite-template.ts",
"install:bundle": "npm install --omit=dev --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
"package": "npm run install:bundle && npm run build:prod",
"package": "npm run install:bundle && npm run build:prod && npm run generate:preinstalled-sqlite-template",
"lint": "eslint .",
"watch": "vite build --config ./vite.config.dev.ts --watch",
"prepublishOnly": "npm run build:npm",
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"cli:build": "npm -w wp-studio run build",
"cli:build:prod": "npm -w wp-studio run build:prod",
"cli:build:npm": "npm -w wp-studio run build:npm",
"cli:generate-preinstalled-sqlite-template": "npm -w wp-studio run generate:preinstalled-sqlite-template",
"cli:package": "npm -w wp-studio run package",
"cli:watch": "npm -w wp-studio run watch",
"app:install:bundle": "npm -w studio-app run install:bundle",
Expand Down
186 changes: 186 additions & 0 deletions scripts/generate-preinstalled-sqlite-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';

const CLI_DIR = process.env.STUDIO_CLI_DIST_DIR
? path.resolve( process.env.STUDIO_CLI_DIST_DIR )
: path.resolve( __dirname, '../apps/cli/dist/cli' );
const CLI_PATH = path.join( CLI_DIR, 'main.mjs' );
const TEMPLATE_PATH = path.join( CLI_DIR, 'wp-files/preinstalled-sqlite/latest/.ht.sqlite' );
const SESSION_PATH = path.join(
'/tmp',
`studio-preinstalled-sqlite-${ process.pid }-${ Date.now() }`
);
const TEMPLATE_BACKUP_PATH = path.join( SESSION_PATH, 'previous-template.ht.sqlite' );
const GENERATED_SITE_PATH = path.join( SESSION_PATH, 'generated-site' );
const VALIDATION_SITE_PATH = path.join( SESSION_PATH, 'validation-site' );
const ADMIN_USERNAME = 'admin';
const ADMIN_PASSWORD = 'password';
const ADMIN_EMAIL = 'admin@localhost.com';

function commandEnv() {
return {
...process.env,
E2E: 'true',
E2E_CLI_CONFIG_PATH: path.join( SESSION_PATH, 'cliConfig' ),
E2E_SHARED_CONFIG_PATH: path.join( SESSION_PATH, 'sharedConfig' ),
HOME: path.join( SESSION_PATH, 'home' ),
};
}

function runCli( args: string[], options: { allowFailure?: boolean } = {} ) {
const result = spawnSync( process.execPath, [ CLI_PATH, ...args ], {
env: commandEnv(),
encoding: 'utf8',
stdio: 'pipe',
timeout: 420_000,
} );

if ( result.error ) {
throw result.error;
}

if ( result.status !== 0 && ! options.allowFailure ) {
throw new Error(
`studio ${ args.join( ' ' ) } failed with exit ${ result.status }:\n${ result.stderr.slice(
-4000
) }`
);
}

return result;
}

function createSite( sitePath: string, siteName: string ) {
runCli( [
'site',
'create',
'--name',
siteName,
'--path',
sitePath,
'--wp',
'latest',
'--php',
'8.3',
'--admin-username',
ADMIN_USERNAME,
'--admin-password',
ADMIN_PASSWORD,
'--admin-email',
ADMIN_EMAIL,
'--skip-browser',
'--skip-log-details',
] );
}

function stopSite( sitePath: string ) {
runCli( [ 'site', 'stop', '--path', sitePath ], { allowFailure: true } );
}

function parseJsonObject( text: string ) {
const start = text.indexOf( '{' );
const end = text.lastIndexOf( '}' );
if ( start === -1 || end === -1 || end < start ) {
throw new Error( `Expected JSON object in command output:\n${ text.slice( -1000 ) }` );
}
return JSON.parse( text.slice( start, end + 1 ) );
}

function siteStatus( sitePath: string ) {
const result = runCli( [ 'site', 'status', '--path', sitePath, '--format', 'json' ] );
return parseJsonObject( result.stdout );
}

function wpEvalJson( sitePath: string, code: string ) {
const result = runCli( [ 'wp', '--path', sitePath, 'eval', `echo wp_json_encode(${ code });` ] );
return parseJsonObject( result.stdout );
}

async function assertWpAdminDoesNotRequireUpgrade( siteUrl: string ) {
const response = await fetch( new URL( '/wp-admin/', siteUrl ), {
signal: AbortSignal.timeout( 30_000 ),
} );
if ( response.url.includes( '/wp-admin/upgrade.php' ) ) {
throw new Error( `Generated template requires a database upgrade: ${ response.url }` );
}
if ( response.status < 200 || response.status >= 400 ) {
throw new Error( `wp-admin returned HTTP ${ response.status }: ${ response.url }` );
}
}

function assertAdminPassword( sitePath: string ) {
runCli( [ 'wp', '--path', sitePath, 'user', 'check-password', ADMIN_USERNAME, ADMIN_PASSWORD ] );
}

async function validateGeneratedTemplate() {
createSite( VALIDATION_SITE_PATH, 'Studio Template Validation' );
const status = siteStatus( VALIDATION_SITE_PATH );
await assertWpAdminDoesNotRequireUpgrade( status.siteUrl );
const options = wpEvalJson(
VALIDATION_SITE_PATH,
`[
'home' => get_option('home'),
'siteurl' => get_option('siteurl'),
'permalink_structure' => get_option('permalink_structure'),
'blogname' => get_option('blogname'),
]`
);
if ( options.home !== status.siteUrl.replace( /\/$/, '' ) ) {
throw new Error( `Generated template did not specialize home: ${ options.home }` );
}
if ( options.siteurl !== options.home ) {
throw new Error( `Generated template home/siteurl mismatch: ${ JSON.stringify( options ) }` );
}
if ( options.permalink_structure !== '/%year%/%monthnum%/%day%/%postname%/' ) {
throw new Error( `Generated template permalink mismatch: ${ JSON.stringify( options ) }` );
}
if ( options.blogname !== 'Studio Template Validation' ) {
throw new Error( `Generated template blogname mismatch: ${ JSON.stringify( options ) }` );
}
assertAdminPassword( VALIDATION_SITE_PATH );
}

async function main() {
if ( ! fs.existsSync( CLI_PATH ) ) {
throw new Error( `Studio CLI build not found at ${ CLI_PATH }. Run the CLI build first.` );
}

fs.mkdirSync( SESSION_PATH, { recursive: true } );
fs.mkdirSync( path.dirname( TEMPLATE_PATH ), { recursive: true } );

let hadPreviousTemplate = false;
try {
if ( fs.existsSync( TEMPLATE_PATH ) ) {
hadPreviousTemplate = true;
fs.renameSync( TEMPLATE_PATH, TEMPLATE_BACKUP_PATH );
}

createSite( GENERATED_SITE_PATH, 'Studio Template Seed' );
const generatedDatabasePath = path.join(
GENERATED_SITE_PATH,
'wp-content/database/.ht.sqlite'
);
if ( ! fs.existsSync( generatedDatabasePath ) ) {
throw new Error( `Template database was not created at ${ generatedDatabasePath }` );
}
fs.copyFileSync( generatedDatabasePath, TEMPLATE_PATH );
console.log( `[preinstalled-sqlite] Generated ${ TEMPLATE_PATH }` );

await validateGeneratedTemplate();
console.log( '[preinstalled-sqlite] Generated template validation passed' );
} catch ( error ) {
if ( hadPreviousTemplate && fs.existsSync( TEMPLATE_BACKUP_PATH ) ) {
fs.copyFileSync( TEMPLATE_BACKUP_PATH, TEMPLATE_PATH );
} else {
fs.rmSync( TEMPLATE_PATH, { force: true } );
}
throw error;
} finally {
stopSite( VALIDATION_SITE_PATH );
stopSite( GENERATED_SITE_PATH );
fs.rmSync( SESSION_PATH, { recursive: true, force: true } );
}
}

void main();