diff --git a/apps/cli/commands/site/start.ts b/apps/cli/commands/site/start.ts index 7844dd95fb..c4e282d5b7 100644 --- a/apps/cli/commands/site/start.ts +++ b/apps/cli/commands/site/start.ts @@ -5,6 +5,7 @@ import { __, sprintf } from '@wordpress/i18n'; import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { getAiInstructionsPath } from 'cli/lib/dependency-management/paths'; +import { getSiteDetails } from 'cli/lib/site-details'; import { withSiteOperation } from 'cli/lib/site-operations'; import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; @@ -126,6 +127,11 @@ async function startSite( export async function commandHandler( argv: CommandArgs< typeof builder > ) { try { await runCommand( argv.path, argv.skipBrowser, argv.skipLogDetails ); + // Read after the operation is released, so Studio can show the site as it now is. + defaultLogger.reportResult( { + ...getSiteDetails( await getSiteByFolder( argv.path ) ), + running: true, + } ); } catch ( error ) { if ( error instanceof LoggerError ) { defaultLogger.reportError( error ); diff --git a/apps/cli/commands/site/stop.ts b/apps/cli/commands/site/stop.ts index 5b6d31b674..e6570d344b 100644 --- a/apps/cli/commands/site/stop.ts +++ b/apps/cli/commands/site/stop.ts @@ -13,6 +13,7 @@ import { disconnectFromDaemon, killDaemonAndChildren, } from 'cli/lib/daemon-client'; +import { getSiteDetails } from 'cli/lib/site-details'; import { withSiteOperation } from 'cli/lib/site-operations'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { getTracksOrigin, recordTracksEvent, TRACKS_EVENTS } from 'cli/lib/tracks'; @@ -158,6 +159,11 @@ export async function commandHandler( argv: CommandArgs< typeof builder > ) { await runCommand( Mode.STOP_ALL_SITES, undefined ); } else { await runCommand( Mode.STOP_SINGLE_SITE, argv.path ); + // Read after the operation is released, so Studio can show the site as it now is. + defaultLogger.reportResult( { + ...getSiteDetails( await getSiteByFolder( argv.path ) ), + running: false, + } ); } } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/local/src/index.ts b/apps/local/src/index.ts index 671bef487d..032c940cf2 100644 --- a/apps/local/src/index.ts +++ b/apps/local/src/index.ts @@ -830,8 +830,7 @@ export async function startLocalServer( options: LocalServerOptions ): Promise< res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); return; } - await startSite( execute, site.path ); - res.sendStatus( 204 ); + res.json( await startSite( execute, site.path ) ); } ) ); @@ -844,8 +843,7 @@ export async function startLocalServer( options: LocalServerOptions ): Promise< res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); return; } - await stopSite( execute, site.path ); - res.sendStatus( 204 ); + res.json( await stopSite( execute, site.path ) ); } ) ); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 43ec934fa8..b547e52cec 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -51,7 +51,7 @@ import { downloadAndExtractBlueprintBundle, removeBlueprintTempDir, } from '@studio/common/lib/blueprint-bundle'; -import { SITE_EVENTS } from '@studio/common/lib/cli-events'; +import { SITE_EVENTS, type SiteListItem } from '@studio/common/lib/cli-events'; import { CliCommandError } from '@studio/common/lib/cli-process'; import { stripIpcErrorPrefix } from '@studio/common/lib/error-formatting'; import { @@ -615,7 +615,11 @@ export async function updateSite( } } -export async function startServer( event: IpcMainInvokeEvent, id: string ): Promise< void > { +// Resolves with the started site, or nothing when an error page is served instead. +export async function startServer( + event: IpcMainInvokeEvent, + id: string +): Promise< SiteListItem | undefined > { // Starting only needs the path, which `cli.json` has; the CLI's full details wait for a failure. const site = await readSite( id ); if ( ! site ) { @@ -629,7 +633,9 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom const start = () => startSite( executeCliCommand, site.path ); try { console.log( `Starting server for '${ site.name }'` ); - await start(); + const started = await start(); + console.log( `Server started for '${ site.name }'` ); + return started; } catch ( error ) { // Too many running sites or a WordPress update in progress: expected, not a bug — skip Sentry if ( error instanceof SiteStartRefusedError ) { @@ -701,11 +707,12 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom throw error; } - - console.log( `Server started for '${ site.name }'` ); } -export async function stopServer( event: IpcMainInvokeEvent, id: string ): Promise< void > { +export async function stopServer( + event: IpcMainInvokeEvent, + id: string +): Promise< SiteListItem | undefined > { const sitePath = await readSitePath( id ); if ( ! sitePath ) { return; @@ -714,7 +721,7 @@ export async function stopServer( event: IpcMainInvokeEvent, id: string ): Promi await stopErrorRecovery( id ); console.log( 'Stopping server with ID', id ); try { - await stopSite( executeCliCommand, sitePath ); + return await stopSite( executeCliCommand, sitePath ); } catch ( error ) { console.error( error ); } diff --git a/apps/studio/src/lib/php-error-recovery.ts b/apps/studio/src/lib/php-error-recovery.ts index c92dd48dee..51542added 100644 --- a/apps/studio/src/lib/php-error-recovery.ts +++ b/apps/studio/src/lib/php-error-recovery.ts @@ -90,7 +90,7 @@ export async function startErrorRecovery( site: { id: string; port: number; path: string }, errorMessage: string, readPm2Logs: ( siteId: string ) => { stdout?: string[]; stderr?: string[] }, - startSite: () => Promise< void > + startSite: () => Promise< unknown > ): Promise< void > { const { id, port, path } = site; diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index 5a17d9b05e..34abb41783 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -308,11 +308,11 @@ export function createIpcConnector(): Connector { }, async startSite( id ) { - await unwrapIpcError( ipcApi.startServer( id ) ); + return unwrapIpcError( ipcApi.startServer( id ) ); }, async stopSite( id ) { - await ipcApi.stopServer( id ); + return ipcApi.stopServer( id ); }, async updateSite( site, wpVersion ) { diff --git a/apps/ui/src/data/core/connectors/local/index.ts b/apps/ui/src/data/core/connectors/local/index.ts index 91748c079b..a4efe7b8a9 100644 --- a/apps/ui/src/data/core/connectors/local/index.ts +++ b/apps/ui/src/data/core/connectors/local/index.ts @@ -33,7 +33,7 @@ import type { } from '../../types'; import type { AgentRunEvent } from '@studio/agent/protocol/agent-events'; import type { AiSettings } from '@studio/agent/protocol/providers'; -import type { SiteEvent, SnapshotEvent } from '@studio/common/lib/cli-events'; +import type { SiteEvent, SiteListItem, SnapshotEvent } from '@studio/common/lib/cli-events'; import type { SyncEvent } from '@studio/common/lib/sync/activity'; import type { RawDirectoryEntry } from '@studio/common/types/sync-tree'; @@ -334,10 +334,12 @@ export function createLocalConnector( { apiBaseUrl }: LocalConnectorOptions ): C return lastSites; }, async startSite( id ) { - await api( `/sites/${ encodeURIComponent( id ) }/start`, { method: 'POST' } ); + return api< SiteListItem >( `/sites/${ encodeURIComponent( id ) }/start`, { + method: 'POST', + } ); }, async stopSite( id ) { - await api( `/sites/${ encodeURIComponent( id ) }/stop`, { method: 'POST' } ); + return api< SiteListItem >( `/sites/${ encodeURIComponent( id ) }/stop`, { method: 'POST' } ); }, async getSiteThumbnail( siteId ) { const { thumbnail } = await api< { thumbnail: string | null } >( diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 31d6589164..74169b7282 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -10,7 +10,7 @@ import type { } from '@studio/agent/protocol/sessions/types'; import type { StudioVisualAnnotationSummary } from '@studio/agent/protocol/visual-annotations'; import type { SiteAgentFile, SiteAgentFileKind } from '@studio/common/lib/agent-skills'; -import type { SiteEvent, SnapshotEvent } from '@studio/common/lib/cli-events'; +import type { SiteEvent, SiteListItem, SnapshotEvent } from '@studio/common/lib/cli-events'; import type { SupportedLocale } from '@studio/common/lib/locale'; import type { TracksAuthSource, @@ -183,8 +183,10 @@ export interface Connector { // registers it as a new site). The connector picks a numbered name and a new // site id internally so callers stay simple. copySite( sourceSiteId: string ): Promise< SiteDetails >; - startSite( id: string ): Promise< void >; - stopSite( id: string ): Promise< void >; + // Resolve with the site as it is once the command finishes, or nothing when + // the caller has to read the list for it. + startSite( id: string ): Promise< SiteListItem | void >; + stopSite( id: string ): Promise< SiteListItem | void >; // Persists edits from the site-settings screen via the CLI-backed main // process handler. `wpVersion` is only forwarded when the user explicitly // picked a pinned version — undefined means "keep auto-updating". diff --git a/apps/ui/src/data/queries/use-sites.test.tsx b/apps/ui/src/data/queries/use-sites.test.tsx index d2309846a9..ad2659c617 100644 --- a/apps/ui/src/data/queries/use-sites.test.tsx +++ b/apps/ui/src/data/queries/use-sites.test.tsx @@ -200,28 +200,22 @@ describe( 'useSyncSitesWithEvents', () => { [ 'start', true ], [ 'stop', false ], ] as const )( - 'shows a %s from its events without refetching the list', + 'shows the site a %s reports, before its release event, without refetching the list', async ( action, running ) => { // What the CLI reports, and the sort order only Studio keeps. const details = { ...makeSite(), url: 'http://localhost:9010' }; const site = { ...details, running: ! running, sortOrder: 1000 }; const connector = createFakeConnector( { sites: [ site ] } ); connector[ action === 'start' ? 'startSite' : 'stopSite' ].mockImplementation( async () => { - const siteId = site.id; - const held = { ...details, operation: { pid: 1, kind: action } }; + // The event while the operation still holds the site. Its release + // event can land after the command finishes. connector.emit( 'onSiteEvent', { event: SITE_EVENTS.UPDATED, - siteId, - site: held, + siteId: site.id, + site: { ...details, operation: { pid: 1, kind: action } }, running, } ); - // An operation's release doesn't know whether the site runs. - connector.emit( 'onSiteEvent', { - event: SITE_EVENTS.OPERATIONS_CHANGED, - siteId, - site: details, - running: ! running, - } ); + return { ...details, running }; } ); const { result } = renderHook( () => { diff --git a/apps/ui/src/data/queries/use-sites.ts b/apps/ui/src/data/queries/use-sites.ts index 3a08eede7b..e576566b09 100644 --- a/apps/ui/src/data/queries/use-sites.ts +++ b/apps/ui/src/data/queries/use-sites.ts @@ -11,7 +11,7 @@ import { SESSIONS_QUERY_KEY } from '@/data/queries/use-sessions'; import { WP_VERSION_QUERY_KEY } from '@/data/queries/use-wordpress-versions'; import { useOpenSite } from '@/data/use-open-site'; import type { Connector, CreateSiteParams, SiteDetails } from '@/data/core'; -import type { SiteEvent } from '@studio/common/lib/cli-events'; +import type { SiteEvent, SiteListItem } from '@studio/common/lib/cli-events'; import type { SiteOperationKind } from '@studio/common/lib/site-operation'; import type { SiteMetadata } from '@studio/common/sites/metadata'; import type { QueryClient } from '@tanstack/react-query'; @@ -157,10 +157,7 @@ export function useStartSite() { if ( isSiteMutating( queryClient, STOP_SITE_MUTATION_KEY, id ) ) { return false; } - await connector.startSite( id ); - if ( ! listShowsSite( queryClient, id, true ) ) { - await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); - } + await showSite( queryClient, await connector.startSite( id ) ); return true; }, onError: ( error, id ) => { @@ -181,10 +178,7 @@ export function useStopSite() { return useMutation( { mutationKey: STOP_SITE_MUTATION_KEY, mutationFn: async ( id: string ) => { - await connector.stopSite( id ); - if ( ! listShowsSite( queryClient, id, false ) ) { - await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); - } + await showSite( queryClient, await connector.stopSite( id ) ); }, onError: ( _error, id ) => { const { title, description } = getBusyNotice( queryClient, id, __( 'Failed to stop site' ) ); @@ -395,12 +389,19 @@ function applySiteEvent( queryClient: QueryClient, event: SiteEvent ): boolean { return true; } -/** Whether the cached list already shows the site in this state, with no operation holding it. */ -function listShowsSite( queryClient: QueryClient, id: string, running: boolean ): boolean { - const site = queryClient - .getQueryData< SiteDetails[] >( SITES_QUERY_KEY ) - ?.find( ( item ) => item.id === id ); - return site?.running === running && ! site.operation; +/** Shows the site a start or stop reported, or reads the list when there's none to show. */ +async function showSite( queryClient: QueryClient, site: SiteListItem | void ): Promise< void > { + const applied = + site && + applySiteEvent( queryClient, { + event: SITE_EVENTS.UPDATED, + siteId: site.id, + running: site.running, + site, + } ); + if ( ! applied ) { + await queryClient.invalidateQueries( { queryKey: SITES_QUERY_KEY } ); + } } /** diff --git a/packages/common/sites/lifecycle.ts b/packages/common/sites/lifecycle.ts index ab6ba42f84..3992c6f73e 100644 --- a/packages/common/sites/lifecycle.ts +++ b/packages/common/sites/lifecycle.ts @@ -1,7 +1,12 @@ import { __ } from '@wordpress/i18n'; +import { siteListItemSchema, type SiteListItem } from '@studio/common/lib/cli-events'; +import { + CliCommandError, + runCliCommand, + type ExecuteCliCommand, +} from '@studio/common/lib/cli-process'; import { getProxyPortConflictMessage } from '@studio/common/lib/domains'; import { checkMaintenanceFile } from '@studio/common/lib/maintenance-file'; -import type { ExecuteCliCommand } from '@studio/common/lib/cli-process'; // Start / stop / delete a local site via the Studio CLI's one-shot `site` // commands, for the desktop main process and the `studio ui` server alike. @@ -22,11 +27,31 @@ function runSiteCommand( } ); } +// Start and stop report the site as it is once they finish, so the caller can +// show it without waiting for the matching site event. +async function runSiteStateCommand( + execute: ExecuteCliCommand, + args: string[], + baseMessage: string +): Promise< SiteListItem > { + try { + return await runCliCommand( execute, args, siteListItemSchema ); + } catch ( error ) { + if ( error instanceof CliCommandError ) { + error.baseMessage = baseMessage; + } + throw error; + } +} + /** The site can't start for an expected reason that its message explains to the user. */ export class SiteStartRefusedError extends Error {} /** Start a local site's server via the Studio CLI. Resolves when it's already running. */ -export async function startSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { +export async function startSite( + execute: ExecuteCliCommand, + sitePath: string +): Promise< SiteListItem > { const maintenance = checkMaintenanceFile( sitePath ); if ( maintenance.exists && ! maintenance.isStale ) { throw new SiteStartRefusedError( @@ -36,7 +61,7 @@ export async function startSite( execute: ExecuteCliCommand, sitePath: string ): ); } try { - await runSiteCommand( + return await runSiteStateCommand( execute, [ 'site', 'start', '--path', sitePath, '--skip-browser', '--skip-log-details' ], 'Failed to start site' @@ -59,8 +84,12 @@ export async function startSite( execute: ExecuteCliCommand, sitePath: string ): } /** Stop a local site's server via the Studio CLI. */ -export function stopSite( execute: ExecuteCliCommand, sitePath: string ): Promise< void > { - return runSiteCommand( execute, [ 'site', 'stop', '--path', sitePath ], 'Failed to stop site' ); +export function stopSite( execute: ExecuteCliCommand, sitePath: string ): Promise< SiteListItem > { + return runSiteStateCommand( + execute, + [ 'site', 'stop', '--path', sitePath ], + 'Failed to stop site' + ); } /** Remove a site from Studio via the Studio CLI, trashing its files unless `deleteFiles` is false. */