Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
54d44be
Add blueprint execution via blueprints.phar for native PHP runtime
bcotrim Apr 28, 2026
533edf1
Remove test blueprint file
bcotrim Apr 28, 2026
9efbf02
Merge branch 'trunk' into claude/vigilant-turing-54f824
bcotrim Apr 28, 2026
56af1e4
Address review: junction symlink for Windows, drop dead cwd option, r…
bcotrim Apr 28, 2026
aae5d2f
Fix phpVersion arg after upstream changes to getPhpBinaryPath
bcotrim Apr 28, 2026
7a8d44e
Reusable and type-safe GitHub release fetching
fredrikekelund Apr 29, 2026
01dd213
Various fixes
fredrikekelund Apr 29, 2026
310c06b
Fix issues raised by AI reviews
fredrikekelund Apr 29, 2026
ada139b
Merge branch 'trunk' into claude/vigilant-turing-54f824
fredrikekelund Apr 29, 2026
a067bbe
Merge branch 'trunk' into claude/vigilant-turing-54f824
bcotrim Apr 29, 2026
b16a675
Merge remote-tracking branch 'origin/trunk' into claude/vigilant-turi…
bcotrim Apr 29, 2026
576d343
Implement blueprint execution via blueprints.phar for native PHP runtime
bcotrim Apr 29, 2026
ac5f746
Merge remote changes into native PHP blueprint implementation
bcotrim Apr 29, 2026
6e361a4
Update create.ts tests to reflect language/blogname steps moving to p…
bcotrim Apr 29, 2026
fe8f6b5
Switch to writeStudioMuPluginsForNativePhpRuntime from trunk (loader-…
bcotrim Apr 29, 2026
baf7e91
Kill php -S on SIGTERM/SIGINT/SIGHUP to prevent orphaned processes
bcotrim Apr 29, 2026
89e8c53
Replace signal handlers with parent-PID watchdog for orphaned process…
bcotrim Apr 29, 2026
f670d83
Force-kill orphaned PHP processes in e2e session cleanup
bcotrim Apr 29, 2026
24e019e
Revert e2e teardown timeout fixes — to be addressed in a follow-up
bcotrim Apr 29, 2026
1bd6576
Fix E2E test issues
fredrikekelund Apr 30, 2026
f2c909d
Merge branch 'trunk' into claude/vigilant-turing-54f824
bcotrim Apr 30, 2026
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 9 additions & 69 deletions apps/cli/commands/site/create.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import crypto from 'crypto';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { confirm, input, password, select } from '@inquirer/prompts';
import { SupportedPHPVersions } from '@php-wasm/universal';
Expand Down Expand Up @@ -45,11 +44,7 @@ import {
import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions';
import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __, sprintf } from '@wordpress/i18n';
import {
isStepDefinition,
type BlueprintV1Declaration,
type StepDefinition,
} from '@wp-playground/blueprints';
import { isStepDefinition, type BlueprintV1Declaration } from '@wp-playground/blueprints';
import { bumpStat, getPlatformMetric } from 'cli/lib/bump-stat';
import {
lockCliConfig,
Expand Down Expand Up @@ -279,71 +274,14 @@ export async function runCommand(
const externalPassword = options.adminPassword || blueprintCredentials?.adminPassword;
const adminPassword = externalPassword ? encodePassword( externalPassword ) : createPassword();

const setupSteps: StepDefinition[] = [];

const siteLanguage = await getPreferredSiteLanguage( options.wpVersion );

if ( siteLanguage && siteLanguage !== DEFAULT_LOCALE ) {
// For the 'latest' WP version, try using bundled language packs first to avoid
// a network round-trip. Fall back to the Playground setSiteLanguage step for
// non-latest versions or when bundled packs aren't available.
let isUsingBundledLanguagePacks = false;
if ( options.wpVersion === DEFAULT_WORDPRESS_VERSION ) {
isUsingBundledLanguagePacks = await copyLanguagePackToSite( sitePath, siteLanguage );
}

if ( isUsingBundledLanguagePacks ) {
setupSteps.push(
{
step: 'defineWpConfigConsts',
consts: {
WPLANG: siteLanguage,
},
},
{
step: 'setSiteOptions',
options: {
WPLANG: siteLanguage,
},
}
);
} else if ( isOnlineStatus ) {
setupSteps.push(
{
step: 'setSiteLanguage',
language: siteLanguage,
},
{
step: 'setSiteOptions',
options: {
WPLANG: siteLanguage,
},
}
);
}
}

const hasWpConfig = await pathExists( path.join( sitePath, 'wp-config.php' ) );
const isWordPressDirectoryInitialized = isWordPressDirResult && hasWpConfig;
if ( options.name && ! isWordPressDirectoryInitialized ) {
setupSteps.push( {
step: 'setSiteOptions',
options: {
blogname: options.name,
},
} );
}

if ( setupSteps.length > 0 ) {
if ( ! blueprint ) {
blueprint = {};
// Since we know the user didn't supply a blueprint, we create an empty directory to use as a
// fake location for the `blueprintUri`
const blueprintDir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-empty-blueprint-' ) );
blueprintUri = path.join( blueprintDir, 'blueprint.json' );
}
const existingSteps = Array.isArray( blueprint.steps ) ? blueprint.steps : [];
blueprint = { ...blueprint, steps: [ ...setupSteps, ...existingSteps ] };
if (
siteLanguage &&
siteLanguage !== DEFAULT_LOCALE &&
options.wpVersion === DEFAULT_WORDPRESS_VERSION
) {
await copyLanguagePackToSite( sitePath, siteLanguage );
}

const siteDetails: SiteData = {
Expand Down Expand Up @@ -394,6 +332,7 @@ export async function runCommand(
wpVersion: options.wpVersion,
blueprint,
blueprintUri,
siteLanguage,
} );
logger.reportSuccess( __( 'WordPress server started' ) );

Expand Down Expand Up @@ -437,6 +376,7 @@ export async function runCommand(
wpVersion: options.wpVersion,
blueprint,
blueprintUri,
siteLanguage,
} );
logger.reportSuccess( __( 'Blueprint applied successfully' ) );

Expand Down
116 changes: 21 additions & 95 deletions apps/cli/commands/site/tests/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { isOnline } from '@studio/common/lib/network-utils';
import { portFinder } from '@studio/common/lib/port-finder';
import { normalizeLineEndings } from '@studio/common/lib/remove-default-db-constants';
import { getServerFilesPath } from '@studio/common/lib/well-known-paths';
import { Blueprint, BlueprintV1Declaration, StepDefinition } from '@wp-playground/blueprints';
import { Blueprint, BlueprintV1Declaration } from '@wp-playground/blueprints';
import { vi, type MockInstance } from 'vitest';
import {
lockCliConfig,
Expand Down Expand Up @@ -302,20 +302,8 @@ describe( 'CLI: studio site create', () => {
] ),
} )
);
expect( startWordPressServer ).toHaveBeenCalledWith(
expect.anything(),
expect.any( Logger ),
expect.objectContaining( {
blueprint: expect.objectContaining( {
steps: expect.arrayContaining( [
expect.objectContaining( {
step: 'setSiteOptions',
options: { blogname: 'My Custom Site' },
} ),
] ),
} ),
} )
);
// blogname is now set by playground-server-child via buildSetupSteps, not create.ts
expect( startWordPressServer ).toHaveBeenCalled();
} );

it( 'should NOT override blogname when adding existing WordPress directory with wp-config.php and name', async () => {
Expand Down Expand Up @@ -365,21 +353,8 @@ describe( 'CLI: studio site create', () => {
name: 'My Custom Site',
} );

// Verify setSiteOptions step IS in the blueprint steps (because wp-config.php doesn't exist)
expect( startWordPressServer ).toHaveBeenCalledWith(
expect.anything(),
expect.any( Logger ),
expect.objectContaining( {
blueprint: expect.objectContaining( {
steps: expect.arrayContaining( [
expect.objectContaining( {
step: 'setSiteOptions',
options: { blogname: 'My Custom Site' },
} ),
] ),
} ),
} )
);
// blogname is now set by playground-server-child via buildSetupSteps, not create.ts
expect( startWordPressServer ).toHaveBeenCalled();
} );

it( 'should use folder name as site name if no name provided', async () => {
Expand Down Expand Up @@ -523,7 +498,7 @@ describe( 'CLI: studio site create', () => {
);
} );

it( 'should prepend setSiteOptions step when name is provided with Blueprint', async () => {
it( 'should pass Blueprint through when name is provided with Blueprint', async () => {
await runCommand( mockSitePath, {
...defaultTestOptions,
name: 'My Site',
Expand All @@ -533,18 +508,12 @@ describe( 'CLI: studio site create', () => {
},
} );

// blogname is now set by playground-server-child via buildSetupSteps, not prepended here
expect( startWordPressServer ).toHaveBeenCalledWith(
expect.anything(),
expect.any( Logger ),
expect.objectContaining( {
blueprint: expect.objectContaining( {
steps: expect.arrayContaining( [
expect.objectContaining( {
step: 'setSiteOptions',
options: { blogname: 'My Site' },
} ),
] ),
} ),
blueprint: expect.any( Object ),
} )
);
} );
Expand Down Expand Up @@ -661,60 +630,37 @@ describe( 'CLI: studio site create', () => {
expect( disconnectFromDaemon ).toHaveBeenCalled();
} );

it( 'should run Blueprint when preferred language is configured but no Blueprint was given', async () => {
it( 'should create site with siteLanguage when preferred language is configured but no Blueprint given', async () => {
vi.mocked( getPreferredSiteLanguage ).mockResolvedValue( 'es_ES' );

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

expect( connectToDaemon ).toHaveBeenCalled();
expect( runBlueprint ).toHaveBeenCalledWith(
expect.any( Object ),
expect.any( Object ),
expect.objectContaining( {
blueprint: expect.any( Object ),
blueprintUri: expect.any( String ),
} )
);
// No blueprint to run — language steps are applied by playground-server-child on first start
expect( connectToDaemon ).not.toHaveBeenCalled();
expect( runBlueprint ).not.toHaveBeenCalled();
expect( startWordPressServer ).not.toHaveBeenCalled();
expect( consoleLogSpy ).toHaveBeenCalledWith( 'Site created successfully' );
expect( disconnectFromDaemon ).toHaveBeenCalled();
} );
} );

describe( 'Language Packs', () => {
it( 'should use bundled language packs and skip setSiteLanguage for latest WP version', async () => {
it( 'should use bundled language packs and pass siteLanguage for latest WP version', async () => {
vi.mocked( getPreferredSiteLanguage ).mockResolvedValue( 'sv_SE' );
vi.mocked( copyLanguagePackToSite ).mockResolvedValue( true );

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

expect( copyLanguagePackToSite ).toHaveBeenCalledWith( mockSitePath, 'sv_SE' );
// Language steps (defineWpConfigConsts / setSiteLanguage) are now built by
// playground-server-child's buildSetupSteps, not by create.ts
expect( startWordPressServer ).toHaveBeenCalledWith(
expect.anything(),
expect.any( Logger ),
expect.objectContaining( {
blueprint: expect.objectContaining( {
steps: expect.arrayContaining( [
expect.objectContaining( {
step: 'defineWpConfigConsts',
consts: { WPLANG: 'sv_SE' },
} ),
expect.objectContaining( {
step: 'setSiteOptions',
options: { WPLANG: 'sv_SE' },
} ),
] ),
} ),
} )
expect.objectContaining( { siteLanguage: 'sv_SE' } )
);
// Should NOT include setSiteLanguage step
const calls = vi.mocked( startWordPressServer ).mock.calls;
const blueprintSteps = ( calls[ 0 ][ 2 ] as { blueprint?: BlueprintV1Declaration } )
?.blueprint?.steps as StepDefinition[];
expect( blueprintSteps.some( ( s ) => s.step === 'setSiteLanguage' ) ).toBe( false );
} );

it( 'should fall back to setSiteLanguage when bundled packs are not available', async () => {
Expand All @@ -723,27 +669,15 @@ describe( 'CLI: studio site create', () => {

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

// setSiteLanguage vs defineWpConfigConsts is now decided by playground-server-child
expect( startWordPressServer ).toHaveBeenCalledWith(
expect.anything(),
expect.any( Logger ),
expect.objectContaining( {
blueprint: expect.objectContaining( {
steps: expect.arrayContaining( [
expect.objectContaining( {
step: 'setSiteLanguage',
language: 'sv_SE',
} ),
expect.objectContaining( {
step: 'setSiteOptions',
options: { WPLANG: 'sv_SE' },
} ),
] ),
} ),
} )
expect.objectContaining( { siteLanguage: 'sv_SE' } )
);
} );

it( 'should use setSiteLanguage for non-latest WP versions', async () => {
it( 'should pass siteLanguage for non-latest WP versions', async () => {
vi.mocked( getPreferredSiteLanguage ).mockResolvedValue( 'sv_SE' );

await runCommand( mockSitePath, {
Expand All @@ -752,19 +686,11 @@ describe( 'CLI: studio site create', () => {
} );

expect( copyLanguagePackToSite ).not.toHaveBeenCalled();
// setSiteLanguage step is now built by playground-server-child, not create.ts
expect( startWordPressServer ).toHaveBeenCalledWith(
expect.anything(),
expect.any( Logger ),
expect.objectContaining( {
blueprint: expect.objectContaining( {
steps: expect.arrayContaining( [
expect.objectContaining( {
step: 'setSiteLanguage',
language: 'sv_SE',
} ),
] ),
} ),
} )
expect.objectContaining( { siteLanguage: 'sv_SE' } )
);
} );

Expand Down
2 changes: 1 addition & 1 deletion apps/cli/lib/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ const daemonStartProcessSuccessResponseSchema = z.object( {
export async function startProcess(
processName: string,
scriptPath: string,
env: Record< string, string > = {},
env: NodeJS.ProcessEnv = process.env,
args: string[] = []
): Promise< ProcessDescription > {
const response = await sendDaemonRequest( {
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/lib/dependency-management/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,7 @@ export function getAiInstructionsPath(): string {
export function getPhpMyAdminPath(): string {
return path.join( getWpFilesPath(), 'phpmyadmin' );
}

export function getBlueprintsPharPath(): string {
return path.join( getWpFilesPath(), 'blueprints', 'blueprints.phar' );
}
2 changes: 1 addition & 1 deletion apps/cli/lib/types/process-manager-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const daemonRequestStartProcessSchema = z.object( {
type: z.literal( 'start-process' ),
processName: z.string(),
scriptPath: z.string(),
env: z.record( z.string(), z.string() ).optional(),
env: z.record( z.string(), z.union( [ z.string(), z.undefined() ] ) ).optional(),
args: z.array( z.string() ).optional(),
} );

Expand Down
5 changes: 4 additions & 1 deletion apps/cli/lib/types/wordpress-server-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ const childMessageActivity = z.object( {
const childMessageResult = z.object( {
originalMessageId: z.string(),
topic: z.literal( 'result' ),
result: z.unknown(),
// `result` is `optional` so handlers that return `void` (e.g. `start-server`) survive
// IPC serialization — Node's default JSON IPC drops `undefined` values, leaving the key
// absent on the receiving side. Zod 4's `z.unknown()` rejects an absent key.
result: z.unknown().optional(),
} );

const childMessageError = z.object( {
Expand Down
Loading
Loading