diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 600c613ca4..9bb4cad929 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -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'; @@ -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 = { @@ -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 @@ -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' ) ); diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index cdbcdee393..c5bebfd9e7 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -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'; @@ -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(); @@ -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, diff --git a/apps/cli/package.json b/apps/cli/package.json index 47cad74e32..bcc0c79db1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", diff --git a/package.json b/package.json index af72dcfd27..ec3f6a4968 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/generate-preinstalled-sqlite-template.ts b/scripts/generate-preinstalled-sqlite-template.ts new file mode 100644 index 0000000000..290adbfc56 --- /dev/null +++ b/scripts/generate-preinstalled-sqlite-template.ts @@ -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();