Skip to content

Commit c86ecea

Browse files
authored
Remove legacy mu-plugin files from site directories on server start (#2655)
Sites created with older Studio versions (e.g June 2024) have physical `0-*.php` mu-plugin files in `wp-content/mu-plugins/`. Newer Studio versions inject the same mu-plugins at runtime via the PHP WASM virtual filesystem (`/internal/studio/mu-plugins/`). When both copies exist, PHP encounters fatal errors like `Cannot redeclare check_current_theme_availability()`. This PR adds a `cleanupLegacyMuPlugins()` function that removes known legacy Studio mu-plugin files from the site's `wp-content/mu-plugins/` directory before starting the WordPress server or running WP-CLI commands.
1 parent b931339 commit c86ecea

File tree

4 files changed

+161
-3
lines changed

4 files changed

+161
-3
lines changed

apps/cli/lib/run-wp-cli-command.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
setPhpIniEntries,
88
} from '@php-wasm/universal';
99
import { createSpawnHandler } from '@php-wasm/util';
10-
import { getMuPlugins } from '@studio/common/lib/mu-plugins';
10+
import { cleanupLegacyMuPlugins, getMuPlugins } from '@studio/common/lib/mu-plugins';
1111
import { LatestSupportedPHPVersion } from '@studio/common/types/php-versions';
1212
import { __ } from '@wordpress/i18n';
1313
import { setupPlatformLevelMuPlugins } from '@wp-playground/wordpress';
@@ -67,6 +67,8 @@ export async function runWpCliCommand(
6767

6868
await php.setSpawnHandler( createNoopSpawnHandler() );
6969

70+
await cleanupLegacyMuPlugins( siteFolder );
71+
7072
// Mount mu-plugins
7173
const [ studioMuPluginsHostPath, loaderMuPluginHostPath ] = await getMuPlugins( {
7274
isWpAutoUpdating: false,

apps/cli/wordpress-server-child.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { dirname } from 'path';
1414
import { DEFAULT_PHP_VERSION } from '@studio/common/constants';
1515
import { isWordPressDirectory } from '@studio/common/lib/fs-utils';
16-
import { getMuPlugins } from '@studio/common/lib/mu-plugins';
16+
import { cleanupLegacyMuPlugins, getMuPlugins } from '@studio/common/lib/mu-plugins';
1717
import { decodePassword } from '@studio/common/lib/passwords';
1818
import { formatPlaygroundCliMessage } from '@studio/common/lib/playground-cli-messages';
1919
import { sequential } from '@studio/common/lib/sequential';
@@ -139,6 +139,8 @@ async function getBaseRunCLIArgs(
139139
): Promise< RunCLIArgs > {
140140
const wordpressInstallMode = await getWordPressInstallMode( config.sitePath );
141141

142+
await cleanupLegacyMuPlugins( config.sitePath );
143+
142144
const [ studioMuPluginsHostPath, loaderMuPluginHostPath ] = await getMuPlugins( {
143145
isWpAutoUpdating: config.isWpAutoUpdating,
144146
} );

tools/common/lib/mu-plugins.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* available to WordPress instances. Shared between desktop app and CLI.
66
*/
77

8-
import { mkdtemp, writeFile } from 'fs/promises';
8+
import { mkdtemp, readdir, unlink, writeFile } from 'fs/promises';
99
import { tmpdir } from 'os';
1010
import { join } from 'path';
1111

@@ -521,3 +521,71 @@ export async function getMuPlugins( options: MuPluginOptions ) {
521521

522522
return [ studioMuPluginsHostPath, loaderMuPluginHostPath ];
523523
}
524+
525+
/**
526+
* Legacy mu-plugin filenames that older Studio versions wrote directly into
527+
* wp-content/mu-plugins/. Newer versions inject these at runtime via the
528+
* PHP WASM virtual filesystem, so on-disk copies cause "Cannot redeclare"
529+
* PHP fatal errors. This list includes both current and retired filenames.
530+
*/
531+
const LEGACY_MU_PLUGIN_FILENAMES = [
532+
// Current mu-plugins (from getStandardMuPlugins)
533+
'0-allowed-redirect-hosts.php',
534+
'0-auto-login.php',
535+
'0-check-theme-availability.php',
536+
'0-deactivate-jetpack-modules.php',
537+
'0-disable-auto-updates.php',
538+
'0-enable-auto-updates.php',
539+
'0-http-request-timeout.php',
540+
'0-https-for-reverse-proxy.php',
541+
'0-permalinks.php',
542+
'0-redirect-to-siteurl-constant.php',
543+
'0-sqlite-command.php',
544+
'0-studio-admin-api.php',
545+
'0-studio-cli-commands.php',
546+
'0-suppress-dns-get-record-warnings.php',
547+
'0-thumbnails.php',
548+
'0-tmp-fix-hide-plugins-spinner.php',
549+
'0-tmp-fix-qm-plugin-sapi.php',
550+
'0-wp-admin-trailing-slash.php',
551+
// Retired mu-plugins from older Studio versions
552+
'0-32bit-integer-warnings.php',
553+
'0-dns-functions.php',
554+
'0-sqlite.php',
555+
'0-wp-config-constants-polyfill.php',
556+
];
557+
558+
/**
559+
* Remove legacy Studio mu-plugin files from a site's wp-content/mu-plugins/ directory.
560+
*
561+
* Older Studio versions wrote mu-plugin PHP files directly into the site directory.
562+
* Newer versions inject them at runtime via the PHP WASM virtual filesystem.
563+
* Having both copies causes PHP fatal errors like "Cannot redeclare" because
564+
* the same functions are defined in both the on-disk file and the runtime-injected file.
565+
*
566+
* @param sitePath - Absolute path to the WordPress site directory
567+
*/
568+
export async function cleanupLegacyMuPlugins( sitePath: string ): Promise< void > {
569+
const muPluginsDir = join( sitePath, 'wp-content', 'mu-plugins' );
570+
571+
let entries: string[];
572+
try {
573+
entries = await readdir( muPluginsDir );
574+
} catch {
575+
// Directory doesn't exist or can't be read – nothing to clean up
576+
return;
577+
}
578+
579+
const legacySet = new Set( LEGACY_MU_PLUGIN_FILENAMES );
580+
const filesToRemove = entries.filter( ( name ) => legacySet.has( name ) );
581+
582+
await Promise.all(
583+
filesToRemove.map( async ( name ) => {
584+
try {
585+
await unlink( join( muPluginsDir, name ) );
586+
} catch {
587+
// Best-effort: file may already be gone or locked
588+
}
589+
} )
590+
);
591+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
2+
import { mkdtemp, readdir } from 'fs/promises';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
import { cleanupLegacyMuPlugins } from '@studio/common/lib/mu-plugins';
6+
7+
describe( 'cleanupLegacyMuPlugins', () => {
8+
let sitePath: string;
9+
10+
beforeEach( async () => {
11+
sitePath = await mkdtemp( join( tmpdir(), 'studio-test-site-' ) );
12+
} );
13+
14+
it( 'should remove legacy Studio mu-plugin files', async () => {
15+
const muPluginsDir = join( sitePath, 'wp-content', 'mu-plugins' );
16+
mkdirSync( muPluginsDir, { recursive: true } );
17+
18+
// Create legacy files that should be removed
19+
const legacyFiles = [
20+
'0-allowed-redirect-hosts.php',
21+
'0-check-theme-availability.php',
22+
'0-permalinks.php',
23+
'0-thumbnails.php',
24+
'0-sqlite.php',
25+
'0-dns-functions.php',
26+
'0-32bit-integer-warnings.php',
27+
];
28+
for ( const file of legacyFiles ) {
29+
writeFileSync( join( muPluginsDir, file ), '<?php // legacy' );
30+
}
31+
32+
await cleanupLegacyMuPlugins( sitePath );
33+
34+
for ( const file of legacyFiles ) {
35+
expect( existsSync( join( muPluginsDir, file ) ) ).toBe( false );
36+
}
37+
} );
38+
39+
it( 'should not remove non-Studio mu-plugin files', async () => {
40+
const muPluginsDir = join( sitePath, 'wp-content', 'mu-plugins' );
41+
mkdirSync( muPluginsDir, { recursive: true } );
42+
43+
// Create a user mu-plugin that should NOT be removed
44+
const userPlugin = 'my-custom-plugin.php';
45+
writeFileSync( join( muPluginsDir, userPlugin ), '<?php // user plugin' );
46+
47+
// Also create a legacy file to verify selective removal
48+
writeFileSync( join( muPluginsDir, '0-check-theme-availability.php' ), '<?php // legacy' );
49+
50+
await cleanupLegacyMuPlugins( sitePath );
51+
52+
expect( existsSync( join( muPluginsDir, userPlugin ) ) ).toBe( true );
53+
expect( existsSync( join( muPluginsDir, '0-check-theme-availability.php' ) ) ).toBe( false );
54+
} );
55+
56+
it( 'should handle missing wp-content/mu-plugins directory gracefully', async () => {
57+
// sitePath exists but wp-content/mu-plugins does not
58+
await expect( cleanupLegacyMuPlugins( sitePath ) ).resolves.toBeUndefined();
59+
} );
60+
61+
it( 'should handle missing site path gracefully', async () => {
62+
await expect( cleanupLegacyMuPlugins( '/nonexistent/path/to/site' ) ).resolves.toBeUndefined();
63+
} );
64+
65+
it( 'should not remove sqlite-database-integration directory', async () => {
66+
const muPluginsDir = join( sitePath, 'wp-content', 'mu-plugins' );
67+
const sqliteDir = join( muPluginsDir, 'sqlite-database-integration' );
68+
mkdirSync( sqliteDir, { recursive: true } );
69+
writeFileSync( join( sqliteDir, 'load.php' ), '<?php // sqlite integration' );
70+
71+
await cleanupLegacyMuPlugins( sitePath );
72+
73+
expect( existsSync( sqliteDir ) ).toBe( true );
74+
expect( existsSync( join( sqliteDir, 'load.php' ) ) ).toBe( true );
75+
} );
76+
77+
it( 'should handle empty mu-plugins directory', async () => {
78+
const muPluginsDir = join( sitePath, 'wp-content', 'mu-plugins' );
79+
mkdirSync( muPluginsDir, { recursive: true } );
80+
81+
await cleanupLegacyMuPlugins( sitePath );
82+
83+
const remaining = await readdir( muPluginsDir );
84+
expect( remaining ).toHaveLength( 0 );
85+
} );
86+
} );

0 commit comments

Comments
 (0)