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
292 changes: 291 additions & 1 deletion projects/js-packages/jetpack-cli/bin/jp.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env node

import { spawnSync } from 'child_process';
import { spawn, spawnSync } from 'child_process';
import fs, { readFileSync } from 'fs';
import os from 'os';
import { dirname, resolve } from 'path';
import process from 'process';
import { fileURLToPath } from 'url';
Expand Down Expand Up @@ -291,6 +292,283 @@ const initJetpack = async () => {
}
};

/**
* Detect whether the host rsync is Apple's openrsync fork.
*
* @return {{ isOpenrsync: boolean, copyLinksOpt: string }} Detection result.
*/
const detectOpenrsync = () => {
if ( os.platform() !== 'darwin' ) {
return { isOpenrsync: false, copyLinksOpt: '--copy-links' };
}
const versionResult = spawnSync( 'rsync', [ '--version' ], { encoding: 'utf8' } );
const detected =
versionResult.status === 0 && ( versionResult.stdout || '' ).includes( 'openrsync' );
return {
isOpenrsync: detected,
copyLinksOpt: detected ? '--copy-unsafe-links' : '--copy-links',
};
};

/**
* Write a line to stdout using \r\n endings.
*
* Docker exec may leave the host terminal in raw mode during watch,
* where \n alone moves down without returning to column 0.
*
* @param {string} text - Text to print (no trailing newline needed).
*/
const writeln = ( text = '' ) => {
process.stdout.write( text + '\r\n' );
};

/**
* Print the AUTOLOAD_DEV warning banner.
*/
const printAutoloadWarning = () => {
writeln();
writeln(
chalk.black.bgYellow(
'*************************************************************************************'
)
);
writeln(
chalk.black.bgYellow(
'** Make sure you have set ' +
chalk.bold( "define( 'JETPACK_AUTOLOAD_DEV', true );" ) +
' in a mu-plugin **'
)
);
writeln(
chalk.black.bgYellow(
'** on the remote site. Otherwise the wrong versions of packages may be loaded! **'
)
);
writeln(
chalk.black.bgYellow(
'*************************************************************************************'
)
);
writeln();
};

/**
* Clean up the rsync data directory.
*
* @param {string} monorepoRoot - Path to the monorepo root.
*/
const cleanupRsyncData = monorepoRoot => {
try {
fs.rmSync( resolve( monorepoRoot, 'tools/docker/data/rsync' ), {
recursive: true,
force: true,
} );
} catch {
// Ignore cleanup errors.
}
};

/**
* Run the host-side rsync command using metadata from the Docker phase.
*
* @param {string} monorepoRoot - Path to the monorepo root.
* @param {string} copyLinksOpt - The --copy-links or --copy-unsafe-links flag.
* @param {boolean} quiet - If true, capture output instead of inheriting stdio.
* @return {number} Exit code from rsync.
*/
const runHostRsync = ( monorepoRoot, copyLinksOpt, quiet = false ) => {
const metadataPath = resolve( monorepoRoot, 'tools/docker/data/rsync/metadata.json' );
if ( ! fs.existsSync( metadataPath ) ) {
console.error( chalk.red( 'Error: rsync metadata not found after Docker phase.' ) );
console.error( chalk.red( `Expected: ${ metadataPath }` ) );
return 1;
}

let metadata;
try {
metadata = JSON.parse( fs.readFileSync( metadataPath, 'utf8' ) );
} catch ( e ) {
console.error( chalk.red( `Error: failed to parse rsync metadata: ${ e.message }` ) );
return 1;
}
if ( metadata.version !== 1 ) {
console.error(
chalk.red(
`Error: unsupported rsync metadata version ${ metadata.version }. ` +
'Try updating the Jetpack CLI.'
)
);
return 1;
}
const filterFile = resolve( monorepoRoot, metadata.filterFile );
const source = resolve( monorepoRoot, metadata.source );
// Ensure source ends with / for rsync directory semantics.
const sourceArg = source.endsWith( '/' ) ? source : source + '/';

const rsyncArgs = [
// In quiet mode, skip -P (progress bars use \r that clashes with concurrent output)
// and capture output to print as a block after completion.
quiet ? '-azKv' : '-azKPv',
'--prune-empty-dirs',
'--delete',
'--delete-after',
'--delete-excluded',
copyLinksOpt,
`--include-from=${ filterFile }`,
sourceArg,
metadata.dest,
];

const rsyncResult = spawnSync( 'rsync', rsyncArgs, {
stdio: quiet ? [ 'inherit', 'pipe', 'pipe' ] : 'inherit',
cwd: monorepoRoot,
} );

if ( quiet ) {
const stderr = ( rsyncResult.stderr || '' ).toString().trim();
if ( stderr ) {
// Use \r\n — terminal may be in raw mode during watch.
process.stderr.write( stderr.replace( /\n/g, '\r\n' ) + '\r\n' );
}
const stdout = ( rsyncResult.stdout || '' ).toString().trim();
if ( stdout ) {
process.stdout.write( stdout.replace( /\n/g, '\r\n' ) + '\r\n' );
}
}

return rsyncResult.status ?? 1;
};

/**
* Handle rsync in split mode: Docker for file collection, host for rsync binary.
*
* This allows rsync to use the host's native SSH, which is required for
* Secure Enclave keys (AutoProxxy) that cannot be forwarded into Docker.
*
* @param {string} monorepoRoot - Path to the monorepo root.
* @param {Array} args - Original CLI arguments (starting with 'rsync').
*/
const handleRsyncSplit = async ( monorepoRoot, args ) => {
// Clean up stale data from previous runs.
cleanupRsyncData( monorepoRoot );

// Detect openrsync on the host (where rsync will actually run).
// This mirrors the checks in rsync.js rsyncInit(), which are skipped in --prepare-filters mode.
const { isOpenrsync: hostIsOpenrsync, copyLinksOpt } = detectOpenrsync();
if ( hostIsOpenrsync ) {
const versionResult = spawnSync( 'sw_vers', [ '--productVersion' ], { encoding: 'utf8' } );
const macVersion = ( versionResult.stdout || '' ).trim();
if ( macVersion === '15.4' ) {
console.error(
chalk.red(
'The implementation of rsync in macOS 15.4 is unable to properly sync symlinks.'
)
);
console.error( chalk.red( 'Please install standard rsync (e.g. `brew install rsync`).' ) );
process.exitCode = 1;
return;
}
console.error(
chalk.yellow( 'The implementation of rsync in macOS is unable to properly sync symlinks.' )
);
console.error(
chalk.yellow( 'Installing standard rsync (e.g. `brew install rsync`) is recommended.' )
);
if ( args.includes( '--non-interactive' ) ) {
process.exitCode = 1;
return;
}
console.error();
const response = await prompts( {
type: 'confirm',
name: 'proceed',
message:
'Continuing will not break anything, but will copy many unneeded files.\nProceed to sync files?',
initial: false,
} );
if ( ! response.proceed ) {
return;
}
}

const isWatch = args.includes( '--watch' );
// Build the Docker command: inject --prepare-filters before any other args.
const dockerArgs = [ ...args ];
// Insert --prepare-filters after 'rsync'.
const rsyncIdx = dockerArgs.indexOf( 'rsync' );
dockerArgs.splice( rsyncIdx + 1, 0, '--prepare-filters' );

const monorepoScript = resolve( monorepoRoot, 'tools/docker/bin/monorepo' );

if ( ! isWatch ) {
// === Single mode ===
const dockerResult = spawnSync( monorepoScript, [ 'pnpm', 'jetpack', ...dockerArgs ], {
stdio: 'inherit',
cwd: monorepoRoot,
} );

if ( dockerResult.status !== 0 ) {
cleanupRsyncData( monorepoRoot );
throw new Error( `Docker phase failed with status ${ dockerResult.status }` );
}

const rsyncStatus = runHostRsync( monorepoRoot, copyLinksOpt );
if ( rsyncStatus !== 0 ) {
cleanupRsyncData( monorepoRoot );
throw new Error( `rsync failed with status ${ rsyncStatus }` );
}

printAutoloadWarning();
cleanupRsyncData( monorepoRoot );
} else {
// === Watch mode ===
const triggerPath = resolve( monorepoRoot, 'tools/docker/data/rsync/trigger' );
let firstSync = true;

// Watch for trigger file changes (polling — works across filesystems).
// Registered before spawning Docker to avoid missing the initial trigger.
const onTriggerChange = () => {
if ( ! fs.existsSync( triggerPath ) ) {
return;
}
const status = runHostRsync( monorepoRoot, copyLinksOpt, true );
if ( status !== 0 ) {
process.stderr.write( chalk.red( `rsync exited with status ${ status }` ) + '\r\n' );
} else if ( firstSync ) {
firstSync = false;
printAutoloadWarning();
}
};

fs.watchFile( triggerPath, { interval: 500 }, onTriggerChange );

const dockerChild = spawn( monorepoScript, [ 'pnpm', 'jetpack', ...dockerArgs ], {
stdio: 'inherit',
cwd: monorepoRoot,
} );

const cleanup = () => {
fs.unwatchFile( triggerPath, onTriggerChange );
cleanupRsyncData( monorepoRoot );
};

// Forward termination signals to the Docker child.
const onSignal = signal => {
dockerChild.kill( signal );
// The 'exit' handler below will clean up.
};
process.on( 'SIGINT', onSignal );
process.on( 'SIGTERM', onSignal );

dockerChild.on( 'exit', code => {
cleanup();
process.removeListener( 'SIGINT', onSignal );
process.removeListener( 'SIGTERM', onSignal );
process.exit( code ?? 0 );
} );
}
};

// Main execution
const main = async () => {
try {
Expand Down Expand Up @@ -497,6 +775,18 @@ const main = async () => {
}
}

// Handle rsync in split mode: Docker collects files, host runs rsync with native SSH.
// The --config, --help, and -h flags fall through to all-in-Docker (no SSH needed).
if (
args[ 0 ] === 'rsync' &&
! args.includes( '--config' ) &&
! args.includes( '--help' ) &&
! args.includes( '-h' )
) {
await handleRsyncSplit( monorepoRoot, args );
return;
}

// Run the monorepo script with the original arguments
const result = spawnSync(
resolve( monorepoRoot, 'tools/docker/bin/monorepo' ),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: changed

Split rsync execution so Docker collects files and the host runs rsync with native SSH, enabling support for Secure Enclave keys that cannot be forwarded into Docker.
Loading
Loading