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

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions apps/cli/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ const logger = new Logger< LoggerAction >();
export async function runCommand(
siteFolder: string,
syncOptions?: SyncOption[],
siteIdentifier?: string
siteIdentifier?: string,
syncIncludePathList?: string[]
): Promise< void > {
let site: SiteData | undefined;
let wasServerRunning = false;
Expand Down Expand Up @@ -86,6 +87,7 @@ export async function runCommand(

if ( syncOptions ) {
optionsToSync = syncOptions;
includePathList = syncIncludePathList;
} else {
logger.reportStart( LoggerAction.FETCH_REMOTE_SITES, __( 'Fetching file tree…' ) );
const { tree } = await fetchPullTree( token.accessToken, remoteSite.id );
Expand Down Expand Up @@ -253,11 +255,30 @@ export const registerCommand = ( yargs: StudioArgv ) => {
.option( 'remote-site', {
type: 'string',
description: __( 'Remote site URL or ID' ),
} )
.option( 'include-path-list', {
type: 'string',
description: __(
'Comma-separated backup node ids to pull when using the "paths" option'
),
hidden: true,
coerce: ( val: string | undefined ) =>
val !== undefined
? val
.split( ',' )
.map( ( item ) => item.trim() )
.filter( Boolean )
: undefined,
} );
},
handler: async ( argv ) => {
try {
await runCommand( argv.path, argv.options as SyncOption[] | undefined, argv.remoteSite );
await runCommand(
argv.path,
argv.options as SyncOption[] | undefined,
argv.remoteSite,
argv.includePathList as string[] | undefined
);
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
Expand Down
113 changes: 102 additions & 11 deletions apps/local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,11 @@ import {
updateSharedSession,
} from '@studio/common/lib/shared-config';
import { fetchStudioAssistantQuota } from '@studio/common/lib/studio-assistant-quota';
import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
import { fetchLatestRewindId, fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
import { detectInstalledApps } from '@studio/common/lib/user-settings/installed-apps';
import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils';
import wpcomFactory from '@studio/common/lib/wpcom-factory';
import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory';
import {
cleanupBlueprintTempDir,
extractBlueprintUpload,
Expand All @@ -68,10 +70,11 @@ import { createSnapshotManager, fetchSnapshots } from '@studio/common/sites/snap
import { pullSite, pushSite } from '@studio/common/sites/sync';
import express from 'express';
import { rateLimit } from 'express-rate-limit';
import { z } from 'zod';
import { isEditor, isTerminal, openInEditor, openInTerminal, openPath } from './open-in-os';
import type { SiteListItem } from '@studio/common/lib/cli-events';
import type { EditSiteOptions } from '@studio/common/sites/edit';
import type { SyncSite } from '@studio/common/types/sync';
import type { PullSyncOptions, PushSyncOptions, SyncSite } from '@studio/common/types/sync';
import type { Request, Response } from 'express';

/**
Expand Down Expand Up @@ -1075,7 +1078,10 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
api.post(
'/sites/:id/pull',
asyncHandler( async ( req: Request, res: Response ) => {
const { remoteSiteId } = req.body as { remoteSiteId?: number };
const { remoteSiteId, options } = req.body as {
remoteSiteId?: number;
options?: PullSyncOptions;
};
if ( ! remoteSiteId ) {
res.status( 400 ).json( { error: 'remoteSiteId is required' } );
return;
Expand All @@ -1085,23 +1091,108 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } );
return;
}
await pullSite( execute, site.path, remoteSiteId, ( progress ) => {
sseSend( {
channel: 'sync-pull',
payload: { ...progress, siteId: req.params.id, remoteSiteId },
} );
} );
await pullSite(
execute,
site.path,
remoteSiteId,
( progress ) => {
sseSend( {
channel: 'sync-pull',
payload: { ...progress, siteId: req.params.id, remoteSiteId },
} );
},
options
);
res.sendStatus( 204 );
} )
);

// Selective-sync lookups for the agentic UI's sync dialog: the latest
// backup (rewind) id, the remote backup file tree under it, and the live
// site's hosting PHP version.
api.get(
'/wpcom/sites/:remoteSiteId/latest-rewind-id',
asyncHandler( async ( req: Request, res: Response ) => {
const token = await readAuthToken();
if ( ! token?.accessToken ) {
res.status( 401 ).json( { error: 'Authentication required.' } );
return;
}
try {
res.json(
await fetchLatestRewindId( token.accessToken, Number( req.params.remoteSiteId ) )
);
} catch {
res.json( null );
}
} )
);

api.get(
'/wpcom/sites/:remoteSiteId/remote-file-tree',
asyncHandler( async ( req: Request, res: Response ) => {
const token = await readAuthToken();
if ( ! token?.accessToken ) {
res.status( 401 ).json( { error: 'Authentication required.' } );
return;
}
const { rewindId, path: treePath } = req.query as { rewindId?: string; path?: string };
if ( ! rewindId || ! treePath ) {
res.status( 400 ).json( { error: 'rewindId and path are required' } );
return;
}
const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest );
const rawResponse = await wpcom.req.post( {
path: `/sites/${ Number( req.params.remoteSiteId ) }/rewind/backup/ls`,
apiNamespace: 'wpcom/v2',
body: { backup_id: rewindId, path: treePath },
} );
const parsed = z
.object( {
ok: z.boolean(),
error: z.string().optional(),
contents: z.record( z.string(), z.unknown() ).optional(),
} )
.parse( rawResponse );
if ( ! parsed.ok ) {
res.status( 502 ).json( { error: parsed.error || 'Failed to fetch remote file tree' } );
return;
}
res.json( parsed.contents ?? {} );
} )
);

api.get(
'/wpcom/sites/:remoteSiteId/hosting-php-version',
asyncHandler( async ( req: Request, res: Response ) => {
const token = await readAuthToken();
if ( ! token?.accessToken ) {
res.status( 401 ).json( { error: 'Authentication required.' } );
return;
}
try {
const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest );
const response = await wpcom.req.get( {
apiNamespace: 'wpcom/v2',
path: `/sites/${ Number( req.params.remoteSiteId ) }/hosting/php-version`,
} );
res.json( z.string().parse( response ) );
} catch {
res.json( null );
}
} )
);

// Push the local site to its connected WordPress.com live site. Long-running
// (export → upload → import); progress streams on the SSE `sync` channel.
// Resolves once the import is initiated.
api.post(
'/sites/:id/push',
asyncHandler( async ( req: Request, res: Response ) => {
const { remoteSiteId } = req.body as { remoteSiteId?: number };
const { remoteSiteId, options } = req.body as {
remoteSiteId?: number;
options?: PushSyncOptions;
};
if ( ! remoteSiteId ) {
res.status( 400 ).json( { error: 'remoteSiteId is required' } );
return;
Expand All @@ -1126,7 +1217,7 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
payload: { ...output, siteId: req.params.id, remoteSiteId },
} ),
},
{ sitePath: site.path, remoteSiteId }
{ sitePath: site.path, remoteSiteId, options }
);
res.sendStatus( 204 );
} )
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ export {
exportSiteForPush,
fetchSyncableWpcomSites,
getConnectedWpcomSites,
getHostingPhpVersion,
getLatestRewindId,
listRemoteFileTree,
pauseSyncUpload,
pullSiteFromLive,
pushArchive,
Expand Down
108 changes: 97 additions & 11 deletions apps/studio/src/modules/sync/lib/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ import {
} from '@studio/common/lib/connected-sites';
import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import { getCurrentUserId } from '@studio/common/lib/shared-config';
import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
import { fetchLatestRewindId, fetchSyncableSites } from '@studio/common/lib/sync/sync-api';
import { shouldRetryTusStatus } from '@studio/common/lib/sync/tus-upload';
import wpcomFactory from '@studio/common/lib/wpcom-factory';
import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory';
import { pullSite, pushSite } from '@studio/common/sites/sync';
import { SyncSite } from '@studio/common/types/sync';
import { PullSyncOptions, PushSyncOptions, SyncSite } from '@studio/common/types/sync';
import { __, sprintf } from '@wordpress/i18n';
import { Upload } from 'tus-js-client';
import { z } from 'zod';
Expand Down Expand Up @@ -529,19 +529,26 @@ export async function updateConnectedWpcomSites(
export async function pullSiteFromLive(
event: IpcMainInvokeEvent,
siteId: string,
remoteSiteId: number
remoteSiteId: number,
options?: PullSyncOptions
): Promise< void > {
const site = SiteServer.get( siteId );
if ( ! site ) {
throw new Error( 'Site not found.' );
}
const window = BrowserWindow.fromWebContents( event.sender );
return pullSite( executeCliCommand, site.details.path, remoteSiteId, ( progress ) => {
sendIpcEventToRendererWithWindow( window, 'sync-pull-progress', {
siteId,
...progress,
} );
} );
return pullSite(
executeCliCommand,
site.details.path,
remoteSiteId,
( progress ) => {
sendIpcEventToRendererWithWindow( window, 'sync-pull-progress', {
siteId,
...progress,
} );
},
options
);
}

// Push for the agentic UI (apps/ui): the same shared `pushSite` the `studio ui`
Expand All @@ -552,7 +559,8 @@ export async function pullSiteFromLive(
export async function pushSiteToLive(
_event: IpcMainInvokeEvent,
selectedSiteId: string,
remoteSiteId: number
remoteSiteId: number,
options?: PushSyncOptions
): Promise< void > {
const site = SiteServer.get( selectedSiteId );
if ( ! site ) {
Expand Down Expand Up @@ -587,7 +595,7 @@ export async function pushSiteToLive(
}
},
},
{ sitePath: site.details.path, remoteSiteId }
{ sitePath: site.details.path, remoteSiteId, options }
);
}

Expand All @@ -612,3 +620,81 @@ export async function getConnectedWpcomSites(
}
return getAllConnectedWpcomSitesForCurrentUserShared();
}

/**
* Latest rewind (backup) id for a remote site — used by the agentic UI's
* selective pull to browse the remote backup file tree. Returns `null` when
* the site has no backup yet.
*/
export async function getLatestRewindId(
_event: IpcMainInvokeEvent,
remoteSiteId: number
): Promise< string | null > {
const token = await getAuthenticationToken();
if ( ! token?.accessToken ) {
throw new Error( 'No token found' );
}
try {
return await fetchLatestRewindId( token.accessToken, remoteSiteId );
} catch {
return null;
}
}

/**
* Raw contents of a remote backup directory (rewind backup `ls`), keyed by
* entry name. The renderer maps entries to tree nodes; returning the raw
* items preserves `has_children`/`type` used for plugin/theme classification.
*/
export async function listRemoteFileTree(
_event: IpcMainInvokeEvent,
remoteSiteId: number,
rewindId: string,
treePath: string
): Promise< Record< string, unknown > > {
const token = await getAuthenticationToken();
if ( ! token?.accessToken ) {
throw new Error( 'No token found' );
}
const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest );
const rawResponse = await wpcom.req.post( {
path: `/sites/${ remoteSiteId }/rewind/backup/ls`,
apiNamespace: 'wpcom/v2',
body: { backup_id: rewindId, path: treePath },
} );
const parsed = z
.object( {
ok: z.boolean(),
error: z.string().optional(),
contents: z.record( z.string(), z.unknown() ).optional(),
} )
.parse( rawResponse );
if ( ! parsed.ok ) {
throw new Error( parsed.error || 'Failed to fetch remote file tree' );
}
return parsed.contents ?? {};
}

/**
* PHP version of the remote site's hosting environment — used by the agentic
* UI's sync dialog to warn about version mismatches before pushing.
*/
export async function getHostingPhpVersion(
_event: IpcMainInvokeEvent,
remoteSiteId: number
): Promise< string | undefined > {
const token = await getAuthenticationToken();
if ( ! token?.accessToken ) {
throw new Error( 'No token found' );
}
try {
const wpcom = wpcomFactory( token.accessToken, wpcomXhrRequest );
const response = await wpcom.req.get( {
apiNamespace: 'wpcom/v2',
path: `/sites/${ remoteSiteId }/hosting/php-version`,
} );
return z.string().parse( response );
} catch {
return undefined;
}
}
13 changes: 9 additions & 4 deletions apps/studio/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ const api: IpcApi = {
optionsToSync,
specificSelectionPaths
),
pushSiteToLive: ( selectedSiteId, remoteSiteId ) =>
ipcRendererInvoke( 'pushSiteToLive', selectedSiteId, remoteSiteId ),
pushSiteToLive: ( selectedSiteId, remoteSiteId, options ) =>
ipcRendererInvoke( 'pushSiteToLive', selectedSiteId, remoteSiteId, options ),
deleteSite: ( id, deleteFiles ) => ipcRendererInvoke( 'deleteSite', id, deleteFiles ),
copySite: ( sourceSiteId, newSiteId, siteName ) =>
ipcRendererInvoke( 'copySite', sourceSiteId, newSiteId, siteName ),
Expand Down Expand Up @@ -144,8 +144,13 @@ const api: IpcApi = {
getConnectedWpcomSites: ( localSiteId ) =>
ipcRendererInvoke( 'getConnectedWpcomSites', localSiteId ),
fetchSyncableWpcomSites: () => ipcRendererInvoke( 'fetchSyncableWpcomSites' ),
pullSiteFromLive: ( siteId, remoteSiteId ) =>
ipcRendererInvoke( 'pullSiteFromLive', siteId, remoteSiteId ),
getHostingPhpVersion: ( remoteSiteId ) =>
ipcRendererInvoke( 'getHostingPhpVersion', remoteSiteId ),
getLatestRewindId: ( remoteSiteId ) => ipcRendererInvoke( 'getLatestRewindId', remoteSiteId ),
listRemoteFileTree: ( remoteSiteId, rewindId, treePath ) =>
ipcRendererInvoke( 'listRemoteFileTree', remoteSiteId, rewindId, treePath ),
pullSiteFromLive: ( siteId, remoteSiteId, options ) =>
ipcRendererInvoke( 'pullSiteFromLive', siteId, remoteSiteId, options ),
addSyncOperation: ( id, status ) => ipcRendererSend( 'addSyncOperation', id, status ),
clearSyncOperation: ( id ) => ipcRendererSend( 'clearSyncOperation', id ),
cancelSyncOperation: ( id ) => ipcRendererSend( 'cancelSyncOperation', id ),
Expand Down
Loading
Loading