Skip to content

Commit

Permalink
Blueprints: wp-cli step (#1017)
Browse files Browse the repository at this point in the history
Adds a new step to the blueprints that allows to run WP-CLI commands.

Example:

```ts
{
	"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"
		}
	]
}
```

The command may also be an array of strings to ease dealing with quotes:

```ts
{
    "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"]
        }
    ]
}
```

 ## Trade-offs

* WP-CLI requires ~7MB of additional downloads: 1.4MB for the compressed
WP-CLI.phar, and 5.6MB for the kitchen-sink PHP extensions bundle.

 ## Implementation

* Whenever the Blueprints compiler finds a `wp-cli` step, it:
   * Downloads the wp-cli.phar file
* Forces the `kitchen-sink` PHP extensions bundle as WP-CLI requires
mbstring and iconv
* `wp-cli.phar` is shipped from playground.wordpress.net. Downloading
the official release from raw.githubusercontent.com transfers 7MB, while
downloading the pre-compressed version from playground.wordpress.net
only transfers 1.4MB.
* playground.wordpress.net now always sets the `PHP_SAPI` constant to
`cli` to ensure this check in WP-CLI passes:
https://github.com/wp-cli/wp-cli-bundle/blob/970d0d4c22d4a89ca890def26ec82e559625abc6/php/boot-phar.php#L3-L10

 ## Future work

* Minify wp-cli.phar to reduce its size even further. We can remove
redundant whitespaces and comments, remove the JavaScript parser library
that is unlikely to be useful in Playground, remove parts of the
Composer library, and perhaps more. cc @schlessera @swissspidy
@danielbachhuber
* Move the hardcoded WP-CLI-related parts of the Blueprint `compile()`
function into a separate, modular function associated with the `wp-cli`
step.
  • Loading branch information
adamziel authored Feb 9, 2024
1 parent 9540923 commit 1d25d6f
Show file tree
Hide file tree
Showing 11 changed files with 345 additions and 10 deletions.
42 changes: 42 additions & 0 deletions packages/playground/blueprints/public/blueprint-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
},
Expand Down
57 changes: 53 additions & 4 deletions packages/playground/blueprints/src/lib/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -358,7 +407,7 @@ function compileStep<S extends StepDefinition>(
const run = async (playground: UniversalPHP) => {
try {
stepProgress.fillSlowly();
return await stepHandlers[step.step](
return await keyedStepHandlers[step.step](
playground,
await resolveArguments(args),
{
Expand Down
1 change: 1 addition & 0 deletions packages/playground/blueprints/src/lib/steps/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
5 changes: 4 additions & 1 deletion packages/playground/blueprints/src/lib/steps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileReference>;
export type StepDefinition = Step & {
Expand Down Expand Up @@ -68,7 +69,8 @@ export type GenericStep<Resource> =
| SetSiteOptionsStep
| UnzipStep<Resource>
| UpdateUserMetaStep
| WriteFileStep<Resource>;
| WriteFileStep<Resource>
| WPCLIStep;

export type {
ActivatePluginStep,
Expand Down Expand Up @@ -99,6 +101,7 @@ export type {
UnzipStep,
UpdateUserMetaStep,
WriteFileStep,
WPCLIStep,
};

/**
Expand Down
60 changes: 60 additions & 0 deletions packages/playground/blueprints/src/lib/steps/wp-cli.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
]);
});
});
138 changes: 138 additions & 0 deletions packages/playground/blueprints/src/lib/steps/wp-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { PHPResponse } from '@php-wasm/universal';
import { StepHandler } from '.';
import { phpVar } from '@php-wasm/util';

/**
* @inheritDoc wpCLI
* @hasRunnableExample
* @example
*
* <code>
* {
* "step": "wpCLI",
* "command": "wp post create --post_title='Test post' --post_excerpt='Some content'"
* }
* </code>
*/
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<WPCLIStep, Promise<PHPResponse>> = 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',
`<?php
// Set up the environment to emulate a shell script
// call.
// Set SHELL_PIPE to 0 to ensure WP-CLI formats
// the output as ASCII tables.
// @see https://github.com/wp-cli/wp-cli/issues/1102
putenv( 'SHELL_PIPE=0' );
// Set the argv global.
$GLOBALS['argv'] = array_merge([
"/tmp/wp-cli.phar",
"--path=/wordpress"
], ${phpVar(args)});
// Provide stdin, stdout, stderr streams outside of
// the CLI SAPI.
define('STDIN', fopen('php://stdin', 'rb'));
define('STDOUT', fopen('php://stdout', 'wb'));
define('STDERR', fopen('/tmp/stderr', 'wb'));
require( ${phpVar(wpCliPath)} );
`
);

const result = await playground.run({
scriptPath: '/wordpress/run-cli.php',
});

if (result.errors) {
throw new Error(result.errors);
}

return result;
};

/**
* Naive shell command parser.
* Ensures that commands like `wp option set blogname "My blog name"` are split into
* `['wp', 'option', 'set', 'blogname', 'My blog name']` instead of
* `['wp', 'option', 'set', 'blogname', 'My', 'blog', 'name']`.
*
* @param command
* @returns
*/
export function splitShellCommand(command: string) {
const MODE_NORMAL = 0;
const MODE_IN_QUOTE = 1;

let mode = MODE_NORMAL;
let quote = '';

const parts: string[] = [];
let currentPart = '';
for (let i = 0; i < command.length; i++) {
const char = command[i];
if (mode === MODE_NORMAL) {
if (char === '"' || char === "'") {
mode = MODE_IN_QUOTE;
quote = char;
} else if (char.match(/\s/)) {
if (currentPart) {
parts.push(currentPart);
}
currentPart = '';
} else {
currentPart += char;
}
} else if (mode === MODE_IN_QUOTE) {
if (char === '\\') {
i++;
currentPart += command[i];
} else if (char === quote) {
mode = MODE_NORMAL;
quote = '';
} else {
currentPart += char;
}
}
}
if (currentPart) {
parts.push(currentPart);
}
return parts;
}
Binary file not shown.
Loading

0 comments on commit 1d25d6f

Please sign in to comment.