Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
54e2813
Add sync push/pull CLI commands with shared Zod schemas and event pip…
bcotrim Mar 23, 2026
9fbe4c3
Merge branch 'trunk' into stu-1354-site-picker-sync
bcotrim Mar 24, 2026
6d265d9
Fix CLI build by replacing stream/promises with stream import
bcotrim Mar 24, 2026
d797225
Fix lint errors in sync store files
bcotrim Mar 24, 2026
7761c7f
Add progress tracking, --archive flag, and better error handling to s…
bcotrim Mar 24, 2026
df20ed0
Add interactive selective sync to CLI push and pull commands
bcotrim Mar 24, 2026
ceaf61d
Extract shared sync logic to common and fix selective pull bugs
bcotrim Mar 24, 2026
35646e8
Fix pre-existing TS error in process.emit override
bcotrim Mar 24, 2026
eda9b8f
Fix lint error in process.emit override
bcotrim Mar 25, 2026
d3ddcc3
Merge branch 'trunk' into stu-1354-site-picker-sync
bcotrim Mar 25, 2026
6cb90d0
Remove unused sync constants re-export
bcotrim Mar 25, 2026
41d523f
Remove unnecessary comment in sync-operations-slice
bcotrim Mar 25, 2026
c046055
Add esc cancel support and unify help tip layout across prompts
bcotrim Mar 25, 2026
51855d3
Fix return types for sync selector cancel support
bcotrim Mar 25, 2026
6013429
Merge branch 'trunk' into stu-1354-site-picker-sync
bcotrim Mar 25, 2026
a03a70f
Use Zod schema for SyncOption validation instead of manual VALID_OPTIONS
bcotrim Mar 25, 2026
f7d92db
Fix sync event lifecycle bugs and extract shared constants
bcotrim Mar 25, 2026
eb02fb5
Merge branch 'trunk' into stu-1354-site-picker-sync
bcotrim Mar 26, 2026
97758c2
Add sync size validation to CLI push and pull commands
bcotrim Mar 26, 2026
ca2485a
Simplify type annotations in sync push and pull commands
bcotrim Mar 26, 2026
3f70aad
Remove unused sync CLI events
bcotrim Mar 26, 2026
8521cc6
Add --site arg, stale-progress polling, and fix push archive flow
bcotrim Mar 26, 2026
6b3f7cd
Merge branch 'trunk' into stu-1354-site-picker-sync
bcotrim Mar 26, 2026
63f96ff
Merge branch 'trunk' into stu-1354-site-picker-sync
bcotrim Mar 26, 2026
6f2b456
Rename --site arg to --remote-site in sync commands
bcotrim Mar 27, 2026
a7682e5
Address PR feedback: move try/catch to handlers, parse options in yar…
bcotrim Mar 27, 2026
f2e5791
Move logger to module scope in sync pull/push commands
bcotrim Mar 27, 2026
533450c
Remove type re-exports, import directly from common
bcotrim Mar 27, 2026
9211780
Fix import order in sync test
bcotrim Mar 27, 2026
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
196 changes: 196 additions & 0 deletions apps/cli/commands/sync/pull.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import os from 'os';
import path from 'path';
import { confirm } from '@inquirer/prompts';
import { readAuthToken } from '@studio/common/lib/shared-config';
import {
SYNC_MAX_STALLED_ATTEMPTS,
SYNC_POLL_INTERVAL_MS,
SYNC_PUSH_SIZE_LIMIT_BYTES,
SYNC_PUSH_SIZE_LIMIT_GB,
} from '@studio/common/lib/sync/constants';
import { SyncCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __, sprintf } from '@wordpress/i18n';
import { getSiteByFolder } from 'cli/lib/cli-config/sites';
import {
checkBackupSize,
fetchSyncableSites,
initiateBackup,
parseSyncOptions,
pollBackupStatus,
downloadBackup,
} from 'cli/lib/sync-api';
import { fetchPullTree, selectSyncItemsForPull } from 'cli/lib/sync-selector';
import { findSyncSiteByIdentifier, pickSyncSite } from 'cli/lib/sync-site-picker';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';
import type { SyncOption } from '@studio/common/types/sync';

const logger = new Logger< LoggerAction >();

export async function runCommand(
siteFolder: string,
syncOptions?: SyncOption[],
siteIdentifier?: string
): Promise< void > {
const token = await readAuthToken();
if ( ! token ) {
throw new LoggerError(
__( 'Authentication required. Please log in with `studio auth login`.' )
);
}

await getSiteByFolder( siteFolder );

logger.reportStart( LoggerAction.FETCH_SITES, __( 'Fetching WordPress.com sites…' ) );
const sites = await fetchSyncableSites( token.accessToken );
logger.spinner.stop();
logger.reportSuccess( sprintf( __( 'Found %d sites' ), sites.length ), true );

let selectedSite;
if ( siteIdentifier ) {
selectedSite = findSyncSiteByIdentifier( sites, siteIdentifier );
} else {
selectedSite = await pickSyncSite( sites, __( 'Select a site to pull from' ) );
if ( ! selectedSite ) {
return;
}
}

let optionsToSync: SyncOption[];
let includePathList: string[] | undefined;

if ( syncOptions ) {
optionsToSync = syncOptions;
} else {
logger.reportStart( LoggerAction.FETCH_SITES, __( 'Fetching file tree…' ) );
const { tree } = await fetchPullTree( token.accessToken, selectedSite.id );
logger.spinner.stop();

const selection = await selectSyncItemsForPull( token.accessToken, selectedSite.id, tree );
if ( ! selection ) {
return;
}
optionsToSync = selection.optionsToSync;
includePathList = selection.includePathList;
}

const remoteSiteId = selectedSite.id;
const remoteSiteName = selectedSite.name;
const remoteSiteUrl = selectedSite.url;

// Pull progress: Backup (0-50%) → Download (50-80%) → Import (80-100%)
logger.reportStart(
LoggerAction.INITIATE_BACKUP,
sprintf( __( 'Initializing remote backup… (%d%%)' ), 0 )
);
const backupId = await initiateBackup( token.accessToken, remoteSiteId, {
optionsToSync,
includePathList,
} );

let downloadUrl: string | null = null;
let lastPercent = -1;
let stalledAttempts = 0;

while ( stalledAttempts < SYNC_MAX_STALLED_ATTEMPTS ) {
const status = await pollBackupStatus( token.accessToken, remoteSiteId, backupId );

if ( status.status === 'failed' ) {
throw new LoggerError( __( 'Remote backup failed' ) );
}

if ( status.status === 'finished' && status.downloadUrl ) {
downloadUrl = status.downloadUrl;
break;
}

const currentPercent = Math.round( status.percent );
if ( currentPercent !== lastPercent ) {
stalledAttempts = 0;
lastPercent = currentPercent;
} else {
stalledAttempts++;
}

// Backup phase: 0-50%
const backupProgress = Math.round( status.percent * 0.5 );
logger.spinner.text = sprintf( __( 'Creating remote backup… (%d%%)' ), backupProgress );

await new Promise( ( resolve ) => setTimeout( resolve, SYNC_POLL_INTERVAL_MS ) );
}

if ( ! downloadUrl ) {
throw new LoggerError( __( 'Backup timed out — no progress detected' ) );
}

// Check backup size before downloading
const backupFileSize = await checkBackupSize( downloadUrl );
if ( backupFileSize > SYNC_PUSH_SIZE_LIMIT_BYTES ) {
logger.spinner.stop();
const shouldContinue = await confirm( {
message: sprintf(
__(
"Your site's backup exceeds %d GB. Pulling it will prevent you from pushing the site back. Do you want to continue?"
),
SYNC_PUSH_SIZE_LIMIT_GB
),
default: true,
} );
if ( ! shouldContinue ) {
return;
}
}

// Download phase: 50-80%
logger.spinner.text = sprintf( __( 'Downloading backup… (%d%%)' ), 50 );
const tempDir = path.join( os.tmpdir(), 'studio-sync' );
const { mkdirSync } = await import( 'fs' );
mkdirSync( tempDir, { recursive: true } );
const destPath = path.join( tempDir, `pull-${ remoteSiteId }-${ Date.now() }.tar.gz` );
await downloadBackup( downloadUrl, destPath );

// TODO: Import backup into local site (80-100%)
logger.spinner.stop();
logger.reportSuccess(
sprintf(
__( 'Pulled from %s (%s). Backup saved to %s. Import not yet implemented in CLI.' ),
remoteSiteName,
remoteSiteUrl,
destPath
)
);
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'pull',
describe: __( 'Pull a WordPress.com site to your local site' ),
builder: ( yargs ) => {
return yargs
.option( 'options', {
type: 'string',
description: __(
'Comma-separated sync options: all, sqls, uploads, plugins, themes, contents'
),
coerce: ( val: string | undefined ) =>
val !== undefined ? parseSyncOptions( val ) : undefined,
} )
.option( 'remote-site', {
type: 'string',
description: __( 'Remote site URL or ID' ),
} );
},
handler: async ( argv ) => {
try {
await runCommand( argv.path, argv.options as SyncOption[] | undefined, argv.remoteSite );
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
const loggerError = new LoggerError( __( 'Pull failed' ), error );
logger.reportError( loggerError );
}
}
},
} );
};
Loading