Skip to content
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"build": "vite build --config ./vite.config.dev.ts",
"build:prod": "vite build --config ./vite.config.prod.ts",
"build:npm": "vite build --config ./vite.config.npm.ts",
"install:bundle": "npm install --omit=dev --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
"install:bundle": "npm install --omit=dev --no-audit --no-fund --no-package-lock --no-progress --install-links --no-workspaces && node ../../scripts/apply-available-patches.mjs patches && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
"package": "npm run install:bundle && npm run build:prod",
"lint": "eslint .",
"watch": "vite build --config ./vite.config.dev.ts --watch",
Expand Down
24 changes: 17 additions & 7 deletions apps/studio/forge.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const bundledPhpBinaryRoot = path.join( __dirname, 'php-bin' );
const config: ForgeConfig = {
packagerConfig: {
asar: true,
// prePackage installs the self-contained production dependency tree.
prune: false,
extraResource: [
path.join( __dirname, 'assets' ),
path.join( __dirname, 'bin' ),
Expand Down Expand Up @@ -199,6 +201,8 @@ const config: ForgeConfig = {
console.log( 'Installing Studio app dependencies for bundling ...' );
// NOTE: The `app:install:bundle` script mutates the `apps/studio/node_modules` directory. You
// may need to rerun `npm ci` from the repo root to reset the dependency tree after packaging.
const studioNodeModules = path.join( repoRoot, 'apps', 'studio', 'node_modules' );
fs.rmSync( studioNodeModules, { recursive: true, force: true } );
await execAsync( [ 'npm', 'run', 'app:install:bundle' ] );

if ( process.env.SKIP_LANGUAGE_PACKS ) {
Expand All @@ -211,6 +215,8 @@ const config: ForgeConfig = {
console.log( 'Building CLI (with bundled node_modules) ...' );
// NOTE: The `cli:package` script mutates the `apps/cli/node_modules` directory. You may need to
// rerun `npm ci` from the repo root to reset the dependency tree after packaging.
const cliNodeModulesSource = path.join( repoRoot, 'apps', 'cli', 'node_modules' );
fs.rmSync( cliNodeModulesSource, { recursive: true, force: true } );
await execAsync( [ 'npm', 'run', 'cli:package' ] );

// Remove native binaries for other platforms from CLI's node_modules.
Expand Down Expand Up @@ -283,10 +289,14 @@ const config: ForgeConfig = {
// and @mistralai's ~200-char generated filenames, nested under pi-coding-agent, blow
// past Windows' 260-char path limit and crash the Squirrel maker.
console.log( 'Removing unused AI provider SDKs from CLI bundle...' );
const unusedProviderPaths = globSync(
const unusedProviderPatterns = [
'{@mistralai,@aws-sdk,@aws-crypto,@smithy,@google/genai}/',
'**/node_modules/{@mistralai,@aws-sdk,@aws-crypto,@smithy,@google/genai}/',
{ cwd: cliNodeModules, absolute: true }
);
];
const unusedProviderPaths = globSync( unusedProviderPatterns, {
cwd: cliNodeModules,
absolute: true,
} );
for ( const providerPath of unusedProviderPaths ) {
fs.rmSync( providerPath, { recursive: true, force: true } );
console.log( `Removed ${ providerPath }` );
Expand All @@ -295,10 +305,10 @@ const config: ForgeConfig = {
// Verify the prune succeeded — a leftover provider tree on Windows resurfaces as
// the PathTooLongException the prune exists to prevent. Fail now with context
// instead of letting the Squirrel maker crash later.
const remaining = globSync(
'**/node_modules/{@mistralai,@aws-sdk,@aws-crypto,@smithy,@google/genai}/',
{ cwd: cliNodeModules, absolute: true }
);
const remaining = globSync( unusedProviderPatterns, {
cwd: cliNodeModules,
absolute: true,
} );
if ( remaining.length > 0 ) {
throw new Error(
`Could not prune ${ remaining.length } provider director(ies) that exceed ` +
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"make:macos-arm64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && tsx ../../scripts/build-desks-renderer.ts && SKIP_DMG=true FILE_ARCHITECTURE=arm64 electron-forge make . --arch=arm64 --platform=darwin",
"make:linux-x64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && tsx ../../scripts/build-desks-renderer.ts && electron-forge make . --arch=x64 --platform=linux",
"make:linux-arm64": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && tsx ../../scripts/build-desks-renderer.ts && electron-forge make . --arch=arm64 --platform=linux",
"install:bundle": "npm install --no-package-lock --no-progress --install-links --no-workspaces && patch-package && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
"install:bundle": "npm install --omit=dev --no-audit --no-fund --no-package-lock --no-progress --install-links --no-workspaces && node ../../scripts/apply-available-patches.mjs patches && node ../../scripts/remove-fs-ext-other-platform-binaries.mjs",
"lint": "eslint src e2e",
"package": "electron-vite build --config ./electron.vite.config.ts --outDir=dist && tsx ../../scripts/build-desks-renderer.ts && electron-forge package .",
"publish": "electron-forge publish .",
Expand Down
70 changes: 70 additions & 0 deletions scripts/apply-available-patches.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';

// Bundle installs can omit dev dependencies. Apply only patches whose target package is present
// instead of failing on patches for packages that are intentionally absent from the bundle.
const patchDir = process.argv[ 2 ];

if ( ! patchDir ) {
throw new Error( 'Usage: node scripts/apply-available-patches.mjs <patch-dir>' );
}

const cwd = process.cwd();
const sourcePatchDir = path.resolve( cwd, patchDir );
const patchFiles = fs
.readdirSync( sourcePatchDir )
.filter( ( file ) => file.endsWith( '.patch' ) )
.sort();

function getPackageName( patchFile ) {
const parts = patchFile.replace( /\.patch$/, '' ).split( '+' );
return parts[ 0 ].startsWith( '@' ) ? `${ parts[ 0 ] }/${ parts[ 1 ] }` : parts[ 0 ];
}

function hasPackage( packageName ) {
return fs.existsSync( path.join( cwd, 'node_modules', ...packageName.split( '/' ) ) );
}

const availablePatchFiles = patchFiles.filter( ( patchFile ) =>
hasPackage( getPackageName( patchFile ) )
);
const skippedPatchFiles = patchFiles.filter(
( patchFile ) => ! availablePatchFiles.includes( patchFile )
);

for ( const patchFile of skippedPatchFiles ) {
console.log( `Skipping patch for missing package: ${ getPackageName( patchFile ) }` );
}

if ( availablePatchFiles.length === 0 ) {
console.log( 'No applicable patches found.' );
process.exit( 0 );
}

const tempPatchDir = fs.mkdtempSync( path.join( cwd, '.studio-patches-' ) );
const relativeTempPatchDir = path.relative( cwd, tempPatchDir );

try {
for ( const patchFile of availablePatchFiles ) {
fs.copyFileSync( path.join( sourcePatchDir, patchFile ), path.join( tempPatchDir, patchFile ) );
}

const result = spawnSync(
'npx',
[ '--no-install', 'patch-package', '--patch-dir', relativeTempPatchDir ],
{
cwd,
stdio: 'inherit',
shell: process.platform === 'win32',
}
);

if ( result.status !== 0 ) {
throw new Error(
`Command failed: npx --no-install patch-package --patch-dir ${ relativeTempPatchDir }`
);
}
} finally {
fs.rmSync( tempPatchDir, { recursive: true, force: true } );
}
98 changes: 90 additions & 8 deletions scripts/package-in-isolation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
*
* In CI, where we have a clean, ephemeral environment, we short-circuit the behavior and run the
* relevant script in place.
*
* Local packaging installs the build toolchain with a lockfile-accurate `npm ci --ignore-scripts`
* and reuses bundled WordPress/server files when they already exist. Set `STUDIO_PACKAGE_FRESH=1`
* to run a full `npm ci` instead, with postinstall re-downloading all bundled files.
*/

import { spawnSync, type SpawnSyncOptions } from 'child_process';
Expand All @@ -25,6 +29,9 @@ import { z } from 'zod';

const REPO_ROOT = path.resolve( import.meta.dirname, '..' );
const STUDIO_APP_PACKAGE_JSON = path.join( REPO_ROOT, 'apps', 'studio', 'package.json' );
const COPY_MODE = fs.constants.COPYFILE_FICLONE;
const BUILD_OUTPUT_DIRS = new Set( [ 'out', 'dist', 'test-results' ] );
const useFreshLocalPackage = process.env.STUDIO_PACKAGE_FRESH === '1';

const STUDIO_APP_PACKAGE_JSON_SCHEMA = z.object( {
scripts: z.record( z.string(), z.string() ),
Expand All @@ -49,8 +56,59 @@ function runOrFail( command: string, args: string[], cwd: string ) {

const result = spawnSync( command, args, options );
if ( result.status !== 0 ) {
process.exit( result.status ?? 1 );
throw new Error( `Command failed: ${ [ command, ...args ].join( ' ' ) }` );
}
}

function ensureBuildToolchain( stagingRoot: string ) {
if ( useFreshLocalPackage ) {
console.log( 'Installing lockfile-fresh workspace dependencies in packaging directory ...' );
runOrFail( 'npm', [ 'ci' ], stagingRoot );
return;
}

// `--ignore-scripts` skips the root postinstall, which would re-download bundled server files
// the staging copy already has. The postinstall steps that are still required (patches, fs-ext
// binary filtering) run explicitly below.
console.log( 'Installing workspace dependencies in packaging directory ...' );
runOrFail(
'npm',
[ 'ci', '--ignore-scripts', '--no-audit', '--no-fund', '--no-progress' ],
stagingRoot
);
runOrFail( 'npx', [ 'patch-package', '--patch-dir', 'apps/cli/patches' ], stagingRoot );
runOrFail( 'npx', [ 'patch-package', '--patch-dir', 'apps/studio/patches' ], stagingRoot );
runOrFail( 'node', [ './scripts/remove-fs-ext-other-platform-binaries.mjs' ], stagingRoot );
}

function hasBundledServerFiles( repoRoot: string ): boolean {
// Marker paths for artifacts produced by download-wp-server-files.ts,
// download-available-site-translations.mjs, and download-agent-skills.ts.
const requiredPaths = [
'wp-files/latest/wordpress/wp-includes/version.php',
'wp-files/latest/available-site-translations.json',
'wp-files/sqlite-database-integration/db.copy',
'wp-files/wp-cli/wp-cli.phar',
'wp-files/sqlite-command/command.php',
'wp-files/phpmyadmin/index.php',
'wp-files/reprint/reprint.phar',
'wp-files/skills/wp-plugin-development/SKILL.md',
];

return requiredPaths.every( ( requiredPath ) =>
fs.existsSync( path.join( repoRoot, requiredPath ) )
);
}

function ensureBundledServerFiles( stagingRoot: string ) {
if ( hasBundledServerFiles( stagingRoot ) ) {
return;
}

console.log( 'Downloading missing bundled server files in packaging directory ...' );
runOrFail( 'npx', [ 'tsx', './scripts/download-wp-server-files.ts' ], stagingRoot );
runOrFail( 'node', [ './scripts/download-available-site-translations.mjs' ], stagingRoot );
runOrFail( 'npx', [ 'tsx', './scripts/download-agent-skills.ts' ], stagingRoot );
}

function shouldCopyToStaging( sourcePath: string ): boolean {
Expand All @@ -60,9 +118,32 @@ function shouldCopyToStaging( sourcePath: string ): boolean {
const pathSegments = relativePath.split( path.sep );
if ( pathSegments.includes( '.git' ) ) return false;
if ( pathSegments.includes( 'node_modules' ) ) return false;
if ( BUILD_OUTPUT_DIRS.has( pathSegments[ 0 ] ) ) return false;
if ( pathSegments[ 0 ] === 'apps' && BUILD_OUTPUT_DIRS.has( pathSegments[ 2 ] ) ) {
return false;
}
if ( pathSegments[ 0 ] === 'tools' && BUILD_OUTPUT_DIRS.has( pathSegments[ 2 ] ) ) {
return false;
}

const topLevelDir = pathSegments[ 0 ];
return topLevelDir !== 'out' && topLevelDir !== 'dist' && topLevelDir !== 'test-results';
return true;
}

function moveOrCopySync( from: string, to: string ) {
fs.rmSync( to, { recursive: true, force: true } );
fs.mkdirSync( path.dirname( to ), { recursive: true } );

try {
fs.renameSync( from, to );
} catch {
fs.cpSync( from, to, {
recursive: true,
force: true,
verbatimSymlinks: true,
mode: COPY_MODE,
} );
fs.rmSync( from, { recursive: true, force: true } );
}
}

function copyArtifactsBack( stagingRoot: string ) {
Expand All @@ -80,9 +161,7 @@ function copyArtifactsBack( stagingRoot: string ) {

for ( const [ from, to ] of artifactPaths ) {
if ( ! fs.existsSync( from ) ) continue;
fs.rmSync( to, { recursive: true, force: true } );
fs.mkdirSync( path.dirname( to ), { recursive: true } );
fs.cpSync( from, to, { recursive: true, force: true, verbatimSymlinks: true } );
moveOrCopySync( from, to );
}
}

Expand Down Expand Up @@ -116,10 +195,13 @@ function main() {
fs.cpSync( REPO_ROOT, stagingRoot, {
recursive: true,
filter: shouldCopyToStaging,
mode: COPY_MODE,
} );

console.log( 'Installing workspace dependencies in packaging directory ...' );
runOrFail( 'npm', [ 'ci' ], stagingRoot );
ensureBuildToolchain( stagingRoot );
if ( ! useFreshLocalPackage ) {
ensureBundledServerFiles( stagingRoot );
}

console.log( `Running script "${ scriptName }" in packaging directory ...` );
runOrFail( 'npm', [ '-w', 'studio-app', 'run', scriptName ], stagingRoot );
Expand Down