Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions packages/playground/cli/src/run-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { Server } from 'http';
import { MessageChannel as NodeMessageChannel, Worker } from 'worker_threads';
// @ts-ignore
import {
containsFullWordPressInstallation,
expandAutoMounts,
parseMountDirArguments,
parseMountWithDelimiterArguments,
Expand Down Expand Up @@ -237,6 +238,48 @@ export async function parseOptionsAndRunCLI(argsToParse: string[]) {
describe: `Automatically mount the specified directory. If no path is provided, 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.`,
type: 'string',
},
develop: {
alias: 'dev',
type: 'string',
describe:
'Enable development mode with automatic WordPress detection and SQLite setup. Accepts optional path to WordPress directory (defaults to current directory).',
requiresArg: false,
default: undefined,
coerce: (value: string | boolean | undefined) => {
// Return undefined if flag not used at all
if (value === undefined) {
return undefined;
}

// Default to current directory if flag is present but no path provided
const targetPath =
typeof value === 'string' && value.length > 0
? value
: process.cwd();

// Resolve relative paths to absolute
const absolutePath = path.resolve(
process.cwd(),
targetPath
);

// Validate path exists
if (!fs.existsSync(absolutePath)) {
throw new Error(
`--develop path does not exist: ${absolutePath}`
);
}

// Validate it's a directory
if (!fs.statSync(absolutePath).isDirectory()) {
throw new Error(
`--develop path must be a directory: ${absolutePath}`
);
}

return absolutePath;
},
},
'follow-symlinks': {
describe:
'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.',
Expand Down Expand Up @@ -732,6 +775,12 @@ export interface RunCLIArgs {
verbosity?: LogVerbosity;
wp?: string;
autoMount?: string;
/**
* Enable development mode with a local WordPress directory.
* Automatically configures mount, WordPress detection, and SQLite setup.
* If provided, should be an absolute path. If not provided, uses current working directory.
*/
develop?: string;
experimentalMultiWorker?: number;
experimentalTrace?: boolean;
internalCookieStore?: boolean;
Expand Down Expand Up @@ -820,6 +869,97 @@ const italic = (text: string) =>
const highlight = (text: string) =>
process.stdout.isTTY ? `\x1b[33m${text}\x1b[0m` : text;

/**
* Configures CLI arguments for development mode.
*
* @param args - CLI arguments
* @returns Modified arguments with development mode configuration
*/
function applyDevelopmentMode(args: RunCLIArgs): RunCLIArgs {
if (!args.develop) {
return args;
}

// Validate that --develop is not used with --auto-mount
if (args.autoMount !== undefined) {
throw new Error(
'The --develop and --auto-mount options cannot be used together. ' +
'Use --develop alone to automatically configure WordPress development mode.'
);
}

const developPath = args.develop;

// 1. Add mount-before-install
const mountConfig: Mount = {
hostPath: developPath,
vfsPath: '/wordpress',
};
const existingMountsBefore = args['mount-before-install'] || [];
args['mount-before-install'] = [...existingMountsBefore, mountConfig];

// 2. Check if WordPress is already installed
const hasWordPress = containsFullWordPressInstallation(developPath);

if (hasWordPress) {
// Skip WordPress download if already present
args.wordpressInstallMode = 'install-from-existing-files-if-needed';
}

// 3. Inject development mode blueprint (SQLite + Debug constants)
// Note: This will set up SQLite even if the WordPress installation
// already has a database configuration. This is intentional for development
// mode to provide a consistent, portable development environment.
const developmentBlueprint = {
steps: [
{
step: 'installPlugin',
pluginData: {
resource: 'wordpress.org/plugins',
slug: 'sqlite-database-integration',
},
},
{
step: 'cp',
fromPath:
'/wordpress/wp-content/plugins/sqlite-database-integration/db.copy',
toPath: '/wordpress/wp-content/db.php',
},
{
step: 'defineWpConfigConsts',
consts: {
WP_DEBUG: true,
WP_DEBUG_LOG: true,
WP_DEBUG_DISPLAY: true,
SCRIPT_DEBUG: true,
},
},
],
};

// If user provided blueprint, combine them
if (args.blueprint) {
const userBlueprint =
typeof args.blueprint === 'string'
? JSON.parse(args.blueprint)
: args.blueprint;

// Combine: development mode setup first (SQLite + debug), then user blueprint
const combinedBlueprint = {
steps: [
...developmentBlueprint.steps,
...(userBlueprint.steps || []),
],
};

args.blueprint = combinedBlueprint as any;
} else {
args.blueprint = developmentBlueprint as any;
}

return args;
}

// These overloads are declared for convenience so runCLI() can return
// different things depending on the CLI command without forcing the
// callers (mostly automated tests) to check return values.
Expand Down Expand Up @@ -850,6 +990,9 @@ export async function runCLI(args: RunCLIArgs): Promise<RunCLIServer | void> {
args = expandStartCommandArgs(args);
}

// Apply development mode configuration early
args = applyDevelopmentMode(args);

if (args.autoMount !== undefined) {
if (args.autoMount === '') {
// No auto-mount path was provided, so use the current working directory.
Expand Down
160 changes: 160 additions & 0 deletions packages/playground/cli/tests/run-cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
existsSync,
lstatSync,
rmSync,
statSync,
} from 'node:fs';
import { createHash } from 'node:crypto';
import { MinifiedWordPressVersionsList } from '@wp-playground/wordpress-builds';
Expand Down Expand Up @@ -1010,4 +1011,163 @@ describe('other run-cli behaviors', () => {
expect(response.status).toBe(500);
});
});

describe('--develop mode', () => {
let tempDir: string;

beforeEach(async () => {
// Create temp directory for tests
tempDir = await mkdtemp(path.join(tmpdir(), 'playground-test-'));
});

afterEach(() => {
// Cleanup
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});

test('should add mount-before-install when --develop is used', async () => {
await using cliServer = await runCLI({
command: 'server',
develop: tempDir,
wordpressInstallMode: 'do-not-attempt-installing',
skipSqliteSetup: true,
});

// We can't directly check args, but we can verify the server started
expect(cliServer).toBeDefined();
});

test('should skip WordPress download if WordPress exists', async () => {
// Create WordPress structure
mkdirSync(path.join(tempDir, 'wp-admin'));
mkdirSync(path.join(tempDir, 'wp-includes'));
mkdirSync(path.join(tempDir, 'wp-content'));

// Create a marker file to verify the mount
writeFileSync(
path.join(tempDir, 'test-marker.txt'),
'test content'
);

await using cliServer = await runCLI({
command: 'server',
develop: tempDir,
skipSqliteSetup: true,
});

// Check that the marker file is accessible in the mounted directory
const fileContent = await cliServer.playground.readFileAsText(
'/wordpress/test-marker.txt'
);
expect(fileContent).toBe('test content');
});

test('should inject development mode blueprint with SQLite and debug constants', async () => {
await using cliServer = await runCLI({
command: 'server',
develop: tempDir,
});

// Verify that wp-config.php has debug constants set
await cliServer.playground.writeFile(
'/wordpress/check-debug.php',
`<?php
require_once '/wordpress/wp-config.php';
echo 'WP_DEBUG: ' . (defined('WP_DEBUG') && WP_DEBUG ? 'true' : 'false') . "\\n";
echo 'WP_DEBUG_LOG: ' . (defined('WP_DEBUG_LOG') && WP_DEBUG_LOG ? 'true' : 'false') . "\\n";
echo 'WP_DEBUG_DISPLAY: ' . (defined('WP_DEBUG_DISPLAY') && WP_DEBUG_DISPLAY ? 'true' : 'false') . "\\n";
echo 'SCRIPT_DEBUG: ' . (defined('SCRIPT_DEBUG') && SCRIPT_DEBUG ? 'true' : 'false') . "\\n";
`
);

const response = await fetch(
new URL('/check-debug.php', cliServer.serverUrl)
);
const text = await response.text();

expect(text).toContain('WP_DEBUG: true');
expect(text).toContain('WP_DEBUG_LOG: true');
expect(text).toContain('WP_DEBUG_DISPLAY: true');
expect(text).toContain('SCRIPT_DEBUG: true');
});

test('should merge with existing blueprint', async () => {
const userBlueprint = {
steps: [
{
step: 'writeFile',
path: '/wordpress/custom-file.txt',
data: 'custom content',
},
],
};

await using cliServer = await runCLI({
command: 'server',
develop: tempDir,
blueprint: userBlueprint as any,
});

// Verify that both development mode and user blueprint steps were executed
const fileContent = await cliServer.playground.readFileAsText(
'/wordpress/custom-file.txt'
);
expect(fileContent).toBe('custom content');

// Also verify debug constants from development mode
await cliServer.playground.writeFile(
'/wordpress/check-debug.php',
`<?php
require_once '/wordpress/wp-config.php';
echo 'WP_DEBUG: ' . (defined('WP_DEBUG') && WP_DEBUG ? 'true' : 'false') . "\\n";
`
);

const response = await fetch(
new URL('/check-debug.php', cliServer.serverUrl)
);
const text = await response.text();
expect(text).toContain('WP_DEBUG: true');
});

test('should throw error if path does not exist', () => {
const nonExistentPath = path.join(tempDir, 'non-existent');

expect(() => {
// This should be caught by the coerce function
// We can't easily test yargs coerce directly, but we can test the logic
if (!existsSync(nonExistentPath)) {
throw new Error(
`--develop path does not exist: ${nonExistentPath}`
);
}
}).toThrow('does not exist');
});

test('should throw error if path is not a directory', () => {
const filePath = path.join(tempDir, 'file.txt');
writeFileSync(filePath, 'content');

expect(() => {
const stats = statSync(filePath);
if (!stats.isDirectory()) {
throw new Error(
`--develop path must be a directory: ${filePath}`
);
}
}).toThrow('must be a directory');
});

test('should throw error if used with --auto-mount', async () => {
await expect(
runCLI({
command: 'server',
develop: tempDir,
autoMount: tempDir,
})
).rejects.toThrow('cannot be used together');
});
});
});
Loading