diff --git a/packages/wp-now/src/constants.ts b/packages/wp-now/src/constants.ts index 5f1db0e0ed..36de49544a 100644 --- a/packages/wp-now/src/constants.ts +++ b/packages/wp-now/src/constants.ts @@ -28,3 +28,9 @@ export const DEFAULT_PHP_VERSION = '8.0'; * The default WordPress version to use when running the WP Now server. */ export const DEFAULT_WORDPRESS_VERSION = 'latest'; + +/** + * The URL for downloading the "wp-cli" WordPress cli. + */ +export const WP_CLI_URL = + 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'; diff --git a/packages/wp-now/src/download.ts b/packages/wp-now/src/download.ts index 07d8b870a2..d307b0319f 100644 --- a/packages/wp-now/src/download.ts +++ b/packages/wp-now/src/download.ts @@ -4,12 +4,13 @@ import followRedirects from 'follow-redirects'; import unzipper from 'unzipper'; import os from 'os'; import { IncomingMessage } from 'http'; -import { DEFAULT_WORDPRESS_VERSION, SQLITE_URL } from './constants'; +import { DEFAULT_WORDPRESS_VERSION, SQLITE_URL, WP_CLI_URL } from './constants'; import { isValidWordPressVersion } from './wp-playground-wordpress'; import { output } from './output'; import getWpNowPath from './get-wp-now-path'; import getWordpressVersionsPath from './get-wordpress-versions-path'; import getSqlitePath from './get-sqlite-path'; +import getWpCliPath from './get-wp-cli-path'; function getWordPressVersionUrl(version = DEFAULT_WORDPRESS_VERSION) { if (!isValidWordPressVersion(version)) { @@ -28,6 +29,55 @@ interface DownloadFileAndUnzipResult { followRedirects.maxRedirects = 5; const { https } = followRedirects; +async function downloadFile({ + url, + destinationFilePath, + itemName, +}): Promise { + let statusCode = 0; + try { + if (fs.existsSync(destinationFilePath)) { + return { downloaded: false, statusCode: 0 }; + } + fs.ensureDirSync(path.dirname(destinationFilePath)); + const response = await new Promise((resolve) => + https.get(url, (response) => resolve(response)) + ); + statusCode = response.statusCode; + if (response.statusCode !== 200) { + throw new Error( + `Failed to download file (Status code ${response.statusCode}).` + ); + } + await new Promise((resolve, reject) => { + fs.ensureFileSync(destinationFilePath); + const file = fs.createWriteStream(destinationFilePath); + response.pipe(file); + file.on('finish', () => { + file.close(); + resolve(); + }); + file.on('error', (error) => { + file.close(); + reject(error); + }); + }); + output?.log(`Downloaded ${itemName} to ${destinationFilePath}`); + return { downloaded: true, statusCode }; + } catch (error) { + output?.error(`Error downloading file ${itemName}`, error); + return { downloaded: false, statusCode }; + } +} + +export async function downloadWPCLI() { + return downloadFile({ + url: WP_CLI_URL, + destinationFilePath: getWpCliPath(), + itemName: 'wp-cli', + }); +} + async function downloadFileAndUnzip({ url, destinationFolder, diff --git a/packages/wp-now/src/execute-wp-cli.ts b/packages/wp-now/src/execute-wp-cli.ts new file mode 100644 index 0000000000..5d313b439e --- /dev/null +++ b/packages/wp-now/src/execute-wp-cli.ts @@ -0,0 +1,41 @@ +import startWPNow from './wp-now'; +import { downloadWPCLI } from './download'; +import { disableOutput } from './output'; +import getWpCliPath from './get-wp-cli-path'; +import getWpNowConfig from './config'; +import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION } from './constants'; + +/** + * This is an unstable API. Multiple wp-cli commands may not work due to a current limitation on php-wasm and pthreads. + * @param args The arguments to pass to wp-cli. + */ +export async function executeWPCli(args: string[]) { + await downloadWPCLI(); + disableOutput(); + const options = await getWpNowConfig({ + php: DEFAULT_PHP_VERSION, + wp: DEFAULT_WORDPRESS_VERSION, + path: process.env.WP_NOW_PROJECT_PATH || process.cwd(), + }); + const { phpInstances, options: wpNowOptions } = await startWPNow({ + ...options, + numberOfPhpInstances: 2, + }); + const [, php] = phpInstances; + + try { + php.useHostFilesystem(); + await php.cli([ + 'php', + getWpCliPath(), + `--path=${wpNowOptions.documentRoot}`, + ...args, + ]); + } catch (resultOrError) { + const success = + resultOrError.name === 'ExitStatus' && resultOrError.status === 0; + if (!success) { + throw resultOrError; + } + } +} diff --git a/packages/wp-now/src/get-wp-cli-path.ts b/packages/wp-now/src/get-wp-cli-path.ts new file mode 100644 index 0000000000..c5b047283a --- /dev/null +++ b/packages/wp-now/src/get-wp-cli-path.ts @@ -0,0 +1,13 @@ +import path from 'path'; +import getWpNowPath from './get-wp-now-path'; +import getWpCliTmpPath from './get-wp-cli-tmp-path'; + +/** + * The path for wp-cli phar file within the WP Now folder. + */ +export default function getWpCliPath() { + if (process.env.NODE_ENV !== 'test') { + return path.join(getWpNowPath(), 'wp-cli.phar'); + } + return getWpCliTmpPath(); +} diff --git a/packages/wp-now/src/get-wp-cli-tmp-path.ts b/packages/wp-now/src/get-wp-cli-tmp-path.ts new file mode 100644 index 0000000000..594ee810ab --- /dev/null +++ b/packages/wp-now/src/get-wp-cli-tmp-path.ts @@ -0,0 +1,11 @@ +import path from 'path'; +import os from 'os'; + +/** + * The full path to the hidden WP-CLI folder in the user's tmp directory. + */ +export default function getWpCliTmpPath() { + const tmpDirectory = os.tmpdir(); + + return path.join(tmpDirectory, `wp-now-tests-wp-cli-hidden-folder`); +} diff --git a/packages/wp-now/src/tests/wp-now.spec.ts b/packages/wp-now/src/tests/wp-now.spec.ts index a1ac6a994d..87f1dc8d99 100644 --- a/packages/wp-now/src/tests/wp-now.spec.ts +++ b/packages/wp-now/src/tests/wp-now.spec.ts @@ -11,11 +11,14 @@ import { } from '../wp-playground-wordpress'; import { downloadSqliteIntegrationPlugin, + downloadWPCLI, downloadWordPress, } from '../download'; import os from 'os'; import crypto from 'crypto'; import getWpNowTmpPath from '../get-wp-now-tmp-path'; +import getWpCliTmpPath from '../get-wp-cli-tmp-path'; +import { executeWPCli } from '../execute-wp-cli'; const exampleDir = __dirname + '/mode-examples'; @@ -510,3 +513,41 @@ describe('Test starting different modes', () => { expect(themeName.text).toContain('Twenty Twenty-Three'); }); }); + +/** + * Test wp-cli command. + */ +describe('wp-cli command', () => { + let consoleSpy; + let output = ''; + + beforeEach(() => { + function onStdout(outputLine: string) { + output += outputLine; + } + consoleSpy = vi.spyOn(console, 'log'); + consoleSpy.mockImplementation(onStdout); + }); + + afterEach(() => { + output = ''; + consoleSpy.mockRestore(); + }); + + beforeAll(async () => { + await downloadWithTimer('wp-cli', downloadWPCLI); + }); + + afterAll(() => { + fs.removeSync(getWpCliTmpPath()); + }); + + /** + * Test wp-cli displays the version. + * We don't need the WordPress context for this test. + */ + test('wp-cli displays the version', async () => { + await executeWPCli(['cli', 'version']); + expect(output).toMatch(/WP-CLI (\d\.?)+/i); + }); +}); diff --git a/packages/wp-now/src/wp-now.ts b/packages/wp-now/src/wp-now.ts index 36c5276095..20c2cac01d 100644 --- a/packages/wp-now/src/wp-now.ts +++ b/packages/wp-now/src/wp-now.ts @@ -1,5 +1,5 @@ import fs from 'fs-extra'; -import { NodePHP } from '@php-wasm/node'; +import { NodePHP, PHPLoaderOptions } from '@php-wasm/node'; import path from 'path'; import { SQLITE_FILENAME } from './constants'; import { @@ -42,7 +42,7 @@ export default async function startWPNow( options: Partial = {} ): Promise<{ php: NodePHP; phpInstances: NodePHP[]; options: WPNowOptions }> { const { documentRoot } = options; - const nodePHPOptions = { + const nodePHPOptions: PHPLoaderOptions = { requestHandler: { documentRoot, absoluteUrl: options.absoluteUrl, diff --git a/packages/wp-now/tsconfig.spec.json b/packages/wp-now/tsconfig.spec.json index b83f32e513..e2b94cae34 100644 --- a/packages/wp-now/tsconfig.spec.json +++ b/packages/wp-now/tsconfig.spec.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "../../dist/out-tsc", - "types": ["jest", "node"] + "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"] }, "include": [ "jest.config.ts",