diff --git a/packages/playground/blueprints/public/blueprint-schema.json b/packages/playground/blueprints/public/blueprint-schema.json index 55a13b45aa..4c6a1405c3 100644 --- a/packages/playground/blueprints/public/blueprint-schema.json +++ b/packages/playground/blueprints/public/blueprint-schema.json @@ -1101,6 +1101,48 @@ } }, "required": ["data", "path", "step"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "progress": { + "type": "object", + "properties": { + "weight": { + "type": "number" + }, + "caption": { + "type": "string" + } + }, + "additionalProperties": false + }, + "step": { + "type": "string", + "const": "wp-cli", + "description": "The step identifier." + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "The WP CLI command to run." + }, + "wpCliPath": { + "type": "string", + "description": "wp-cli.phar path" + } + }, + "required": ["command", "step"] } ] }, diff --git a/packages/playground/blueprints/src/lib/compile.ts b/packages/playground/blueprints/src/lib/compile.ts index 8d825c843a..0e3e614463 100644 --- a/packages/playground/blueprints/src/lib/compile.ts +++ b/packages/playground/blueprints/src/lib/compile.ts @@ -12,9 +12,16 @@ import { import type { SupportedPHPExtensionBundle } from '@php-wasm/universal'; import { FileReference, isFileReference, Resource } from './resources'; import { Step, StepDefinition } from './steps'; -import * as stepHandlers from './steps/handlers'; +import * as allStepHandlers from './steps/handlers'; import { Blueprint } from './blueprint'; +// @TODO: Configure this in the `wp-cli` step, not here. +const { wpCLI, ...otherStepHandlers } = allStepHandlers; +const keyedStepHandlers = { + ...otherStepHandlers, + 'wp-cli': wpCLI, +}; + import Ajv from 'ajv'; /** * The JSON schema stored in this directory is used to validate the Blueprints @@ -126,6 +133,48 @@ export function compileBlueprint( : blueprint.login), }); } + + /** + * Download WP-CLI. {{{ + * Hardcoding this in the compilt() function is a temporary solution + * to provide the wpCLI step with the wp-cli.phar file it needs. Eventually, + * each Blueprint step may be able to specify any pre-requisite resources. + * Also, wp-cli should only be downloaded if it's not already present. + */ + const wpCliStepIndex = blueprint.steps?.findIndex( + (step) => typeof step === 'object' && step?.step === 'wp-cli' + ); + if (wpCliStepIndex !== undefined && wpCliStepIndex > -1) { + if (!blueprint.phpExtensionBundles) { + blueprint.phpExtensionBundles = []; + } + if (!blueprint.phpExtensionBundles.includes('kitchen-sink')) { + blueprint.phpExtensionBundles.push('kitchen-sink'); + console.warn( + `The WP-CLI step used in your Blueprint requires the iconv and mbstring PHP extensions. ` + + `However, you did not specify the kitchen-sink extension bundle. Playground will override your ` + + `choice and load the kitchen-sink PHP extensions bundle to prevent the WP-CLI step from failing. ` + ); + } + blueprint.steps?.splice(wpCliStepIndex, 0, { + step: 'writeFile', + data: { + resource: 'url', + /** + * Use compression for downloading the wp-cli.phar file. + * The official release, hosted at raw.githubusercontent.com, is ~7MB and the + * transfer is uncompressed. playground.wordpress.net supports transfer compression + * and only transmits ~1.4MB. + * + * @TODO: minify the wp-cli.phar file. It can be as small as 1MB when all the + * whitespaces and are removed, and even 500KB when libraries like the + * JavaScript parser or Composer are removed. + */ + url: 'https://playground.wordpress.net/wp-cli.phar', + }, + path: '/tmp/wp-cli.phar', + }); + } // }}} const { valid, errors } = validateBlueprint(blueprint); @@ -187,11 +236,11 @@ export function compileBlueprint( const result = await run(playground); onStepCompleted(result, step); } catch (e) { + console.error(e); throw new Error( `Error when executing the blueprint step #${i} (${JSON.stringify( step - )}). ` + - `Inspect the cause of this error for more details`, + )}). Inspect the "cause" property of this error for more details`, { cause: e, } @@ -358,7 +407,7 @@ function compileStep( const run = async (playground: UniversalPHP) => { try { stepProgress.fillSlowly(); - return await stepHandlers[step.step]( + return await keyedStepHandlers[step.step]( playground, await resolveArguments(args), { diff --git a/packages/playground/blueprints/src/lib/steps/handlers.ts b/packages/playground/blueprints/src/lib/steps/handlers.ts index 6ff2b6907e..b1a21ade3f 100644 --- a/packages/playground/blueprints/src/lib/steps/handlers.ts +++ b/packages/playground/blueprints/src/lib/steps/handlers.ts @@ -25,3 +25,4 @@ export { runWpInstallationWizard } from './run-wp-installation-wizard'; export { setSiteOptions, updateUserMeta } from './site-data'; export { defineWpConfigConsts } from './define-wp-config-consts'; export { zipWpContent } from './zip-wp-content'; +export { wpCLI } from './wp-cli'; diff --git a/packages/playground/blueprints/src/lib/steps/index.ts b/packages/playground/blueprints/src/lib/steps/index.ts index 3346e3851b..beec1ad16d 100644 --- a/packages/playground/blueprints/src/lib/steps/index.ts +++ b/packages/playground/blueprints/src/lib/steps/index.ts @@ -28,6 +28,7 @@ import { UnzipStep } from './unzip'; import { ImportWordPressFilesStep } from './import-wordpress-files'; import { ImportFileStep } from './import-file'; import { EnableMultisiteStep } from './enable-multisite'; +import { WPCLIStep } from './wp-cli'; export type Step = GenericStep; export type StepDefinition = Step & { @@ -68,7 +69,8 @@ export type GenericStep = | SetSiteOptionsStep | UnzipStep | UpdateUserMetaStep - | WriteFileStep; + | WriteFileStep + | WPCLIStep; export type { ActivatePluginStep, @@ -99,6 +101,7 @@ export type { UnzipStep, UpdateUserMetaStep, WriteFileStep, + WPCLIStep, }; /** diff --git a/packages/playground/blueprints/src/lib/steps/wp-cli.spec.ts b/packages/playground/blueprints/src/lib/steps/wp-cli.spec.ts new file mode 100644 index 0000000000..6e2e439cd1 --- /dev/null +++ b/packages/playground/blueprints/src/lib/steps/wp-cli.spec.ts @@ -0,0 +1,60 @@ +import { NodePHP } from '@php-wasm/node'; +import { splitShellCommand, wpCLI } from './wp-cli'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { unzip } from './unzip'; +import { getWordPressModule } from '@wp-playground/wordpress'; + +const phpVersion = '8.0'; +describe('Blueprint step wpCLI', () => { + let php: NodePHP; + + beforeEach(async () => { + php = await NodePHP.load(phpVersion, { + requestHandler: { + documentRoot: '/wordpress', + }, + }); + php.setSapiName('cli'); + await unzip(php, { + zipFile: await getWordPressModule(), + extractToPath: '/wordpress', + }); + const wpCliPath = join(__dirname, '../../test/wp-cli.phar'); + php.writeFile('/tmp/wp-cli.phar', readFileSync(wpCliPath)); + }); + + it('should run wp-cli commands', async () => { + const result = await wpCLI(php, { + command: + "wp post create --post_title='Test post' --post_excerpt='Some content' --no-color", + }); + expect(result.text).toMatch(/Success: Created post/); + }); +}); + +describe('splitShellCommand', () => { + it('Should split a shell command into an array', () => { + const command = + 'wp post create --post_title="Test post" --post_excerpt="Some content"'; + const result = splitShellCommand(command); + expect(result).toEqual([ + 'wp', + 'post', + 'create', + '--post_title=Test post', + '--post_excerpt=Some content', + ]); + }); + + it('Should treat multiple spaces as a single space', () => { + const command = 'ls --wordpress --playground --is-great'; + const result = splitShellCommand(command); + expect(result).toEqual([ + 'ls', + '--wordpress', + '--playground', + '--is-great', + ]); + }); +}); diff --git a/packages/playground/blueprints/src/lib/steps/wp-cli.ts b/packages/playground/blueprints/src/lib/steps/wp-cli.ts new file mode 100644 index 0000000000..f56e3ffac8 --- /dev/null +++ b/packages/playground/blueprints/src/lib/steps/wp-cli.ts @@ -0,0 +1,138 @@ +import { PHPResponse } from '@php-wasm/universal'; +import { StepHandler } from '.'; +import { phpVar } from '@php-wasm/util'; + +/** + * @inheritDoc wpCLI + * @hasRunnableExample + * @example + * + * + * { + * "step": "wpCLI", + * "command": "wp post create --post_title='Test post' --post_excerpt='Some content'" + * } + * + */ +export interface WPCLIStep { + /** The step identifier. */ + step: 'wp-cli'; + /** The WP CLI command to run. */ + command: string | string[]; + /** wp-cli.phar path */ + wpCliPath?: string; +} + +/** + * Runs PHP code. + */ +export const wpCLI: StepHandler> = async ( + playground, + { command, wpCliPath = '/tmp/wp-cli.phar' } +) => { + if (!(await playground.fileExists(wpCliPath))) { + throw new Error(`wp-cli.phar not found at ${wpCliPath}`); + } + + let args: string[]; + if (typeof command === 'string') { + command = command.trim(); + args = splitShellCommand(command); + } else { + args = command; + } + + const cmd = args.shift(); + if (cmd !== 'wp') { + throw new Error(`The first argument must be "wp".`); + } + + await playground.writeFile('/tmp/stdout', ''); + await playground.writeFile('/tmp/stderr', ''); + await playground.writeFile( + '/wordpress/run-cli.php', + ` { + processApi.stdout(data); + }); + processApi.flushStdin(); + processApi.exit(0); + } else { + processApi.exit(1); + } }) ); diff --git a/packages/playground/website/.htaccess b/packages/playground/website/.htaccess index b8fad2ad62..48d804b221 100644 --- a/packages/playground/website/.htaccess +++ b/packages/playground/website/.htaccess @@ -1,8 +1,11 @@ +Options +Multiviews +AddEncoding x-gzip .gz + Header unset ETag Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate" - + Header set Access-Control-Allow-Origin "*" Header unset ETag Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate" diff --git a/packages/playground/website/cypress/e2e/blueprints.cy.ts b/packages/playground/website/cypress/e2e/blueprints.cy.ts index 510d6f5bec..f42828f188 100644 --- a/packages/playground/website/cypress/e2e/blueprints.cy.ts +++ b/packages/playground/website/cypress/e2e/blueprints.cy.ts @@ -53,4 +53,23 @@ describe('Blueprints', () => { .find('[data-slug="wordpress-importer-git-loader"].active') .should('exist'); }); + + it('wp-cli step should create a post', () => { + const blueprint: Blueprint = { + landingPage: '/wp-admin/post.php', + login: true, + steps: [ + { + step: 'wp-cli', + command: + "wp post create --post_title='Test post' --post_excerpt='Some content' --no-color", + }, + ], + }; + cy.visit('/#' + JSON.stringify(blueprint)); + cy.wordPressDocument() + .its('body') + .find('[aria-label="“Test post” (Edit)"]') + .should('exist'); + }); }); diff --git a/packages/playground/website/project.json b/packages/playground/website/project.json index be0e0d4f83..0179bf5a54 100644 --- a/packages/playground/website/project.json +++ b/packages/playground/website/project.json @@ -19,7 +19,8 @@ "cp -r ./client ./wasm-wordpress-net/", "cp -r ./remote/* ./wasm-wordpress-net/", "cp -r ./website/* ./wasm-wordpress-net/", - "cat ./remote/.htaccess ./website/.htaccess > ./wasm-wordpress-net/.htaccess" + "cat ./remote/.htaccess ./website/.htaccess > ./wasm-wordpress-net/.htaccess", + "curl https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar | gzip -c -9 > wasm-wordpress-net/wp-cli.phar.gz" ], "cwd": "dist/packages/playground", "parallel": false