From d8275e49430cac491bdfcc19f32d343d215f564b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 13 May 2026 20:54:42 -0400 Subject: [PATCH 1/2] feat(cli): seed latest site creation from SQLite template --- apps/cli/commands/site/create.ts | 37 +++- apps/cli/commands/site/tests/create.test.ts | 54 +++++ apps/cli/package.json | 3 +- package.json | 1 + .../generate-preinstalled-sqlite-template.ts | 186 ++++++++++++++++++ 5 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 scripts/generate-preinstalled-sqlite-template.ts diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index fa363b5029..7c12d300b5 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -61,7 +61,7 @@ import { updateSiteLatestCliPid, } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; -import { getAiInstructionsPath } from 'cli/lib/dependency-management/paths'; +import { getAiInstructionsPath, getWpFilesPath } from 'cli/lib/dependency-management/paths'; import { updateServerFiles } from 'cli/lib/dependency-management/setup'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; import { getPreferredSiteLanguage } from 'cli/lib/site-language'; @@ -78,6 +78,12 @@ import { StudioArgv } from 'cli/types'; const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ]; +const PREINSTALLED_SQLITE_TEMPLATE_PATH = path.join( + 'preinstalled-sqlite', + 'latest', + '.ht.sqlite' +); + const logger = new Logger< LoggerAction >(); type CreateCommandOptions = { @@ -103,6 +109,34 @@ function resolveRuntimeFromEnv(): SiteRuntime { return siteRuntimeSchema.catch( 'playground' ).parse( process.env.STUDIO_RUNTIME ); } +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 @@ -231,6 +265,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 a7acab12b0..2150d826a3 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -22,6 +22,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 { copyLanguagePackToSite } from 'cli/lib/language-packs'; import { getPreferredSiteLanguage } from 'cli/lib/site-language'; @@ -67,6 +68,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( import( '@studio/common/lib/well-known-paths' ), async ( importOriginal ) => { const actual = await importOriginal(); return { @@ -287,6 +295,52 @@ 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 = '/test/wp-files/preinstalled-sqlite/latest/.ht.sqlite'; + const databasePath = '/test/site/new-site/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( '/test/site/new-site/wp-content/database', { + 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 = '/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 72edee74fe..baa0b7f6e0 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 0414c9ee05..4930cefeb6 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,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(); From dfefdd69310037400ca9d82d6ff68f85c147a632 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 13 May 2026 22:29:55 -0400 Subject: [PATCH 2/2] test(cli): make SQLite template test paths platform-native --- apps/cli/commands/site/tests/create.test.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index 2150d826a3..4d3a6e6421 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import path from 'path'; import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; import { isEmptyDir, @@ -296,8 +297,13 @@ describe( 'CLI: studio site create', () => { } ); it( 'should copy bundled preinstalled SQLite template for latest WordPress sites', async () => { - const templatePath = '/test/wp-files/preinstalled-sqlite/latest/.ht.sqlite'; - const databasePath = '/test/site/new-site/wp-content/database/.ht.sqlite'; + 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; } ); @@ -307,7 +313,7 @@ describe( 'CLI: studio site create', () => { await runCommand( mockSitePath, { ...defaultTestOptions } ); expect( getWpFilesPath ).toHaveBeenCalled(); - expect( mkdirSpy ).toHaveBeenCalledWith( '/test/site/new-site/wp-content/database', { + expect( mkdirSpy ).toHaveBeenCalledWith( path.dirname( databasePath ), { recursive: true, } ); expect( copyFileSpy ).toHaveBeenCalledWith( templatePath, databasePath ); @@ -328,7 +334,12 @@ describe( 'CLI: studio site create', () => { } ); it( 'should not copy bundled preinstalled SQLite template for no-start sites', async () => { - const templatePath = '/test/wp-files/preinstalled-sqlite/latest/.ht.sqlite'; + const templatePath = path.join( + '/test/wp-files', + 'preinstalled-sqlite', + 'latest', + '.ht.sqlite' + ); const existsSyncSpy = vi.spyOn( fs, 'existsSync' ).mockImplementation( ( filePath ) => { return filePath === templatePath; } );