Skip to content

Commit cb670ec

Browse files
authored
[CLI] Enable users to automatically mount their current working directory into Playground (#39)
## Motivation for the change, related issues This PR adds support for automatically mounting project folders using the Playground CLI. If the user specifies the `--autoMount` argument, Playground CLI will detect the project type and mount it into Playground. The project folder can be a plugin, a theme, a WP-content folder, a WordPress folder, a PHP folder, or a static HTML folder. By automatically mounting, users don't need to write long mount commands and the CLI ensures the mounted project folder won't be modified by Playground. ## Implementation details If the `--autoMount` argument is provided, the CLI will detect the current mode and mount the current working directory. Based on the mount type, the CLI adds necessary mounts and Blueprint steps to `runCli` arguments. User-provided arguments, won't be removed, the CLI only appends arguments. ## Testing Instructions (or ideally a Blueprint) - CI
1 parent 5a2e706 commit cb670ec

File tree

34 files changed

+615
-78
lines changed

34 files changed

+615
-78
lines changed

packages/playground/blueprints/src/lib/steps/import-wordpress-files.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { StepHandler } from '.';
22
import { unzip } from './unzip';
33
import { dirname, joinPaths, phpVar } from '@php-wasm/util';
44
import type { UniversalPHP } from '@php-wasm/universal';
5-
import { ensureRequiredWpConfigConstants } from '@wp-playground/wordpress';
5+
import { ensureWpConfig } from '@wp-playground/wordpress';
66
import { wpContentFilesExcludedFromExport } from '../utils/wp-content-files-excluded-from-exports';
77
import { defineSiteUrl } from './define-site-url';
88

@@ -111,10 +111,7 @@ export const importWordPressFiles: StepHandler<
111111
await playground.rmdir(importPath);
112112

113113
// Ensure required constants are defined in wp-config.php.
114-
await ensureRequiredWpConfigConstants(
115-
playground,
116-
joinPaths(documentRoot, 'wp-config.php')
117-
);
114+
await ensureWpConfig(playground, documentRoot);
118115

119116
// Adjust the site URL
120117
await defineSiteUrl(playground, {

packages/playground/cli/project.json

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,10 @@
9292
},
9393
"test": {
9494
"executor": "@nx/vite:test",
95-
"outputs": ["{workspaceRoot}/coverage/playground/cli"],
95+
"outputs": ["{workspaceRoot}/coverage/packages/playground/cli"],
9696
"options": {
9797
"passWithNoTests": true,
98-
"reportsDirectory": "../../../coverage/packages/playground/cli",
99-
"typecheck": {
100-
"tsconfig": "{projectRoot}/tsconfig.spec.json"
101-
}
102-
},
103-
"configurations": {
104-
"ci": {
105-
"ci": true,
106-
"codeCoverage": true
107-
}
98+
"reportsDirectory": "../../../coverage/packages/playground/cli"
10899
}
109100
},
110101
"typecheck": {
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { basename, join } from 'path';
2+
import type {
3+
BlueprintDeclaration,
4+
StepDefinition,
5+
} from '@wp-playground/blueprints';
6+
import fs from 'fs';
7+
import type { RunCLIArgs } from './run-cli';
8+
9+
export function expandAutoMounts(args: RunCLIArgs): RunCLIArgs {
10+
const path = process.cwd();
11+
12+
const mount = [...(args.mount || [])];
13+
const mountBeforeInstall = [...(args.mountBeforeInstall || [])];
14+
15+
if (isPluginDirectory(path)) {
16+
const pluginName = basename(path);
17+
mount.push(`${path}:/wordpress/wp-content/plugins/${pluginName}`);
18+
} else if (isThemeDirectory(path)) {
19+
const themeName = basename(path);
20+
mount.push(`${path}:/wordpress/wp-content/themes/${themeName}`);
21+
} else if (containsWpContentDirectories(path)) {
22+
mount.push(...wpContentMounts(path));
23+
} else if (containsFullWordPressInstallation(path)) {
24+
/**
25+
* We don't want Playground and WordPress to modify the OS filesystem on their own
26+
* by creating files like wp-config.php or wp-content/db.php.
27+
* To ensure WordPress can write to the /wordpress/ and /wordpress/wp-content/ directories,
28+
* we leave these directories as MEMFS nodes and mount individual files
29+
* and directories into them instead of mounting the entire directory as a NODEFS node.
30+
*/
31+
const files = fs.readdirSync(path);
32+
const mounts: string[] = [];
33+
for (const file of files) {
34+
if (file.startsWith('wp-content')) {
35+
continue;
36+
}
37+
mounts.push(`${path}/${file}:/wordpress/${file}`);
38+
}
39+
mountBeforeInstall.push(
40+
...mounts,
41+
...wpContentMounts(join(path, 'wp-content'))
42+
);
43+
} else {
44+
/**
45+
* By default, mount the current working directory as the Playground root.
46+
* This allows users to run and PHP or HTML files using the Playground CLI.
47+
*/
48+
mount.push(`${path}:/wordpress`);
49+
}
50+
51+
const blueprint = (args.blueprint as BlueprintDeclaration) || {};
52+
blueprint.steps = [...(blueprint.steps || []), ...getSteps(path)];
53+
54+
/**
55+
* If Playground is mounting a full WordPress directory,
56+
* it doesn't need to setup WordPress.
57+
*/
58+
const skipWordPressSetup =
59+
args.skipWordPressSetup || containsFullWordPressInstallation(path);
60+
61+
return {
62+
...args,
63+
blueprint,
64+
mount,
65+
mountBeforeInstall,
66+
skipWordPressSetup,
67+
} as RunCLIArgs;
68+
}
69+
70+
export function containsFullWordPressInstallation(path: string): boolean {
71+
const files = fs.readdirSync(path);
72+
return (
73+
files.includes('wp-admin') &&
74+
files.includes('wp-includes') &&
75+
files.includes('wp-content')
76+
);
77+
}
78+
79+
export function containsWpContentDirectories(path: string): boolean {
80+
const files = fs.readdirSync(path);
81+
return (
82+
files.includes('themes') ||
83+
files.includes('plugins') ||
84+
files.includes('mu-plugins') ||
85+
files.includes('uploads')
86+
);
87+
}
88+
89+
export function isThemeDirectory(path: string): boolean {
90+
const files = fs.readdirSync(path);
91+
if (!files.includes('style.css')) {
92+
return false;
93+
}
94+
const styleCssContent = fs.readFileSync(join(path, 'style.css'), 'utf8');
95+
const themeNameRegex = /^(?:[ \t]*<\?php)?[ \t/*#@]*Theme Name:(.*)$/im;
96+
return !!themeNameRegex.exec(styleCssContent);
97+
}
98+
99+
export function isPluginDirectory(path: string): boolean {
100+
const files = fs.readdirSync(path);
101+
const pluginNameRegex = /^(?:[ \t]*<\?php)?[ \t/*#@]*Plugin Name:(.*)$/im;
102+
const pluginNameMatch = files
103+
.filter((file) => file.endsWith('.php'))
104+
.find((file) => {
105+
const fileContent = fs.readFileSync(join(path, file), 'utf8');
106+
return !!pluginNameRegex.exec(fileContent);
107+
});
108+
return !!pluginNameMatch;
109+
}
110+
111+
/**
112+
* Returns a list of files and directories in the wp-content directory
113+
* to be mounted individually.
114+
*
115+
* This is needed because WordPress needs to be able to write to the
116+
* wp-content directory without Playground modifying the OS filesystem.
117+
*
118+
* See expandAutoMounts for more details.
119+
*/
120+
export function wpContentMounts(wpContentDir: string): string[] {
121+
const files = fs.readdirSync(wpContentDir);
122+
return (
123+
files
124+
/**
125+
* index.php is added by WordPress automatically and
126+
* can't be mounted from the current working directory
127+
* because it already exists.
128+
*
129+
* Because index.php should be empty, it's safe to not include it.
130+
*/
131+
.filter((file) => !file.startsWith('index.php'))
132+
.map(
133+
(file) =>
134+
`${wpContentDir}/${file}:/wordpress/wp-content/${file}`
135+
)
136+
);
137+
}
138+
139+
export function getSteps(path: string): StepDefinition[] {
140+
if (isPluginDirectory(path)) {
141+
return [
142+
{
143+
step: 'activatePlugin',
144+
pluginPath: `/wordpress/wp-content/plugins/${basename(path)}`,
145+
},
146+
];
147+
} else if (isThemeDirectory(path)) {
148+
return [
149+
{
150+
step: 'activateTheme',
151+
themeFolderName: basename(path),
152+
},
153+
];
154+
} else if (
155+
containsWpContentDirectories(path) ||
156+
containsFullWordPressInstallation(path)
157+
) {
158+
/**
159+
* Playground needs to ensure there is an active theme.
160+
* Otherwise when WordPress loads it will show a white screen.
161+
*/
162+
return [
163+
{
164+
step: 'runPHP',
165+
code: `<?php
166+
require_once '/wordpress/wp-load.php';
167+
$theme = wp_get_theme();
168+
if (!$theme->exists()) {
169+
$themes = wp_get_themes();
170+
if (count($themes) > 0) {
171+
$themeName = array_keys($themes)[0];
172+
switch_theme($themeName);
173+
}
174+
}
175+
`,
176+
},
177+
];
178+
}
179+
return [];
180+
}

packages/playground/cli/src/cli.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ async function run() {
9898
type: 'boolean',
9999
default: false,
100100
})
101+
.option('autoMount', {
102+
describe: `Automatically mount the current working directory. You can mount a WordPress directory, a plugin directory, a theme directory, a wp-content directory, or any directory containing PHP and HTML files.`,
103+
type: 'boolean',
104+
default: false,
105+
})
101106
.option('followSymlinks', {
102107
describe:
103108
'Allow Playground to follow symlinks by automatically mounting symlinked directories and files encountered in mounted directories. \nWarning: Following symlinks will expose files outside mounted directories to Playground and could be a security risk.',
@@ -139,7 +144,7 @@ async function run() {
139144
} as RunCLIArgs;
140145

141146
try {
142-
return await runCLI(cliArgs);
147+
return runCLI(cliArgs);
143148
} catch (e) {
144149
const reportableCause = ReportableError.getReportableCause(e);
145150
if (reportableCause) {

packages/playground/cli/src/run-cli.ts

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ import type {
1212
BlueprintDeclaration,
1313
BlueprintBundle,
1414
} from '@wp-playground/blueprints';
15-
import { compileBlueprint, runBlueprintSteps } from '@wp-playground/blueprints';
15+
import {
16+
compileBlueprint,
17+
runBlueprintSteps,
18+
isBlueprintBundle,
19+
} from '@wp-playground/blueprints';
1620
import { RecommendedPHPVersion, zipDirectory } from '@wp-playground/common';
1721
import {
1822
bootWordPress,
@@ -22,6 +26,7 @@ import fs from 'fs';
2226
import type { Server } from 'http';
2327
import path from 'path';
2428
import { rootCertificates } from 'tls';
29+
import { expandAutoMounts } from './cli-auto-mount';
2530
import {
2631
CACHE_FOLDER,
2732
cachedDownload,
@@ -44,6 +49,7 @@ export interface RunCLIArgs {
4449
skipWordPressSetup?: boolean;
4550
skipSqliteSetup?: boolean;
4651
wp?: string;
52+
autoMount?: boolean;
4753
followSymlinks?: boolean;
4854
}
4955

@@ -53,6 +59,14 @@ export interface RunCLIServer {
5359
}
5460

5561
export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
62+
/**
63+
* Expand auto-mounts to include the necessary mounts and steps
64+
* when running in auto-mount mode.
65+
*/
66+
if (args.autoMount) {
67+
args = expandAutoMounts(args);
68+
}
69+
5670
/**
5771
* TODO: This exact feature will be provided in the PHP Blueprints library.
5872
* Let's use it when it ships. Let's also use it in the web Playground
@@ -112,21 +126,24 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer> {
112126
* Also the Blueprint Builder tool does something similar.
113127
* Perhaps all these cases could be handled by the same function?
114128
*/
115-
let blueprint: BlueprintDeclaration | BlueprintBundle | undefined;
116-
117-
if (args.blueprint) {
118-
blueprint = args.blueprint as
119-
| BlueprintDeclaration
120-
| BlueprintBundle;
121-
} else {
122-
blueprint = {
123-
preferredVersions: {
124-
php: args.php ?? RecommendedPHPVersion,
125-
wp: args.wp ?? 'latest',
126-
},
127-
login: args.login,
128-
};
129-
}
129+
const blueprint: BlueprintDeclaration | BlueprintBundle =
130+
isBlueprintBundle(args.blueprint)
131+
? args.blueprint
132+
: {
133+
login: args.login,
134+
...args.blueprint,
135+
preferredVersions: {
136+
php:
137+
args.php ??
138+
args?.blueprint?.preferredVersions?.php ??
139+
RecommendedPHPVersion,
140+
wp:
141+
args.wp ??
142+
args?.blueprint?.preferredVersions?.wp ??
143+
'latest',
144+
...(args.blueprint?.preferredVersions || {}),
145+
},
146+
};
130147

131148
const tracker = new ProgressTracker();
132149
let lastCaption = '';

0 commit comments

Comments
 (0)