From e60b66b0426d6b05389eea46ea754d25b41959aa Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 29 Jun 2026 12:40:35 +0200 Subject: [PATCH 1/4] Extract site operations (create, edit, snapshots, sync, blueprint) to @studio/common Moves the create/edit/snapshots/sync/blueprint-extract logic into transport-agnostic @studio/common/sites modules. The desktop's site-server, cli-site-creator/editor, and the preview + sync IPC handlers now delegate to them (the snapshot/preview runner is replaced by the shared snapshot manager over cli-process, retiring execute-preview-command). No behavior change; this is the shared backend the studio ui local server will reuse. Part of splitting #3953. Co-Authored-By: Claude Opus 4.8 --- apps/studio/src/ipc-handlers.ts | 45 +----- .../src/modules/cli/lib/cli-site-creator.ts | 109 +------------ .../src/modules/cli/lib/cli-site-editor.ts | 83 +--------- .../cli/lib/execute-preview-command.ts | 74 --------- .../modules/preview-site/lib/ipc-handlers.ts | 122 +++++++------- .../src/modules/sync/lib/ipc-handlers.ts | 59 +++++-- apps/studio/src/preload.ts | 2 + apps/studio/src/site-server.ts | 30 +--- tools/common/sites/blueprint-extract.ts | 53 ++++++ tools/common/sites/create.ts | 102 ++++++++++++ tools/common/sites/edit.ts | 75 +++++++++ tools/common/sites/index.ts | 73 +++++++++ tools/common/sites/snapshots.ts | 151 ++++++++++++++++++ tools/common/sites/sync.ts | 97 +++++++++++ 14 files changed, 688 insertions(+), 387 deletions(-) delete mode 100644 apps/studio/src/modules/cli/lib/execute-preview-command.ts create mode 100644 tools/common/sites/blueprint-extract.ts create mode 100644 tools/common/sites/create.ts create mode 100644 tools/common/sites/edit.ts create mode 100644 tools/common/sites/index.ts create mode 100644 tools/common/sites/snapshots.ts create mode 100644 tools/common/sites/sync.ts diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 5e6bb463bc..475eea36a6 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -44,7 +44,6 @@ import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error'; import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites'; import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore'; -import { extractZip } from '@studio/common/lib/extract-zip'; import { calculateDirectorySizeForArchive, isWordPressDirectory, @@ -84,6 +83,10 @@ import { SYNC_IGNORE_DEFAULTS } from '@studio/common/lib/sync/constants'; import { shouldExcludeFromSync } from '@studio/common/lib/sync/exclude-from-sync'; import { shouldLimitDepth } from '@studio/common/lib/sync/tree-utils'; import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; +import { + cleanupBlueprintTempDir as cleanupBlueprintTempDirShared, + extractBlueprintBundle as extractBlueprintBundleShared, +} from '@studio/common/sites/blueprint-extract'; import { __, sprintf, LocaleData, defaultI18n } from '@wordpress/i18n'; import { MACOS_TRAFFIC_LIGHT_POSITION, @@ -203,6 +206,7 @@ export { pauseSyncUpload, pullSiteFromLive, pushArchive, + pushSiteToLive, removeSyncBackup, resumeSyncUpload, updateConnectedWpcomSites, @@ -2278,48 +2282,15 @@ export async function readBlueprintFile( return JSON.parse( fileContents ); } -export async function extractBlueprintBundle( - _event: IpcMainInvokeEvent, - zipFilePath: string -): Promise< { - blueprintJson: Blueprint[ 'blueprint' ]; - blueprintJsonPath: string; - tempDir: string; -} > { - const resolvedZipPath = nodePath.resolve( zipFilePath ); - const tempDir = await fsPromises.mkdtemp( - nodePath.join( os.tmpdir(), 'studio-blueprint-bundle-' ) - ); - - try { - await extractZip( resolvedZipPath, tempDir ); - - const blueprintJsonPath = nodePath.join( tempDir, 'blueprint.json' ); - try { - await fsPromises.access( blueprintJsonPath ); - } catch { - throw new Error( - __( - 'No blueprint.json found in the ZIP file. Please ensure the ZIP contains a blueprint.json at its root.' - ) - ); - } - - const fileContents = await fsPromises.readFile( blueprintJsonPath, 'utf-8' ); - const blueprintJson = JSON.parse( fileContents ); - - return { blueprintJson, blueprintJsonPath, tempDir }; - } catch ( error ) { - await fsPromises.rm( tempDir, { recursive: true, force: true } ); - throw error; - } +export async function extractBlueprintBundle( _event: IpcMainInvokeEvent, zipFilePath: string ) { + return extractBlueprintBundleShared( zipFilePath ); } export async function cleanupBlueprintTempDir( _event: IpcMainInvokeEvent, tempDir: string ): Promise< void > { - await removeBlueprintTempDir( tempDir ); + await cleanupBlueprintTempDirShared( tempDir ); } export async function setWindowControlVisibility( event: IpcMainInvokeEvent, visible: boolean ) { diff --git a/apps/studio/src/modules/cli/lib/cli-site-creator.ts b/apps/studio/src/modules/cli/lib/cli-site-creator.ts index fb5fae3274..52543a4768 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-creator.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-creator.ts @@ -1,14 +1,8 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; -import { siteModeFromRuntime, type SiteRuntime } from '@studio/common/lib/site-runtime'; -import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; import { SiteCommandLoggerAction } from '@studio/common/logger-actions'; +import { buildSiteCreateArgs, type SiteCreateOptions } from '@studio/common/sites/create'; import { z } from 'zod'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { executeCliCommand } from './execute-command'; -import type { Blueprint } from '@wp-playground/blueprints'; const cliEventSchema = z.discriminatedUnion( 'action', [ z.object( { @@ -29,38 +23,12 @@ interface CreateSiteResult { running: boolean; } -export interface CreateSiteOptions { - path: string; - name?: string; - wpVersion?: string; - phpVersion?: string; - runtime?: SiteRuntime; - fileAccess?: SiteFileAccess; - customDomain?: string; - enableHttps?: boolean; - siteId?: string; - blueprint?: Blueprint; - originalBlueprintPath?: string; - adminUsername?: string; - adminPassword?: string; - adminEmail?: string; - noStart?: boolean; -} +export type CreateSiteOptions = SiteCreateOptions; export async function createSiteViaCli( options: CreateSiteOptions ): Promise< CreateSiteResult > { - const args = buildCliArgs( options ); + const { args, cleanup } = buildSiteCreateArgs( options ); const siteId = options.siteId; - let blueprintTempPath: string | undefined; - if ( options.blueprint ) { - blueprintTempPath = path.join( os.tmpdir(), `studio-blueprint-${ Date.now() }.json` ); - fs.writeFileSync( blueprintTempPath, JSON.stringify( options.blueprint ) ); - args.push( '--blueprint', blueprintTempPath ); - if ( options.originalBlueprintPath ) { - args.push( '--original-blueprint-path', options.originalBlueprintPath ); - } - } - return new Promise( ( resolve, reject ) => { const result: Partial< CreateSiteResult > = {}; const [ emitter ] = executeCliCommand( args, { output: 'capture', logPrefix: siteId } ); @@ -97,7 +65,7 @@ export async function createSiteViaCli( options: CreateSiteOptions ): Promise< C } ); emitter.on( 'success', () => { - cleanupTempFile( blueprintTempPath ); + cleanup(); if ( ! result.id ) { reject( new Error( 'CLI create site succeeded but no site ID received' ) ); return; @@ -110,79 +78,14 @@ export async function createSiteViaCli( options: CreateSiteOptions ): Promise< C } ); emitter.on( 'failure', ( { error } ) => { - cleanupTempFile( blueprintTempPath ); + cleanup(); error.baseMessage = 'Failed to create site'; reject( error ); } ); emitter.on( 'error', ( { error } ) => { - cleanupTempFile( blueprintTempPath ); + cleanup(); reject( error ); } ); } ); } - -function buildCliArgs( options: CreateSiteOptions ): string[] { - const args = [ 'site', 'create', '--path', options.path, '--skip-browser', '--skip-log-details' ]; - - if ( options.siteId ) { - args.push( '--id', options.siteId ); - } - - if ( options.name ) { - args.push( '--name', options.name ); - } - - if ( options.wpVersion ) { - const wp = isWordPressDevVersion( options.wpVersion ) ? 'nightly' : options.wpVersion; - args.push( '--wp', wp ); - } - - if ( options.phpVersion ) { - args.push( '--php', options.phpVersion ); - } - - if ( options.runtime ) { - args.push( '--runtime', siteModeFromRuntime( options.runtime ) ); - } - - if ( options.fileAccess ) { - args.push( '--file-access', options.fileAccess ); - } - - if ( options.customDomain ) { - args.push( '--domain', options.customDomain ); - } - - if ( options.enableHttps ) { - args.push( '--https' ); - } - - if ( options.adminUsername ) { - args.push( '--admin-username', options.adminUsername ); - } - - if ( options.adminPassword ) { - args.push( '--admin-password', options.adminPassword ); - } - - if ( options.adminEmail ) { - args.push( '--admin-email', options.adminEmail ); - } - - if ( options.noStart ) { - args.push( '--no-start' ); - } - - return args; -} - -function cleanupTempFile( filePath: string | undefined ): void { - if ( filePath && fs.existsSync( filePath ) ) { - try { - fs.unlinkSync( filePath ); - } catch ( error ) { - console.error( 'Failed to clean up temp Blueprint file:', error ); - } - } -} diff --git a/apps/studio/src/modules/cli/lib/cli-site-editor.ts b/apps/studio/src/modules/cli/lib/cli-site-editor.ts index 9649def25d..84f98760c9 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-editor.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-editor.ts @@ -1,35 +1,18 @@ -import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; -import { type SiteMode } from '@studio/common/lib/site-runtime'; import { SiteCommandLoggerAction } from '@studio/common/logger-actions'; +import { buildSiteSetArgs, type EditSiteOptions } from '@studio/common/sites/edit'; import { z } from 'zod'; import { executeCliCommand } from './execute-command'; +export type { EditSiteOptions }; + const cliEventSchema = z.object( { action: z.enum( SiteCommandLoggerAction ), status: z.enum( [ 'inprogress', 'fail', 'success', 'warning' ] ), message: z.string(), } ); -export interface EditSiteOptions { - path: string; - siteId: string; - name?: string; - domain?: string; - https?: boolean; - php?: string; - wp?: string; - runtime?: SiteMode; - fileAccess?: SiteFileAccess; - xdebug?: boolean; - adminUsername?: string; - adminPassword?: string; - adminEmail?: string; - debugLog?: boolean; - debugDisplay?: boolean; -} - export async function editSiteViaCli( options: EditSiteOptions ): Promise< void > { - const args = buildCliArgs( options ); + const args = buildSiteSetArgs( options ); console.log( `[CLI Site Editor] Executing: studio ${ args.join( ' ' ) }` ); return new Promise( ( resolve, reject ) => { @@ -60,61 +43,3 @@ export async function editSiteViaCli( options: EditSiteOptions ): Promise< void } ); } ); } - -function buildCliArgs( options: EditSiteOptions ): string[] { - const args = [ 'site', 'set', '--path', options.path ]; - - if ( options.name !== undefined ) { - args.push( '--name', options.name ); - } - - if ( options.domain !== undefined ) { - args.push( '--domain', options.domain ); - } - - if ( options.https !== undefined ) { - args.push( options.https ? '--https' : '--no-https' ); - } - - if ( options.php !== undefined ) { - args.push( '--php', options.php ); - } - - if ( options.wp !== undefined ) { - args.push( '--wp', options.wp ); - } - - if ( options.runtime !== undefined ) { - args.push( '--runtime', options.runtime ); - } - - if ( options.fileAccess !== undefined ) { - args.push( '--file-access', options.fileAccess ); - } - - if ( options.xdebug !== undefined ) { - args.push( options.xdebug ? '--xdebug' : '--no-xdebug' ); - } - - if ( options.adminUsername !== undefined ) { - args.push( '--admin-username', options.adminUsername ); - } - - if ( options.adminPassword !== undefined ) { - args.push( '--admin-password', options.adminPassword ); - } - - if ( options.adminEmail !== undefined ) { - args.push( '--admin-email', options.adminEmail ); - } - - if ( options.debugLog !== undefined ) { - args.push( options.debugLog ? '--debug-log' : '--no-debug-log' ); - } - - if ( options.debugDisplay !== undefined ) { - args.push( options.debugDisplay ? '--debug-display' : '--no-debug-display' ); - } - - return args; -} diff --git a/apps/studio/src/modules/cli/lib/execute-preview-command.ts b/apps/studio/src/modules/cli/lib/execute-preview-command.ts deleted file mode 100644 index e210f24cc3..0000000000 --- a/apps/studio/src/modules/cli/lib/execute-preview-command.ts +++ /dev/null @@ -1,74 +0,0 @@ -import crypto from 'crypto'; -import { PreviewCommandLoggerAction } from '@studio/common/logger-actions'; -import { z } from 'zod'; -import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; -import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; - -const snapshotEventSchema = z.discriminatedUnion( 'action', [ - z.object( { - action: z.enum( PreviewCommandLoggerAction ), - status: z.enum( [ 'inprogress', 'fail', 'success' ] ), - message: z.string(), - } ), - z.object( { - action: z.literal( 'keyValuePair' ), - key: z.string(), - value: z.string(), - } ), -] ); - -export async function executePreviewCliCommand( - args: string[], - parentWindow: Electron.BrowserWindow | null -): Promise< { operationId: crypto.UUID } > { - const operationId = crypto.randomUUID(); - const [ cliEventEmitter ] = executeCliCommand( args, { output: 'capture' } ); - - cliEventEmitter.on( 'data', ( { data } ) => { - const parsed = snapshotEventSchema.safeParse( data ); - - if ( ! parsed.success ) { - console.error( 'Invalid snapshot event:', parsed.error ); - return; - } - - if ( parsed.data.action === 'keyValuePair' ) { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-key-value', { - operationId, - data: parsed.data, - } ); - } else if ( parsed.data.status === 'fail' ) { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-error', { - operationId, - data: parsed.data, - } ); - } else { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-output', { - operationId, - data: parsed.data, - } ); - } - } ); - - cliEventEmitter.on( 'error', ( { error } ) => { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-fatal-error', { - operationId, - data: { message: error.message }, - } ); - } ); - - cliEventEmitter.on( 'failure', ( { error } ) => { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-fatal-error', { - operationId, - data: { message: error.message }, - } ); - } ); - - cliEventEmitter.on( 'success', () => { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-success', { - operationId, - } ); - } ); - - return { operationId }; -} diff --git a/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts b/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts index c6b92147cb..0bedf75c58 100644 --- a/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts @@ -1,41 +1,58 @@ import { BrowserWindow, IpcMainInvokeEvent } from 'electron'; -import { snapshotSchema } from '@studio/common/types/snapshot'; -import { z } from 'zod'; +import { + createSnapshotManager, + fetchSnapshots as fetchSnapshotsFromCli, + type SnapshotManager, + type SnapshotOutput, +} from '@studio/common/sites/snapshots'; +import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; -import { executePreviewCliCommand } from 'src/modules/cli/lib/execute-preview-command'; import type { Snapshot } from '@studio/common/types/snapshot'; -const snapshotListKeyValueSchema = z.object( { - action: z.literal( 'keyValuePair' ), - key: z.literal( 'snapshots' ), - value: z - .string() - .transform( ( val ) => JSON.parse( val ) ) - .pipe( z.array( snapshotSchema ) ), -} ); +// Desktop binding for the shared snapshot manager: forwards the CLI's progress +// to the originating renderer over the existing `snapshot-*` IPC channels. The +// `studio ui` server wires the same manager to SSE instead. +function snapshotManagerForWindow( window: BrowserWindow | null ): SnapshotManager { + return createSnapshotManager( { + executeCliCommand, + emit: ( output: SnapshotOutput ) => { + switch ( output.kind ) { + case 'output': + sendIpcEventToRendererWithWindow( window, 'snapshot-output', { + operationId: output.operationId, + data: output.data, + } ); + break; + case 'key-value': + sendIpcEventToRendererWithWindow( window, 'snapshot-key-value', { + operationId: output.operationId, + data: output.data, + } ); + break; + case 'error': + sendIpcEventToRendererWithWindow( window, 'snapshot-error', { + operationId: output.operationId, + data: output.data, + } ); + break; + case 'fatal-error': + sendIpcEventToRendererWithWindow( window, 'snapshot-fatal-error', { + operationId: output.operationId, + data: output.data, + } ); + break; + case 'success': + sendIpcEventToRendererWithWindow( window, 'snapshot-success', { + operationId: output.operationId, + } ); + break; + } + }, + } ); +} export async function fetchSnapshots(): Promise< Snapshot[] > { - try { - return await new Promise< Snapshot[] >( ( resolve, reject ) => { - const [ emitter ] = executeCliCommand( [ 'preview', 'list', '--format', 'json' ], { - output: 'capture', - } ); - - emitter.on( 'data', ( { data } ) => { - const parsed = snapshotListKeyValueSchema.safeParse( data ); - if ( parsed.success ) { - resolve( parsed.data.value ); - } - } ); - - emitter.on( 'success', () => resolve( [] ) ); - emitter.on( 'failure', ( { error } ) => reject( error ) ); - emitter.on( 'error', ( { error } ) => reject( error ) ); - } ); - } catch ( error ) { - console.error( 'Failed to fetch snapshots from CLI:', error ); - return []; - } + return fetchSnapshotsFromCli( executeCliCommand ); } export async function createSnapshot( @@ -43,12 +60,8 @@ export async function createSnapshot( siteFolder: string, name?: string ) { - const parentWindow = BrowserWindow.fromWebContents( event.sender ); - const args = [ 'preview', 'create', '--path', siteFolder ]; - if ( name ) { - args.push( '--name', name ); - } - return executePreviewCliCommand( args, parentWindow ); + const window = BrowserWindow.fromWebContents( event.sender ); + return snapshotManagerForWindow( window ).createSnapshot( siteFolder, name ); } export async function updateSnapshot( @@ -56,16 +69,22 @@ export async function updateSnapshot( siteFolder: string, hostname: string ) { - const parentWindow = BrowserWindow.fromWebContents( event.sender ); - return executePreviewCliCommand( - [ 'preview', 'update', '--path', siteFolder, hostname ], - parentWindow - ); + const window = BrowserWindow.fromWebContents( event.sender ); + return snapshotManagerForWindow( window ).updateSnapshot( siteFolder, hostname ); } export async function deleteSnapshot( event: IpcMainInvokeEvent, hostname: string ) { - const parentWindow = BrowserWindow.fromWebContents( event.sender ); - return executePreviewCliCommand( [ 'preview', 'delete', hostname ], parentWindow ); + const window = BrowserWindow.fromWebContents( event.sender ); + return snapshotManagerForWindow( window ).deleteSnapshot( hostname ); +} + +export async function setSnapshot( + event: IpcMainInvokeEvent, + hostname: string, + options: { name?: string } +) { + const window = BrowserWindow.fromWebContents( event.sender ); + return snapshotManagerForWindow( window ).setSnapshot( hostname, options ); } export async function deleteAllSnapshots() { @@ -76,16 +95,3 @@ export async function deleteAllSnapshots() { cliEventEmitter.on( 'success', () => resolve() ); } ); } - -export async function setSnapshot( - event: IpcMainInvokeEvent, - hostname: string, - options: { name?: string } -) { - const parentWindow = BrowserWindow.fromWebContents( event.sender ); - const args = [ 'preview', 'set', hostname ]; - if ( options.name !== undefined ) { - args.push( '--name', options.name ); - } - return executePreviewCliCommand( args, parentWindow ); -} diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index f33ff10efa..bf0180a5e2 100644 --- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts @@ -15,6 +15,7 @@ import { getCurrentUserId } from '@studio/common/lib/shared-config'; import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api'; 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 { Upload } from 'tus-js-client'; import { z } from 'zod'; @@ -503,16 +504,54 @@ export async function pullSiteFromLive( siteFolder: string, remoteSiteId: number ): Promise< void > { - return new Promise< void >( ( resolve, reject ) => { - const [ emitter ] = executeCliCommand( - [ 'pull', '--path', siteFolder, '--remote-site', String( remoteSiteId ), '--options', 'all' ], - { output: 'capture' } - ); - - emitter.on( 'success', () => resolve() ); - emitter.on( 'failure', ( { error } ) => reject( error ) ); - emitter.on( 'error', ( { error } ) => reject( error ) ); - } ); + return pullSite( executeCliCommand, siteFolder, remoteSiteId ); +} + +// Push for the agentic UI (apps/ui): the same shared `pushSite` the `studio ui` +// server uses, so the agentic UI pushes identically in the desktop and the +// browser (export → TUS upload → import). Progress is forwarded over the +// existing `sync-upload-*` channels. The legacy renderer keeps its own +// `exportSiteForPush` + `pushArchive` (with manual pause/resume) untouched. +export async function pushSiteToLive( + _event: IpcMainInvokeEvent, + selectedSiteId: string, + remoteSiteId: number +): Promise< void > { + const site = SiteServer.get( selectedSiteId ); + if ( ! site ) { + throw new Error( 'Site not found.' ); + } + const token = await getAuthenticationToken(); + if ( ! token?.accessToken ) { + throw new Error( 'No token found' ); + } + await pushSite( + { + executeCliCommand, + accessToken: token.accessToken, + emit: ( output ) => { + if ( output.kind === 'upload-progress' ) { + void sendIpcEventToRenderer( 'sync-upload-progress', { + selectedSiteId, + remoteSiteId, + progress: output.progress, + } ); + } else if ( output.kind === 'network-paused' ) { + void sendIpcEventToRenderer( 'sync-upload-network-paused', { + selectedSiteId, + remoteSiteId, + error: output.error, + } ); + } else if ( output.kind === 'resumed' ) { + void sendIpcEventToRenderer( 'sync-upload-resumed', { + selectedSiteId, + remoteSiteId, + } ); + } + }, + }, + { sitePath: site.details.path, remoteSiteId } + ); } // Fetches every WordPress.com site the authenticated user can sync to. diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index a475b9d794..68d83bbbf9 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -38,6 +38,8 @@ const api: IpcApi = { optionsToSync, specificSelectionPaths ), + pushSiteToLive: ( selectedSiteId, remoteSiteId ) => + ipcRendererInvoke( 'pushSiteToLive', selectedSiteId, remoteSiteId ), deleteSite: ( id, deleteFiles ) => ipcRendererInvoke( 'deleteSite', id, deleteFiles ), copySite: ( sourceSiteId, newSiteId, siteName ) => ipcRendererInvoke( 'copySite', sourceSiteId, newSiteId, siteName ), diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 75a393530e..5f396bf883 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -2,9 +2,9 @@ import fs from 'fs'; import nodePath from 'path'; import * as Sentry from '@sentry/electron/main'; import { SQLITE_FILENAME } from '@studio/common/constants'; -import { siteListSchema, type SiteListItem } from '@studio/common/lib/cli-events'; import { parseJsonFromPhpOutput } from '@studio/common/lib/php-output-parser'; import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; +import { listSites } from '@studio/common/sites'; import fsExtra from 'fs-extra'; import { parse } from 'shell-quote'; import { z } from 'zod'; @@ -137,33 +137,11 @@ export class SiteServer { return deletedServers.includes( id ); } - private static siteListKeyValueSchema = z.object( { - action: z.literal( 'keyValuePair' ), - key: z.literal( 'sites' ), - value: z - .string() - .transform( ( val ) => JSON.parse( val ) ) - .pipe( siteListSchema ), - } ); - static async fetchAll(): Promise< void > { try { - const sites = await new Promise< SiteListItem[] >( ( resolve, reject ) => { - const [ emitter ] = executeCliCommand( [ 'site', 'list', '--format', 'json' ], { - output: 'capture', - } ); - - emitter.on( 'data', ( { data } ) => { - const parsed = SiteServer.siteListKeyValueSchema.safeParse( data ); - if ( parsed.success ) { - resolve( parsed.data.value ); - } - } ); - - emitter.on( 'success', () => resolve( [] ) ); - emitter.on( 'failure', ( { error } ) => reject( error ) ); - emitter.on( 'error', ( { error } ) => reject( error ) ); - } ); + // Same shared site-listing the `studio ui` server uses; it forks the CLI + // through the desktop's `executeCliCommand` so existing mocks still apply. + const sites = await listSites( executeCliCommand ); for ( const site of sites ) { if ( ! SiteServer.get( site.id ) ) { diff --git a/tools/common/sites/blueprint-extract.ts b/tools/common/sites/blueprint-extract.ts new file mode 100644 index 0000000000..9461a6affd --- /dev/null +++ b/tools/common/sites/blueprint-extract.ts @@ -0,0 +1,53 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { __ } from '@wordpress/i18n'; +import { extractZip } from '@studio/common/lib/extract-zip'; +import type { BlueprintV1Declaration } from '@wp-playground/blueprints'; + +export interface ExtractedBlueprintBundle { + blueprintJson: BlueprintV1Declaration; + blueprintJsonPath: string; + tempDir: string; +} + +/** + * Extract a Blueprint ZIP bundle to a temp directory and return the parsed + * `blueprint.json`. Shared between the desktop app and the local web server so + * both handle uploaded Blueprint bundles identically. The caller is responsible + * for cleaning up `tempDir` (via {@link cleanupBlueprintTempDir}) if it doesn't + * go on to consume the extracted bundle. + */ +export async function extractBlueprintBundle( + zipFilePath: string +): Promise< ExtractedBlueprintBundle > { + const resolvedZipPath = path.resolve( zipFilePath ); + const tempDir = await fs.promises.mkdtemp( path.join( os.tmpdir(), 'studio-blueprint-bundle-' ) ); + + try { + await extractZip( resolvedZipPath, tempDir ); + + const blueprintJsonPath = path.join( tempDir, 'blueprint.json' ); + try { + await fs.promises.access( blueprintJsonPath ); + } catch { + throw new Error( + __( + 'No blueprint.json found in the ZIP file. Please ensure the ZIP contains a blueprint.json at its root.' + ) + ); + } + + const fileContents = await fs.promises.readFile( blueprintJsonPath, 'utf-8' ); + const blueprintJson = JSON.parse( fileContents ) as BlueprintV1Declaration; + + return { blueprintJson, blueprintJsonPath, tempDir }; + } catch ( error ) { + await fs.promises.rm( tempDir, { recursive: true, force: true } ); + throw error; + } +} + +export async function cleanupBlueprintTempDir( tempDir: string ): Promise< void > { + await fs.promises.rm( tempDir, { recursive: true, force: true } ); +} diff --git a/tools/common/sites/create.ts b/tools/common/sites/create.ts new file mode 100644 index 0000000000..b8ae06b8d7 --- /dev/null +++ b/tools/common/sites/create.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; +import { siteModeFromRuntime, type SiteRuntime } from '@studio/common/lib/site-runtime'; +import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; +import type { Blueprint } from '@wp-playground/blueprints'; + +export interface SiteCreateOptions { + path: string; + name?: string; + wpVersion?: string; + phpVersion?: string; + runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; + customDomain?: string; + enableHttps?: boolean; + siteId?: string; + // Parsed Blueprint JSON to apply on creation. Written to a temp file and + // passed as --blueprint; `originalBlueprintPath` (an extracted bundle's + // blueprint.json path or a URL) lets the CLI resolve relative assets. + blueprint?: Blueprint; + originalBlueprintPath?: string; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; + noStart?: boolean; +} + +/** + * Build the `site create` CLI args shared by the desktop app and the local web + * server (so both create sites — including from a Blueprint — identically). When + * a blueprint is supplied it's written to a temp file and passed as + * `--blueprint`; call the returned `cleanup` once the CLI command settles to + * remove that temp file. + */ +export function buildSiteCreateArgs( options: SiteCreateOptions ): { + args: string[]; + cleanup: () => void; +} { + const args = [ 'site', 'create', '--path', options.path, '--skip-browser', '--skip-log-details' ]; + + if ( options.siteId ) { + args.push( '--id', options.siteId ); + } + if ( options.name ) { + args.push( '--name', options.name ); + } + if ( options.wpVersion ) { + args.push( '--wp', isWordPressDevVersion( options.wpVersion ) ? 'nightly' : options.wpVersion ); + } + if ( options.phpVersion ) { + args.push( '--php', options.phpVersion ); + } + if ( options.runtime ) { + args.push( '--runtime', siteModeFromRuntime( options.runtime ) ); + } + if ( options.fileAccess ) { + args.push( '--file-access', options.fileAccess ); + } + if ( options.customDomain ) { + args.push( '--domain', options.customDomain ); + } + if ( options.enableHttps ) { + args.push( '--https' ); + } + if ( options.adminUsername ) { + args.push( '--admin-username', options.adminUsername ); + } + if ( options.adminPassword ) { + args.push( '--admin-password', options.adminPassword ); + } + if ( options.adminEmail ) { + args.push( '--admin-email', options.adminEmail ); + } + if ( options.noStart ) { + args.push( '--no-start' ); + } + + let blueprintTempPath: string | undefined; + if ( options.blueprint ) { + blueprintTempPath = path.join( os.tmpdir(), `studio-blueprint-${ Date.now() }.json` ); + fs.writeFileSync( blueprintTempPath, JSON.stringify( options.blueprint ) ); + args.push( '--blueprint', blueprintTempPath ); + if ( options.originalBlueprintPath ) { + args.push( '--original-blueprint-path', options.originalBlueprintPath ); + } + } + + return { + args, + cleanup: () => { + if ( blueprintTempPath && fs.existsSync( blueprintTempPath ) ) { + try { + fs.unlinkSync( blueprintTempPath ); + } catch ( error ) { + console.error( 'Failed to clean up temp Blueprint file:', error ); + } + } + }, + }; +} diff --git a/tools/common/sites/edit.ts b/tools/common/sites/edit.ts new file mode 100644 index 0000000000..4f938ac123 --- /dev/null +++ b/tools/common/sites/edit.ts @@ -0,0 +1,75 @@ +import type { SiteFileAccess } from '@studio/common/lib/site-file-access'; +import type { SiteMode } from '@studio/common/lib/site-runtime'; + +/** + * Options accepted by the CLI `site set` command. Shared so the desktop app + * (via `editSiteViaCli`) and the local web server build identical args — only + * the spawn transport differs. + */ +export interface EditSiteOptions { + path: string; + siteId: string; + name?: string; + domain?: string; + https?: boolean; + php?: string; + wp?: string; + runtime?: SiteMode; + fileAccess?: SiteFileAccess; + xdebug?: boolean; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; + debugLog?: boolean; + debugDisplay?: boolean; +} + +/** + * Build the `site set` CLI args for the given edits. Only defined fields are + * forwarded, so callers pass just what changed. + */ +export function buildSiteSetArgs( options: EditSiteOptions ): string[] { + const args = [ 'site', 'set', '--path', options.path ]; + + if ( options.name !== undefined ) { + args.push( '--name', options.name ); + } + if ( options.domain !== undefined ) { + args.push( '--domain', options.domain ); + } + if ( options.https !== undefined ) { + args.push( options.https ? '--https' : '--no-https' ); + } + if ( options.php !== undefined ) { + args.push( '--php', options.php ); + } + if ( options.wp !== undefined ) { + args.push( '--wp', options.wp ); + } + if ( options.runtime !== undefined ) { + args.push( '--runtime', options.runtime ); + } + if ( options.fileAccess !== undefined ) { + args.push( '--file-access', options.fileAccess ); + } + if ( options.xdebug !== undefined ) { + args.push( options.xdebug ? '--xdebug' : '--no-xdebug' ); + } + if ( options.adminUsername !== undefined ) { + args.push( '--admin-username', options.adminUsername ); + } + if ( options.adminPassword !== undefined ) { + args.push( '--admin-password', options.adminPassword ); + } + if ( options.adminEmail !== undefined ) { + args.push( '--admin-email', options.adminEmail ); + } + if ( options.debugLog !== undefined ) { + args.push( options.debugLog ? '--debug-log' : '--no-debug-log' ); + } + if ( options.debugDisplay !== undefined ) { + args.push( options.debugDisplay ? '--debug-display' : '--no-debug-display' ); + } + + return args; +} diff --git a/tools/common/sites/index.ts b/tools/common/sites/index.ts new file mode 100644 index 0000000000..e2eb73d147 --- /dev/null +++ b/tools/common/sites/index.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { siteListItemSchema, type SiteListItem } from '@studio/common/lib/cli-events'; +import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; + +/** + * Site operations, delegated to the Studio CLI. + * + * This is the shared body of logic the desktop app and the `studio ui` server + * both use: each passes its {@link ExecuteCliCommand} (which knows the CLI + * binary to fork), and the operations here are identical regardless of transport + * (IPC vs REST). Taking the function rather than the whole runner keeps the + * desktop's existing command mocks working. + */ + +// The CLI's `site list --format json` reports the array over its IPC channel as +// a `keyValuePair` ("sites" → JSON string), the same envelope the desktop reads. +const siteListKeyValueSchema = z.object( { + action: z.literal( 'keyValuePair' ), + key: z.literal( 'sites' ), + value: z + .string() + .transform( ( val ) => JSON.parse( val ) as unknown ) + .pipe( z.array( siteListItemSchema ) ), +} ); + +export function listSites( execute: ExecuteCliCommand ): Promise< SiteListItem[] > { + return new Promise( ( resolve, reject ) => { + const [ emitter ] = execute( [ 'site', 'list', '--format', 'json' ], { + output: 'capture', + } ); + + emitter.on( 'data', ( { data } ) => { + const parsed = siteListKeyValueSchema.safeParse( data ); + if ( parsed.success ) { + resolve( parsed.data.value ); + } + } ); + + // No `keyValuePair` arrived before exit — treat as an empty list. + emitter.on( 'success', () => resolve( [] ) ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); +} + +export function startSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const [ emitter ] = execute( + [ 'site', 'start', '--path', sitePath, '--skip-browser', '--skip-log-details' ], + { output: 'capture' } + ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => { + error.baseMessage = 'Failed to start site'; + reject( error ); + } ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); +} + +export function stopSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const [ emitter ] = execute( [ 'site', 'stop', '--path', sitePath ], { + output: 'capture', + } ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => { + error.baseMessage = 'Failed to stop site'; + reject( error ); + } ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); +} diff --git a/tools/common/sites/snapshots.ts b/tools/common/sites/snapshots.ts new file mode 100644 index 0000000000..b12eceb193 --- /dev/null +++ b/tools/common/sites/snapshots.ts @@ -0,0 +1,151 @@ +import crypto from 'node:crypto'; +import { z } from 'zod'; +import { PreviewCommandLoggerAction } from '@studio/common/logger-actions'; +import { snapshotSchema, type Snapshot } from '@studio/common/types/snapshot'; +import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; + +/** + * Preview-site (snapshot) operations, delegated to the Studio CLI. + * + * Shared by the desktop app and the `studio ui` server: each `preview` command + * is forked via the CLI and its progress relayed through a single injected + * `emit` (the only per-host difference) — the desktop forwards it over IPC as + * `snapshot-*` events; the server broadcasts it over SSE. Mirrors the agent + * run-manager's shape. + */ + +type OperationId = ReturnType< typeof crypto.randomUUID >; + +// A progress/log line, or a final key/value (e.g. the preview `url`/`name`), +// matching what the CLI's Logger emits over its IPC channel. +export type SnapshotProgress = { + action: PreviewCommandLoggerAction; + status: 'inprogress' | 'fail' | 'success'; + message: string; +}; +export type SnapshotKeyValue = { action: 'keyValuePair'; key: string; value: string }; + +// Everything a snapshot command produces for the UI, correlated by operationId. +export type SnapshotOutput = + | { kind: 'output'; operationId: OperationId; data: SnapshotProgress } + | { kind: 'key-value'; operationId: OperationId; data: SnapshotKeyValue } + | { kind: 'error'; operationId: OperationId; data: SnapshotProgress } + | { kind: 'fatal-error'; operationId: OperationId; data: { message: string } } + | { kind: 'success'; operationId: OperationId }; + +const snapshotEventSchema = z.discriminatedUnion( 'action', [ + z.object( { + action: z.enum( PreviewCommandLoggerAction ), + status: z.enum( [ 'inprogress', 'fail', 'success' ] ), + message: z.string(), + } ), + z.object( { + action: z.literal( 'keyValuePair' ), + key: z.string(), + value: z.string(), + } ), +] ); + +export interface SnapshotCommandContext { + executeCliCommand: ExecuteCliCommand; + emit: ( output: SnapshotOutput ) => void; +} + +export interface SnapshotManager { + createSnapshot( siteFolder: string, name?: string ): { operationId: OperationId }; + updateSnapshot( siteFolder: string, hostname: string ): { operationId: OperationId }; + deleteSnapshot( hostname: string ): { operationId: OperationId }; + setSnapshot( hostname: string, options: { name?: string } ): { operationId: OperationId }; +} + +export function createSnapshotManager( ctx: SnapshotCommandContext ): SnapshotManager { + // Forks a `preview` subcommand, returns its operationId immediately, and + // relays the CLI's progress/result through `emit`. + function run( args: string[] ): { operationId: OperationId } { + const operationId = crypto.randomUUID(); + const [ emitter ] = ctx.executeCliCommand( args, { output: 'capture' } ); + + emitter.on( 'data', ( { data } ) => { + const parsed = snapshotEventSchema.safeParse( data ); + if ( ! parsed.success ) { + console.error( 'Invalid snapshot event:', parsed.error ); + return; + } + if ( parsed.data.action === 'keyValuePair' ) { + ctx.emit( { kind: 'key-value', operationId, data: parsed.data } ); + } else if ( parsed.data.status === 'fail' ) { + ctx.emit( { kind: 'error', operationId, data: parsed.data } ); + } else { + ctx.emit( { kind: 'output', operationId, data: parsed.data } ); + } + } ); + + emitter.on( 'error', ( { error } ) => + ctx.emit( { kind: 'fatal-error', operationId, data: { message: error.message } } ) + ); + emitter.on( 'failure', ( { error } ) => + ctx.emit( { kind: 'fatal-error', operationId, data: { message: error.message } } ) + ); + emitter.on( 'success', () => ctx.emit( { kind: 'success', operationId } ) ); + + return { operationId }; + } + + return { + createSnapshot( siteFolder, name ) { + const args = [ 'preview', 'create', '--path', siteFolder ]; + if ( name ) { + args.push( '--name', name ); + } + return run( args ); + }, + updateSnapshot( siteFolder, hostname ) { + return run( [ 'preview', 'update', '--path', siteFolder, hostname ] ); + }, + deleteSnapshot( hostname ) { + return run( [ 'preview', 'delete', hostname ] ); + }, + setSnapshot( hostname, options ) { + const args = [ 'preview', 'set', hostname ]; + if ( options.name !== undefined ) { + args.push( '--name', options.name ); + } + return run( args ); + }, + }; +} + +// The CLI reports the snapshot list over its IPC channel as a `keyValuePair` +// ("snapshots" → JSON string), the same envelope the desktop reads. +const snapshotListKeyValueSchema = z.object( { + action: z.literal( 'keyValuePair' ), + key: z.literal( 'snapshots' ), + value: z + .string() + .transform( ( val ) => JSON.parse( val ) as unknown ) + .pipe( z.array( snapshotSchema ) ), +} ); + +export async function fetchSnapshots( + executeCliCommand: ExecuteCliCommand +): Promise< Snapshot[] > { + try { + return await new Promise< Snapshot[] >( ( resolve, reject ) => { + const [ emitter ] = executeCliCommand( [ 'preview', 'list', '--format', 'json' ], { + output: 'capture', + } ); + emitter.on( 'data', ( { data } ) => { + const parsed = snapshotListKeyValueSchema.safeParse( data ); + if ( parsed.success ) { + resolve( parsed.data.value ); + } + } ); + emitter.on( 'success', () => resolve( [] ) ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + } catch ( error ) { + console.error( 'Failed to fetch snapshots from CLI:', error ); + return []; + } +} diff --git a/tools/common/sites/sync.ts b/tools/common/sites/sync.ts new file mode 100644 index 0000000000..3f0fb48d24 --- /dev/null +++ b/tools/common/sites/sync.ts @@ -0,0 +1,97 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { initiateImport } from '@studio/common/lib/sync/sync-api'; +import { createTusUpload } from '@studio/common/lib/sync/tus-upload'; +import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; + +/** + * WordPress.com sync operations. Pull and push are delegated to the Studio CLI + * (`pull`) and to the shared upload/import primitives (`push`), so the desktop + * app and the `studio ui` server share the same logic; only the transport for + * progress differs (IPC vs SSE). + */ + +// Progress a push reports for the UI (the desktop also exposes manual +// pause/resume; that lives in its own registry on top of these signals). +export type PushOutput = + | { kind: 'upload-progress'; progress: number } + | { kind: 'network-paused'; error: string } + | { kind: 'resumed' }; + +export interface PushSiteContext { + executeCliCommand: ExecuteCliCommand; + accessToken: string; + emit?: ( output: PushOutput ) => void; +} + +/** + * Push a local site to its connected WordPress.com live site: export a full + * archive via the CLI, TUS-upload it (shared {@link createTusUpload}), then + * initiate the remote import (shared {@link initiateImport}). Resolves once the + * import is initiated; rejects on any failure. + */ +export async function pushSite( + ctx: PushSiteContext, + params: { sitePath: string; remoteSiteId: number } +): Promise< void > { + const dir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-push-' ) ); + const archivePath = path.join( dir, `site_${ crypto.randomUUID() }.tar.gz` ); + + try { + await new Promise< void >( ( resolve, reject ) => { + const [ emitter ] = ctx.executeCliCommand( + [ + 'export', + '--path', + params.sitePath, + archivePath, + '--mode', + 'full', + '--split-db-dump-by-table', + '--apply-deploy-ignore', + ], + { output: 'capture' } + ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + + const { promise } = createTusUpload( { + token: ctx.accessToken, + remoteSiteId: params.remoteSiteId, + archivePath, + onProgress: ( progress ) => ctx.emit?.( { kind: 'upload-progress', progress } ), + onNetworkPause: ( error ) => ctx.emit?.( { kind: 'network-paused', error } ), + onResume: () => ctx.emit?.( { kind: 'resumed' } ), + } ); + const attachmentId = await promise; + + await initiateImport( ctx.accessToken, params.remoteSiteId, attachmentId ); + } finally { + fs.rm( dir, { recursive: true, force: true }, () => undefined ); + } +} + +/** + * Pull a local site from its connected WordPress.com live site via the CLI + * `pull` command, exchanging everything (`--options all`). Resolves on success, + * rejects on failure. + */ +export function pullSite( + executeCliCommand: ExecuteCliCommand, + siteFolder: string, + remoteSiteId: number +): Promise< void > { + return new Promise( ( resolve, reject ) => { + const [ emitter ] = executeCliCommand( + [ 'pull', '--path', siteFolder, '--remote-site', String( remoteSiteId ), '--options', 'all' ], + { output: 'capture' } + ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); +} From 997738f90497b6b32c7f1c3872d65ae97a45bc7b Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 29 Jun 2026 13:17:37 +0200 Subject: [PATCH 2/4] Trim cross-surface meta from the shared site-module comments Keeps each module's functional description; drops the "shared between the desktop app and the studio ui server" commentary that described the refactor rather than the code. Co-Authored-By: Claude Opus 4.8 --- tools/common/sites/blueprint-extract.ts | 6 ++---- tools/common/sites/edit.ts | 6 +----- tools/common/sites/index.ts | 9 ++------- tools/common/sites/snapshots.ts | 10 +++------- tools/common/sites/sync.ts | 6 ++---- 5 files changed, 10 insertions(+), 27 deletions(-) diff --git a/tools/common/sites/blueprint-extract.ts b/tools/common/sites/blueprint-extract.ts index 9461a6affd..e28be64f40 100644 --- a/tools/common/sites/blueprint-extract.ts +++ b/tools/common/sites/blueprint-extract.ts @@ -13,10 +13,8 @@ export interface ExtractedBlueprintBundle { /** * Extract a Blueprint ZIP bundle to a temp directory and return the parsed - * `blueprint.json`. Shared between the desktop app and the local web server so - * both handle uploaded Blueprint bundles identically. The caller is responsible - * for cleaning up `tempDir` (via {@link cleanupBlueprintTempDir}) if it doesn't - * go on to consume the extracted bundle. + * `blueprint.json`. The caller is responsible for cleaning up `tempDir` (via + * {@link cleanupBlueprintTempDir}) if it doesn't go on to consume the bundle. */ export async function extractBlueprintBundle( zipFilePath: string diff --git a/tools/common/sites/edit.ts b/tools/common/sites/edit.ts index 4f938ac123..c8b1c3e3bc 100644 --- a/tools/common/sites/edit.ts +++ b/tools/common/sites/edit.ts @@ -1,11 +1,7 @@ import type { SiteFileAccess } from '@studio/common/lib/site-file-access'; import type { SiteMode } from '@studio/common/lib/site-runtime'; -/** - * Options accepted by the CLI `site set` command. Shared so the desktop app - * (via `editSiteViaCli`) and the local web server build identical args — only - * the spawn transport differs. - */ +/** Options accepted by the CLI `site set` command. */ export interface EditSiteOptions { path: string; siteId: string; diff --git a/tools/common/sites/index.ts b/tools/common/sites/index.ts index e2eb73d147..9ee564f300 100644 --- a/tools/common/sites/index.ts +++ b/tools/common/sites/index.ts @@ -3,13 +3,8 @@ import { siteListItemSchema, type SiteListItem } from '@studio/common/lib/cli-ev import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; /** - * Site operations, delegated to the Studio CLI. - * - * This is the shared body of logic the desktop app and the `studio ui` server - * both use: each passes its {@link ExecuteCliCommand} (which knows the CLI - * binary to fork), and the operations here are identical regardless of transport - * (IPC vs REST). Taking the function rather than the whole runner keeps the - * desktop's existing command mocks working. + * Site operations, delegated to the Studio CLI. Each caller passes its + * {@link ExecuteCliCommand}, which knows the CLI binary to fork. */ // The CLI's `site list --format json` reports the array over its IPC channel as diff --git a/tools/common/sites/snapshots.ts b/tools/common/sites/snapshots.ts index b12eceb193..911dd54f45 100644 --- a/tools/common/sites/snapshots.ts +++ b/tools/common/sites/snapshots.ts @@ -5,13 +5,9 @@ import { snapshotSchema, type Snapshot } from '@studio/common/types/snapshot'; import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; /** - * Preview-site (snapshot) operations, delegated to the Studio CLI. - * - * Shared by the desktop app and the `studio ui` server: each `preview` command - * is forked via the CLI and its progress relayed through a single injected - * `emit` (the only per-host difference) — the desktop forwards it over IPC as - * `snapshot-*` events; the server broadcasts it over SSE. Mirrors the agent - * run-manager's shape. + * Preview-site (snapshot) operations, delegated to the Studio CLI. Each + * `preview` command is forked via the CLI and its progress relayed through the + * injected `emit` callback. */ type OperationId = ReturnType< typeof crypto.randomUUID >; diff --git a/tools/common/sites/sync.ts b/tools/common/sites/sync.ts index 3f0fb48d24..9e11789be8 100644 --- a/tools/common/sites/sync.ts +++ b/tools/common/sites/sync.ts @@ -7,10 +7,8 @@ import { createTusUpload } from '@studio/common/lib/sync/tus-upload'; import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; /** - * WordPress.com sync operations. Pull and push are delegated to the Studio CLI - * (`pull`) and to the shared upload/import primitives (`push`), so the desktop - * app and the `studio ui` server share the same logic; only the transport for - * progress differs (IPC vs SSE). + * WordPress.com sync operations. Pull is delegated to the Studio CLI; push uses + * the shared upload/import primitives. */ // Progress a push reports for the UI (the desktop also exposes manual From 5188d3ba3cab16367dc2dc7b76a5c640e6198627 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Mon, 29 Jun 2026 13:41:04 +0200 Subject: [PATCH 3/4] Address review on the site-operations extraction - blueprint-extract: reuse the guarded createBlueprintTempDir/removeBlueprintTempDir from @studio/common/lib/blueprint-bundle instead of bare fs.rm, restoring the temp-dir path-prefix guard that the renderer-invokable cleanup dropped (and removing the duplicated temp lifecycle). - create: use crypto.randomUUID() for the blueprint temp filename (Date.now() could collide on concurrent creates). - sync: await the push temp-dir cleanup so short-lived hosts don't orphan multi-hundred-MB archives. - ipc-handlers: restore the explicit Promise return type on the extractBlueprintBundle handler (AGENTS.md). Co-Authored-By: Claude Opus 4.8 --- apps/studio/src/ipc-handlers.ts | 6 +++++- tools/common/sites/blueprint-extract.ts | 11 +++++++---- tools/common/sites/create.ts | 3 ++- tools/common/sites/sync.ts | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 475eea36a6..b6fc381eaa 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -86,6 +86,7 @@ import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-util import { cleanupBlueprintTempDir as cleanupBlueprintTempDirShared, extractBlueprintBundle as extractBlueprintBundleShared, + type ExtractedBlueprintBundle, } from '@studio/common/sites/blueprint-extract'; import { __, sprintf, LocaleData, defaultI18n } from '@wordpress/i18n'; import { @@ -2282,7 +2283,10 @@ export async function readBlueprintFile( return JSON.parse( fileContents ); } -export async function extractBlueprintBundle( _event: IpcMainInvokeEvent, zipFilePath: string ) { +export async function extractBlueprintBundle( + _event: IpcMainInvokeEvent, + zipFilePath: string +): Promise< ExtractedBlueprintBundle > { return extractBlueprintBundleShared( zipFilePath ); } diff --git a/tools/common/sites/blueprint-extract.ts b/tools/common/sites/blueprint-extract.ts index e28be64f40..a674e1f67f 100644 --- a/tools/common/sites/blueprint-extract.ts +++ b/tools/common/sites/blueprint-extract.ts @@ -1,7 +1,10 @@ import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { __ } from '@wordpress/i18n'; +import { + createBlueprintTempDir, + removeBlueprintTempDir, +} from '@studio/common/lib/blueprint-bundle'; import { extractZip } from '@studio/common/lib/extract-zip'; import type { BlueprintV1Declaration } from '@wp-playground/blueprints'; @@ -20,7 +23,7 @@ export async function extractBlueprintBundle( zipFilePath: string ): Promise< ExtractedBlueprintBundle > { const resolvedZipPath = path.resolve( zipFilePath ); - const tempDir = await fs.promises.mkdtemp( path.join( os.tmpdir(), 'studio-blueprint-bundle-' ) ); + const tempDir = await createBlueprintTempDir(); try { await extractZip( resolvedZipPath, tempDir ); @@ -41,11 +44,11 @@ export async function extractBlueprintBundle( return { blueprintJson, blueprintJsonPath, tempDir }; } catch ( error ) { - await fs.promises.rm( tempDir, { recursive: true, force: true } ); + await removeBlueprintTempDir( tempDir ); throw error; } } export async function cleanupBlueprintTempDir( tempDir: string ): Promise< void > { - await fs.promises.rm( tempDir, { recursive: true, force: true } ); + await removeBlueprintTempDir( tempDir ); } diff --git a/tools/common/sites/create.ts b/tools/common/sites/create.ts index b8ae06b8d7..ce4f196335 100644 --- a/tools/common/sites/create.ts +++ b/tools/common/sites/create.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -79,7 +80,7 @@ export function buildSiteCreateArgs( options: SiteCreateOptions ): { let blueprintTempPath: string | undefined; if ( options.blueprint ) { - blueprintTempPath = path.join( os.tmpdir(), `studio-blueprint-${ Date.now() }.json` ); + blueprintTempPath = path.join( os.tmpdir(), `studio-blueprint-${ crypto.randomUUID() }.json` ); fs.writeFileSync( blueprintTempPath, JSON.stringify( options.blueprint ) ); args.push( '--blueprint', blueprintTempPath ); if ( options.originalBlueprintPath ) { diff --git a/tools/common/sites/sync.ts b/tools/common/sites/sync.ts index 9e11789be8..3009bfb120 100644 --- a/tools/common/sites/sync.ts +++ b/tools/common/sites/sync.ts @@ -69,7 +69,7 @@ export async function pushSite( await initiateImport( ctx.accessToken, params.remoteSiteId, attachmentId ); } finally { - fs.rm( dir, { recursive: true, force: true }, () => undefined ); + await fs.promises.rm( dir, { recursive: true, force: true } ).catch( () => undefined ); } } From 17ed7913a426b75323497d0c99af2a001af99654 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Tue, 30 Jun 2026 11:12:57 +0200 Subject: [PATCH 4/4] Split sites/index into list.ts + lifecycle.ts (one op per file) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.ts doubled as a topic file holding listSites/startSite/stopSite rather than a package root, so importing @studio/common/sites gave only those three ops. Now each site op lives in its own concern file: listSites -> list.ts (used by the desktop site-server), startSite/stopSite -> lifecycle.ts (CLI one-shot start/stop, for the studio ui server — the desktop runs sites via its own long-lived SiteServer). No re-export barrel, matching the repo's deep-import convention and avoiding eager-loading heavy deps. Co-Authored-By: Claude Opus 4.8 --- apps/studio/src/site-server.ts | 2 +- tools/common/sites/lifecycle.ts | 37 ++++++++++++++++++++++++ tools/common/sites/{index.ts => list.ts} | 35 +--------------------- 3 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 tools/common/sites/lifecycle.ts rename tools/common/sites/{index.ts => list.ts} (52%) diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 5f396bf883..601580add9 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -4,7 +4,7 @@ import * as Sentry from '@sentry/electron/main'; import { SQLITE_FILENAME } from '@studio/common/constants'; import { parseJsonFromPhpOutput } from '@studio/common/lib/php-output-parser'; import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; -import { listSites } from '@studio/common/sites'; +import { listSites } from '@studio/common/sites/list'; import fsExtra from 'fs-extra'; import { parse } from 'shell-quote'; import { z } from 'zod'; diff --git a/tools/common/sites/lifecycle.ts b/tools/common/sites/lifecycle.ts new file mode 100644 index 0000000000..c7003e4908 --- /dev/null +++ b/tools/common/sites/lifecycle.ts @@ -0,0 +1,37 @@ +import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; + +// Start / stop a local site's server via the Studio CLI's one-shot `site start` +// / `site stop` commands. The desktop runs sites through its own long-lived +// `SiteServer` and doesn't use these; the `studio ui` server (which has no such +// supervised process) does. + +/** Start a local site's server via the Studio CLI. */ +export function startSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const [ emitter ] = execute( + [ 'site', 'start', '--path', sitePath, '--skip-browser', '--skip-log-details' ], + { output: 'capture' } + ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => { + error.baseMessage = 'Failed to start site'; + reject( error ); + } ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); +} + +/** Stop a local site's server via the Studio CLI. */ +export function stopSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { + return new Promise( ( resolve, reject ) => { + const [ emitter ] = execute( [ 'site', 'stop', '--path', sitePath ], { + output: 'capture', + } ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => { + error.baseMessage = 'Failed to stop site'; + reject( error ); + } ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); +} diff --git a/tools/common/sites/index.ts b/tools/common/sites/list.ts similarity index 52% rename from tools/common/sites/index.ts rename to tools/common/sites/list.ts index 9ee564f300..5ec6dad630 100644 --- a/tools/common/sites/index.ts +++ b/tools/common/sites/list.ts @@ -2,11 +2,6 @@ import { z } from 'zod'; import { siteListItemSchema, type SiteListItem } from '@studio/common/lib/cli-events'; import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; -/** - * Site operations, delegated to the Studio CLI. Each caller passes its - * {@link ExecuteCliCommand}, which knows the CLI binary to fork. - */ - // The CLI's `site list --format json` reports the array over its IPC channel as // a `keyValuePair` ("sites" → JSON string), the same envelope the desktop reads. const siteListKeyValueSchema = z.object( { @@ -18,6 +13,7 @@ const siteListKeyValueSchema = z.object( { .pipe( z.array( siteListItemSchema ) ), } ); +/** List the user's local sites via the Studio CLI. */ export function listSites( execute: ExecuteCliCommand ): Promise< SiteListItem[] > { return new Promise( ( resolve, reject ) => { const [ emitter ] = execute( [ 'site', 'list', '--format', 'json' ], { @@ -37,32 +33,3 @@ export function listSites( execute: ExecuteCliCommand ): Promise< SiteListItem[] emitter.on( 'error', ( { error } ) => reject( error ) ); } ); } - -export function startSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { - return new Promise( ( resolve, reject ) => { - const [ emitter ] = execute( - [ 'site', 'start', '--path', sitePath, '--skip-browser', '--skip-log-details' ], - { output: 'capture' } - ); - emitter.on( 'success', () => resolve() ); - emitter.on( 'failure', ( { error } ) => { - error.baseMessage = 'Failed to start site'; - reject( error ); - } ); - emitter.on( 'error', ( { error } ) => reject( error ) ); - } ); -} - -export function stopSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { - return new Promise( ( resolve, reject ) => { - const [ emitter ] = execute( [ 'site', 'stop', '--path', sitePath ], { - output: 'capture', - } ); - emitter.on( 'success', () => resolve() ); - emitter.on( 'failure', ( { error } ) => { - error.baseMessage = 'Failed to stop site'; - reject( error ); - } ); - emitter.on( 'error', ( { error } ) => reject( error ) ); - } ); -}