Skip to content

Commit 47825b3

Browse files
authored
Add studio site create CLI command (#2131)
1 parent 8f87ddd commit 47825b3

39 files changed

+1702
-204
lines changed

cli/commands/site/create.ts

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
import crypto from 'crypto';
2+
import fs from 'fs';
3+
import path from 'path';
4+
import { SupportedPHPVersions } from '@php-wasm/universal';
5+
import { __, sprintf } from '@wordpress/i18n';
6+
import { Blueprint } from '@wp-playground/blueprints';
7+
import { RecommendedPHPVersion } from '@wp-playground/common';
8+
import {
9+
filterUnsupportedBlueprintFeatures,
10+
scanBlueprintForUnsupportedFeatures,
11+
validateBlueprintData,
12+
} from 'common/lib/blueprint-validation';
13+
import { getDomainNameValidationError } from 'common/lib/domains';
14+
import { arePathsEqual, isEmptyDir, isWordPressDirectory, pathExists } from 'common/lib/fs-utils';
15+
import { createPassword } from 'common/lib/passwords';
16+
import { portFinder } from 'common/lib/port-finder';
17+
import { sortSites } from 'common/lib/sort-sites';
18+
import {
19+
isValidWordPressVersion,
20+
isWordPressVersionAtLeast,
21+
} from 'common/lib/wordpress-version-utils';
22+
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
23+
import { lockAppdata, readAppdata, saveAppdata, SiteData, unlockAppdata } from 'cli/lib/appdata';
24+
import { connect, disconnect } from 'cli/lib/pm2-manager';
25+
import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils';
26+
import {
27+
isSqliteIntegrationAvailable,
28+
keepSqliteIntegrationUpdated,
29+
} from 'cli/lib/sqlite-integration';
30+
import { runBlueprint, startWordPressServer } from 'cli/lib/wordpress-server-manager';
31+
import { Logger, LoggerError } from 'cli/logger';
32+
import { StudioArgv } from 'cli/types';
33+
34+
const DEFAULT_VERSIONS = {
35+
php: RecommendedPHPVersion,
36+
wp: 'latest',
37+
} as const;
38+
const MINIMUM_WORDPRESS_VERSION = '6.2.1' as const; // https://wordpress.github.io/wordpress-playground/blueprints/examples/#load-an-older-wordpress-version
39+
const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ];
40+
41+
export async function runCommand(
42+
sitePath: string,
43+
options: {
44+
name?: string;
45+
wpVersion: string;
46+
phpVersion: ( typeof ALLOWED_PHP_VERSIONS )[ number ];
47+
customDomain?: string;
48+
enableHttps: boolean;
49+
blueprint?: string;
50+
noStart: boolean;
51+
}
52+
): Promise< void > {
53+
const logger = new Logger< LoggerAction >();
54+
55+
try {
56+
logger.reportStart( LoggerAction.VALIDATE, __( 'Validating site configuration...' ) );
57+
58+
const pathExistsResult = await pathExists( sitePath );
59+
const isEmptyDirResult = pathExistsResult && ( await isEmptyDir( sitePath ) );
60+
const isWordPressDirResult = pathExistsResult && isWordPressDirectory( sitePath );
61+
62+
if ( pathExistsResult && ! isEmptyDirResult && ! isWordPressDirResult ) {
63+
throw new LoggerError(
64+
__( 'The selected directory is not empty nor an existing WordPress site.' )
65+
);
66+
}
67+
68+
if ( ! isValidWordPressVersion( options.wpVersion ) ) {
69+
throw new LoggerError(
70+
__(
71+
'Invalid WordPress version. Must be "latest", "nightly", or a valid version number (e.g., "6.4", "6.4.1", "6.4-beta1").'
72+
)
73+
);
74+
}
75+
if ( ! isWordPressVersionAtLeast( options.wpVersion, MINIMUM_WORDPRESS_VERSION ) ) {
76+
throw new LoggerError(
77+
__( `WordPress version must be at least ${ MINIMUM_WORDPRESS_VERSION }.` )
78+
);
79+
}
80+
81+
let blueprint: Blueprint | undefined;
82+
if ( options.blueprint ) {
83+
if ( ! fs.existsSync( options.blueprint ) ) {
84+
throw new LoggerError( sprintf( __( 'Blueprint file not found: %s' ), options.blueprint ) );
85+
}
86+
let blueprintJson: Blueprint;
87+
try {
88+
const blueprintContent = fs.readFileSync( options.blueprint, 'utf-8' );
89+
blueprintJson = JSON.parse( blueprintContent );
90+
} catch ( error ) {
91+
throw new LoggerError(
92+
sprintf( __( 'Invalid blueprint JSON file: %s' ), options.blueprint ),
93+
error
94+
);
95+
}
96+
const validation = await validateBlueprintData( blueprintJson );
97+
if ( ! validation.valid ) {
98+
throw new LoggerError( validation.error );
99+
}
100+
101+
const unsupportedFeatures = scanBlueprintForUnsupportedFeatures( blueprintJson );
102+
if ( unsupportedFeatures.length > 0 ) {
103+
for ( const feature of unsupportedFeatures ) {
104+
logger.reportWarning(
105+
sprintf(
106+
/* translators: %1$s: feature name, %2$s: reason */
107+
__( `Blueprint feature "%1$s" is not supported: %2$s` ),
108+
feature.name,
109+
feature.reason
110+
)
111+
);
112+
}
113+
}
114+
115+
blueprint = filterUnsupportedBlueprintFeatures( blueprintJson ) as Blueprint;
116+
}
117+
118+
const appdata = await readAppdata();
119+
if ( appdata.sites.some( ( site ) => arePathsEqual( site.path, sitePath ) ) ) {
120+
throw new LoggerError( __( 'The selected directory is already in use.' ) );
121+
}
122+
123+
for ( const site of appdata.sites ) {
124+
portFinder.addUnavailablePort( site.port );
125+
}
126+
127+
if ( options.customDomain ) {
128+
const existingDomains = appdata.sites
129+
.map( ( site ) => site.customDomain )
130+
.filter( ( domain ): domain is string => Boolean( domain ) );
131+
const domainError = getDomainNameValidationError(
132+
true,
133+
options.customDomain,
134+
existingDomains
135+
);
136+
if ( domainError ) {
137+
throw new LoggerError( domainError );
138+
}
139+
}
140+
141+
logger.reportSuccess( __( 'Site configuration validated' ) );
142+
143+
if ( ! pathExistsResult ) {
144+
logger.reportStart( LoggerAction.CREATE_DIRECTORY, __( 'Creating site directory...' ) );
145+
fs.mkdirSync( sitePath, { recursive: true } );
146+
logger.reportSuccess( __( 'Site directory created' ) );
147+
}
148+
149+
if ( ! ( await isSqliteIntegrationAvailable() ) ) {
150+
throw new LoggerError(
151+
__(
152+
'SQLite integration files not found. Please ensure Studio Desktop is installed and has been run at least once.'
153+
)
154+
);
155+
}
156+
logger.reportStart( LoggerAction.INSTALL_SQLITE, __( 'Setting up SQLite integration...' ) );
157+
await keepSqliteIntegrationUpdated( sitePath );
158+
logger.reportSuccess( __( 'SQLite integration configured' ) );
159+
160+
logger.reportStart( LoggerAction.ASSIGN_PORT, __( 'Assigning port...' ) );
161+
const port = await portFinder.getOpenPort();
162+
logger.reportSuccess( __( 'Port assigned: ' ) + port );
163+
164+
const siteName = options.name || path.basename( sitePath );
165+
const siteId = crypto.randomUUID();
166+
const adminPassword = createPassword();
167+
168+
if ( options.name ) {
169+
if ( ! blueprint ) {
170+
blueprint = {};
171+
}
172+
const existingSteps = blueprint.steps || [];
173+
blueprint.steps = [
174+
{
175+
step: 'setSiteOptions',
176+
options: {
177+
blogname: options.name,
178+
},
179+
},
180+
...existingSteps,
181+
];
182+
}
183+
184+
const siteDetails: SiteData = {
185+
id: siteId,
186+
name: siteName,
187+
path: sitePath,
188+
adminPassword,
189+
port,
190+
phpVersion: options.phpVersion,
191+
running: false,
192+
isWpAutoUpdating: options.wpVersion === DEFAULT_VERSIONS.wp,
193+
customDomain: options.customDomain,
194+
enableHttps: options.enableHttps,
195+
};
196+
197+
logger.reportStart( LoggerAction.SAVE_SITE, __( 'Saving site...' ) );
198+
199+
try {
200+
await lockAppdata();
201+
const userData = await readAppdata();
202+
203+
userData.sites.push( siteDetails );
204+
sortSites( userData.sites );
205+
206+
await saveAppdata( userData );
207+
logger.reportSuccess( __( 'Site created successfully' ) );
208+
} finally {
209+
await unlockAppdata();
210+
}
211+
212+
if ( ! options.noStart ) {
213+
logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) );
214+
await connect();
215+
logger.reportSuccess( __( 'Process daemon started' ) );
216+
217+
await setupCustomDomain( siteDetails, logger );
218+
219+
const startMessage = options.blueprint
220+
? __( 'Starting WordPress site and applying blueprint...' )
221+
: __( 'Starting WordPress site...' );
222+
logger.reportStart( LoggerAction.START_SITE, startMessage );
223+
try {
224+
await startWordPressServer( siteDetails, { wpVersion: options.wpVersion, blueprint } );
225+
logger.reportSuccess( __( 'WordPress site started' ) );
226+
227+
logSiteDetails( siteDetails );
228+
await openSiteInBrowser( siteDetails );
229+
} catch ( error ) {
230+
throw new LoggerError( __( 'Failed to start WordPress server' ), error );
231+
}
232+
} else if ( blueprint ) {
233+
logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) );
234+
await connect();
235+
logger.reportSuccess( __( 'Process daemon started' ) );
236+
237+
logger.reportStart( LoggerAction.START_SITE, __( 'Applying blueprint...' ) );
238+
try {
239+
await runBlueprint( siteDetails, { wpVersion: options.wpVersion, blueprint } );
240+
logger.reportSuccess( __( 'Blueprint applied successfully' ) );
241+
} catch ( error ) {
242+
throw new LoggerError( __( 'Failed to apply blueprint' ), error );
243+
}
244+
245+
console.log( '' );
246+
console.log( __( 'Site created successfully!' ) );
247+
console.log( '' );
248+
logSiteDetails( siteDetails );
249+
console.log( __( 'Run "studio site start" to start the site.' ) );
250+
} else {
251+
console.log( '' );
252+
console.log( __( 'Site created successfully!' ) );
253+
console.log( '' );
254+
logSiteDetails( siteDetails );
255+
console.log( __( 'Run "studio site start" to start the site.' ) );
256+
}
257+
} catch ( error ) {
258+
if ( error instanceof LoggerError ) {
259+
logger.reportError( error );
260+
} else {
261+
const loggerError = new LoggerError( __( 'Failed to create site' ), error );
262+
logger.reportError( loggerError );
263+
}
264+
} finally {
265+
disconnect();
266+
}
267+
}
268+
269+
export const registerCommand = ( yargs: StudioArgv ) => {
270+
return yargs.command( {
271+
command: 'create',
272+
describe: __( 'Create a new local site' ),
273+
builder: ( yargs ) => {
274+
return yargs
275+
.option( 'name', {
276+
type: 'string',
277+
describe: __( 'Site name' ),
278+
} )
279+
.option( 'wp', {
280+
type: 'string',
281+
describe: __( 'WordPress version (e.g., "latest", "6.4", "6.4.1")' ),
282+
default: DEFAULT_VERSIONS.wp,
283+
} )
284+
.option( 'php', {
285+
type: 'string',
286+
describe: __( 'PHP version' ),
287+
choices: ALLOWED_PHP_VERSIONS,
288+
default: DEFAULT_VERSIONS.php,
289+
} )
290+
.option( 'domain', {
291+
type: 'string',
292+
describe: __( 'Custom domain (e.g., "mysite.local")' ),
293+
} )
294+
.option( 'https', {
295+
type: 'boolean',
296+
describe: __( 'Enable HTTPS for custom domain' ),
297+
default: false,
298+
} )
299+
.option( 'blueprint', {
300+
type: 'string',
301+
describe: __( 'Path to blueprint JSON file' ),
302+
} )
303+
.option( 'start', {
304+
type: 'boolean',
305+
describe: __( 'Start the site after creation' ),
306+
default: true,
307+
} );
308+
},
309+
handler: async ( argv ) => {
310+
await runCommand( argv.path, {
311+
name: argv.name,
312+
wpVersion: argv.wp,
313+
phpVersion: argv.php,
314+
customDomain: argv.domain,
315+
enableHttps: argv.https,
316+
blueprint: argv.blueprint,
317+
noStart: ! argv.start,
318+
} );
319+
},
320+
} );
321+
};

cli/commands/site/start.ts

Lines changed: 4 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,12 @@
11
import { __ } from '@wordpress/i18n';
22
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
3-
import { readAppdata, getSiteUrl, SiteData, updateSiteLatestCliPid } from 'cli/lib/appdata';
4-
import { openBrowser } from 'cli/lib/browser';
5-
import { generateSiteCertificate } from 'cli/lib/certificate-manager';
6-
import { addDomainToHosts } from 'cli/lib/hosts-file';
7-
import { connect, isProxyProcessRunning, startProxyProcess, disconnect } from 'cli/lib/pm2-manager';
3+
import { readAppdata, updateSiteLatestCliPid } from 'cli/lib/appdata';
4+
import { connect, disconnect } from 'cli/lib/pm2-manager';
5+
import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils';
86
import { isServerRunning, startWordPressServer } from 'cli/lib/wordpress-server-manager';
97
import { Logger, LoggerError } from 'cli/logger';
108
import { StudioArgv } from 'cli/types';
119

12-
async function startProxyIfNeeded( logger: Logger< LoggerAction > ) {
13-
const proxyProcess = await isProxyProcessRunning();
14-
if ( ! proxyProcess ) {
15-
logger.reportStart( LoggerAction.START_PROXY, __( 'Starting HTTP proxy server...' ) );
16-
await startProxyProcess();
17-
logger.reportSuccess( __( 'HTTP proxy server started' ) );
18-
} else {
19-
logger.reportSuccess( __( 'HTTP proxy already running' ) );
20-
}
21-
}
22-
23-
async function openSiteInBrowser( site: SiteData ) {
24-
const siteUrl = getSiteUrl( site );
25-
try {
26-
const autoLoginUrl = `${ siteUrl }/studio-auto-login?redirect_to=${ encodeURIComponent(
27-
`${ siteUrl }/wp-admin/`
28-
) }`;
29-
await openBrowser( autoLoginUrl );
30-
} catch ( error ) {
31-
// Silently fail if browser can't be opened
32-
}
33-
}
34-
35-
function logSiteDetails( site: SiteData ) {
36-
const siteUrl = getSiteUrl( site );
37-
console.log( __( 'Site URL: ' ), siteUrl );
38-
console.log( __( 'Username: ' ), 'admin' );
39-
console.log( __( 'Password: ' ), site.adminPassword );
40-
}
41-
4210
export async function runCommand( siteFolder: string, skipBrowser = false ): Promise< void > {
4311
const logger = new Logger< LoggerAction >();
4412

@@ -68,26 +36,7 @@ export async function runCommand( siteFolder: string, skipBrowser = false ): Pro
6836
return;
6937
}
7038

71-
if ( site.customDomain ) {
72-
await startProxyIfNeeded( logger );
73-
74-
if ( site.enableHttps && ( ! site.tlsKey || ! site.tlsCert ) ) {
75-
logger.reportStart( LoggerAction.GENERATE_CERT, __( 'Generating SSL certificates...' ) );
76-
await generateSiteCertificate( site.customDomain );
77-
logger.reportSuccess( __( 'SSL certificates generated' ) );
78-
}
79-
80-
logger.reportStart(
81-
LoggerAction.ADD_DOMAIN_TO_HOSTS,
82-
__( 'Adding domain to hosts file...' )
83-
);
84-
try {
85-
await addDomainToHosts( site.customDomain, site.port );
86-
logger.reportSuccess( __( 'Domain added to hosts file' ) );
87-
} catch ( error ) {
88-
throw new LoggerError( __( 'Failed to add domain to hosts file:' ), error );
89-
}
90-
}
39+
await setupCustomDomain( site, logger );
9140

9241
logger.reportStart( LoggerAction.START_SITE, __( 'Starting WordPress site...' ) );
9342
try {

0 commit comments

Comments
 (0)