diff --git a/apps/cli/ai/tools/stop-site.ts b/apps/cli/ai/tools/stop-site.ts index 233c631e56..14209752e0 100644 --- a/apps/cli/ai/tools/stop-site.ts +++ b/apps/cli/ai/tools/stop-site.ts @@ -12,7 +12,7 @@ export const stopSiteTool = defineTool( async ( args ) => { try { const site = await resolveSite( args.nameOrPath ); - await runStopSiteCommand( StopMode.STOP_SINGLE_SITE, site.path, false ); + await runStopSiteCommand( StopMode.STOP_SINGLE_SITE, site.path ); return textResult( `Site "${ site.name }" stopped.` ); } catch ( error ) { throw new Error( diff --git a/apps/cli/commands/pull-reprint.ts b/apps/cli/commands/pull-reprint.ts index 9805c2261b..224794375c 100644 --- a/apps/cli/commands/pull-reprint.ts +++ b/apps/cli/commands/pull-reprint.ts @@ -29,7 +29,7 @@ import { type SiteData, unlockCliConfig, } from 'cli/lib/cli-config/core'; -import { getSiteUrl, updateSiteAutoStart, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; +import { getSiteUrl, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { type ReprintProcessResult, @@ -475,7 +475,6 @@ export async function runCommand( if ( processDesc.status === 'online' ) { await updateSiteLatestCliPid( site.id, processDesc.pid ); } - await updateSiteAutoStart( site.id, true ); studioMetadata.localUrl = getSiteUrl( site ); savePullMetadata( studioMetadata ); recordCompletedStage( studioMetadata, 'site-started' ); diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 600c613ca4..f96fcec6a6 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -50,11 +50,7 @@ import { SiteData, unlockCliConfig, } from 'cli/lib/cli-config/core'; -import { - removeSiteFromConfig, - updateSiteAutoStart, - updateSiteLatestCliPid, -} from 'cli/lib/cli-config/sites'; +import { removeSiteFromConfig, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { getAiInstructionsPath, @@ -355,7 +351,6 @@ export async function runCommand( if ( processDesc.status === 'online' ) { await updateSiteLatestCliPid( siteDetails.id, processDesc.pid ); } - await updateSiteAutoStart( siteDetails.id, true ); siteDetails.running = true; siteDetails.url = siteDetails.customDomain diff --git a/apps/cli/commands/site/start.ts b/apps/cli/commands/site/start.ts index 9fed3a7797..124f50ac41 100644 --- a/apps/cli/commands/site/start.ts +++ b/apps/cli/commands/site/start.ts @@ -1,11 +1,7 @@ import { updateManagedInstructionFiles } from '@studio/common/lib/agent-skills'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; -import { - getSiteByFolder, - updateSiteAutoStart, - updateSiteLatestCliPid, -} from 'cli/lib/cli-config/sites'; +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 { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; @@ -71,7 +67,6 @@ export async function runCommand( if ( processDesc.status === 'online' ) { await updateSiteLatestCliPid( site.id, processDesc.pid ); } - await updateSiteAutoStart( site.id, true ); if ( ! skipLogDetails ) { logSiteDetails( site ); diff --git a/apps/cli/commands/site/stop.ts b/apps/cli/commands/site/stop.ts index b9da299d56..2c3a154250 100644 --- a/apps/cli/commands/site/stop.ts +++ b/apps/cli/commands/site/stop.ts @@ -7,11 +7,7 @@ import { unlockCliConfig, type SiteData, } from 'cli/lib/cli-config/core'; -import { - clearSiteLatestCliPid, - getSiteByFolder, - updateSiteAutoStart, -} from 'cli/lib/cli-config/sites'; +import { clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, @@ -31,25 +27,18 @@ export enum Mode { export async function runCommand( target: Mode.STOP_SINGLE_SITE, - siteFolder: string, - autoStart: boolean + siteFolder: string ): Promise< void >; export async function runCommand( target: Mode.STOP_ALL_SITES, - siteFolder: undefined, - autoStart: boolean + siteFolder: undefined ): Promise< void >; -export async function runCommand( - target: Mode, - siteFolder: string | undefined, - autoStart: boolean -): Promise< void > { +export async function runCommand( target: Mode, siteFolder: string | undefined ): Promise< void > { try { await connectToDaemon(); if ( target === Mode.STOP_SINGLE_SITE && siteFolder ) { const site = await getSiteByFolder( siteFolder ); - await updateSiteAutoStart( site.id, autoStart ); const runningProcess = await isServerRunning( site.id ); if ( ! runningProcess ) { @@ -89,7 +78,6 @@ export async function runCommand( for ( const site of cliConfig.sites ) { if ( runningSites.find( ( r ) => r.id === site.id ) ) { delete site.latestCliPid; - site.autoStart = autoStart; } } await saveCliConfig( cliConfig ); @@ -122,25 +110,18 @@ export const registerCommand = ( yargs: StudioArgv ) => { command: 'stop', describe: __( 'Stop site(s)' ), builder: ( yargs ) => { - return yargs - .option( 'all', { - type: 'boolean', - describe: __( 'Stop all sites' ), - default: false, - } ) - .option( 'auto-start', { - type: 'boolean', - describe: __( 'Set auto-start flag for the site(s)' ), - default: false, - hidden: true, - } ); + return yargs.option( 'all', { + type: 'boolean', + describe: __( 'Stop all sites' ), + default: false, + } ); }, handler: async ( argv ) => { try { if ( argv.all ) { - await runCommand( Mode.STOP_ALL_SITES, undefined, argv.autoStart ); + await runCommand( Mode.STOP_ALL_SITES, undefined ); } else { - await runCommand( Mode.STOP_SINGLE_SITE, argv.path, argv.autoStart ); + await runCommand( Mode.STOP_SINGLE_SITE, argv.path ); } } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index cdbcdee393..94bd5fafcd 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -22,7 +22,7 @@ import { unlockCliConfig, SiteData, } from 'cli/lib/cli-config/core'; -import { removeSiteFromConfig, updateSiteAutoStart } from 'cli/lib/cli-config/sites'; +import { removeSiteFromConfig } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { updateServerFiles } from 'cli/lib/dependency-management/setup'; import { downloadWordPress } from 'cli/lib/dependency-management/wordpress'; @@ -62,7 +62,6 @@ vi.mock( 'cli/lib/cli-config/sites', async () => { return { ...actual, updateSiteLatestCliPid: vi.fn(), - updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), removeSiteFromConfig: vi.fn(), getSiteUrl: vi.fn().mockImplementation( ( site ) => `http://localhost:${ site.port }` ), }; @@ -294,7 +293,6 @@ describe( 'CLI: studio site create', () => { expect( saveCliConfig ).toHaveBeenCalled(); expect( connectToDaemon ).toHaveBeenCalled(); expect( startWordPressServer ).toHaveBeenCalled(); - expect( updateSiteAutoStart ).toHaveBeenCalledWith( expect.any( String ), true ); expect( logSiteDetails ).toHaveBeenCalled(); expect( openSiteInBrowser ).toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); diff --git a/apps/cli/commands/site/tests/start.test.ts b/apps/cli/commands/site/tests/start.test.ts index c7a80c21b8..19bdf5e324 100644 --- a/apps/cli/commands/site/tests/start.test.ts +++ b/apps/cli/commands/site/tests/start.test.ts @@ -1,11 +1,7 @@ import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { vi } from 'vitest'; import { SiteData } from 'cli/lib/cli-config/core'; -import { - getSiteByFolder, - updateSiteAutoStart, - updateSiteLatestCliPid, -} from 'cli/lib/cli-config/sites'; +import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; @@ -18,7 +14,6 @@ vi.mock( 'cli/lib/cli-config/sites', async () => ( { ...( await vi.importActual( 'cli/lib/cli-config/sites' ) ), getSiteByFolder: vi.fn(), updateSiteLatestCliPid: vi.fn(), - updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), } ) ); vi.mock( 'cli/lib/daemon-client' ); vi.mock( 'cli/lib/site-utils' ); @@ -146,7 +141,6 @@ describe( 'CLI: studio site start', () => { testSite.id, testProcessDescription.pid ); - expect( updateSiteAutoStart ).toHaveBeenCalledWith( testSite.id, true ); expect( logSiteDetails ).toHaveBeenCalledWith( testSite ); expect( openSiteInBrowser ).toHaveBeenCalledWith( testSite ); expect( disconnectFromDaemon ).toHaveBeenCalled(); diff --git a/apps/cli/commands/site/tests/stop.test.ts b/apps/cli/commands/site/tests/stop.test.ts index 7a9b760e57..45ae3281c5 100644 --- a/apps/cli/commands/site/tests/stop.test.ts +++ b/apps/cli/commands/site/tests/stop.test.ts @@ -1,11 +1,7 @@ import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { vi } from 'vitest'; import { SiteData, readCliConfig, saveCliConfig } from 'cli/lib/cli-config/core'; -import { - clearSiteLatestCliPid, - getSiteByFolder, - updateSiteAutoStart, -} from 'cli/lib/cli-config/sites'; +import { clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, @@ -30,7 +26,6 @@ vi.mock( 'cli/lib/cli-config/sites', async () => { ...actual, getSiteByFolder: vi.fn(), clearSiteLatestCliPid: vi.fn(), - updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), }; } ); vi.mock( 'cli/lib/daemon-client' ); @@ -78,7 +73,7 @@ describe( 'CLI: studio site stop', () => { it( 'should throw when site not found', async () => { vi.mocked( getSiteByFolder ).mockRejectedValue( new Error( 'Site not found' ) ); - await expect( runCommand( Mode.STOP_SINGLE_SITE, '/invalid/path', false ) ).rejects.toThrow( + await expect( runCommand( Mode.STOP_SINGLE_SITE, '/invalid/path' ) ).rejects.toThrow( 'Site not found' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); @@ -89,7 +84,7 @@ describe( 'CLI: studio site stop', () => { new Error( 'process manager connection failed' ) ); - await expect( runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ) ).rejects.toThrow( + await expect( runCommand( Mode.STOP_SINGLE_SITE, '/test/site' ) ).rejects.toThrow( 'process manager connection failed' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); @@ -99,7 +94,7 @@ describe( 'CLI: studio site stop', () => { vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); vi.mocked( stopWordPressServer ).mockRejectedValue( new Error( 'Server stop failed' ) ); - await expect( runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ) ).rejects.toThrow( + await expect( runCommand( Mode.STOP_SINGLE_SITE, '/test/site' ) ).rejects.toThrow( 'Failed to stop WordPress server' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); @@ -108,7 +103,7 @@ describe( 'CLI: studio site stop', () => { describe( 'Success Cases', () => { it( 'should skip stop if server is not running', async () => { - await runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ); + await runCommand( Mode.STOP_SINGLE_SITE, '/test/site' ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( clearSiteLatestCliPid ).not.toHaveBeenCalled(); @@ -119,7 +114,7 @@ describe( 'CLI: studio site stop', () => { it( 'should stop a running site', async () => { vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - await runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ); + await runCommand( Mode.STOP_SINGLE_SITE, '/test/site' ); expect( getSiteByFolder ).toHaveBeenCalledWith( '/test/site' ); expect( connectToDaemon ).toHaveBeenCalled(); @@ -131,32 +126,16 @@ describe( 'CLI: studio site stop', () => { } ); it( 'should not call stopProxyIfNoSitesNeedIt if site is not running', async () => { - await runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ); + await runCommand( Mode.STOP_SINGLE_SITE, '/test/site' ); expect( stopProxyIfNoSitesNeedIt ).not.toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); - - it( 'should set autoStart to true when flag is passed', async () => { - vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - - await runCommand( Mode.STOP_SINGLE_SITE, '/test/site', true ); - - expect( updateSiteAutoStart ).toHaveBeenCalledWith( testSite.id, true ); - } ); - - it( 'should set autoStart to false when flag is not passed', async () => { - vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - - await runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ); - - expect( updateSiteAutoStart ).toHaveBeenCalledWith( testSite.id, false ); - } ); } ); describe( 'Cleanup', () => { it( 'should always disconnect from process manager on success', async () => { - await runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ); + await runCommand( Mode.STOP_SINGLE_SITE, '/test/site' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); @@ -165,7 +144,7 @@ describe( 'CLI: studio site stop', () => { vi.mocked( getSiteByFolder ).mockRejectedValue( new Error( 'Error' ) ); try { - await runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ); + await runCommand( Mode.STOP_SINGLE_SITE, '/test/site' ); } catch { // Expected } @@ -174,7 +153,7 @@ describe( 'CLI: studio site stop', () => { } ); it( 'should always disconnect when site is not running', async () => { - await runCommand( Mode.STOP_SINGLE_SITE, '/test/site', false ); + await runCommand( Mode.STOP_SINGLE_SITE, '/test/site' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); @@ -238,7 +217,7 @@ describe( 'CLI: studio site stop --all', () => { it( 'should throw when appdata cannot be read', async () => { vi.mocked( readCliConfig ).mockRejectedValue( new Error( 'Failed to read appdata' ) ); - await expect( runCommand( Mode.STOP_ALL_SITES, undefined, false ) ).rejects.toThrow( + await expect( runCommand( Mode.STOP_ALL_SITES, undefined ) ).rejects.toThrow( 'Failed to read appdata' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); @@ -254,7 +233,7 @@ describe( 'CLI: studio site stop --all', () => { new Error( 'process manager connection failed' ) ); - await expect( runCommand( Mode.STOP_ALL_SITES, undefined, false ) ).rejects.toThrow( + await expect( runCommand( Mode.STOP_ALL_SITES, undefined ) ).rejects.toThrow( 'process manager connection failed' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); @@ -269,7 +248,7 @@ describe( 'CLI: studio site stop --all', () => { vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); vi.mocked( killDaemonAndChildren ).mockRejectedValue( new Error( 'Failed to kill daemon' ) ); - await expect( runCommand( Mode.STOP_ALL_SITES, undefined, false ) ).rejects.toThrow( + await expect( runCommand( Mode.STOP_ALL_SITES, undefined ) ).rejects.toThrow( 'Failed to kill daemon' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); @@ -280,7 +259,7 @@ describe( 'CLI: studio site stop --all', () => { it( 'should kill daemon even with empty sites list', async () => { vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } ); - await runCommand( Mode.STOP_ALL_SITES, undefined, false ); + await runCommand( Mode.STOP_ALL_SITES, undefined ); expect( connectToDaemon ).toHaveBeenCalled(); expect( killDaemonAndChildren ).toHaveBeenCalledTimes( 1 ); @@ -295,7 +274,7 @@ describe( 'CLI: studio site stop --all', () => { vi.mocked( isServerRunning ).mockResolvedValue( undefined ); - await runCommand( Mode.STOP_ALL_SITES, undefined, false ); + await runCommand( Mode.STOP_ALL_SITES, undefined ); expect( connectToDaemon ).toHaveBeenCalled(); expect( isServerRunning ).toHaveBeenCalledTimes( 3 ); @@ -311,7 +290,7 @@ describe( 'CLI: studio site stop --all', () => { } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - await runCommand( Mode.STOP_ALL_SITES, undefined, false ); + await runCommand( Mode.STOP_ALL_SITES, undefined ); expect( killDaemonAndChildren ).toHaveBeenCalledTimes( 1 ); expect( saveCliConfig ).toHaveBeenCalledTimes( 1 ); @@ -320,7 +299,6 @@ describe( 'CLI: studio site stop --all', () => { sites: expect.arrayContaining( [ expect.objectContaining( { id: 'site-1', - autoStart: false, } ), ] ), } ) @@ -335,7 +313,7 @@ describe( 'CLI: studio site stop --all', () => { } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - await runCommand( Mode.STOP_ALL_SITES, undefined, false ); + await runCommand( Mode.STOP_ALL_SITES, undefined ); expect( readCliConfig ).toHaveBeenCalled(); expect( connectToDaemon ).toHaveBeenCalled(); @@ -352,15 +330,12 @@ describe( 'CLI: studio site stop --all', () => { sites: expect.arrayContaining( [ expect.objectContaining( { id: 'site-1', - autoStart: false, } ), expect.objectContaining( { id: 'site-2', - autoStart: false, } ), expect.objectContaining( { id: 'site-3', - autoStart: false, } ), ] ), } ) @@ -379,7 +354,7 @@ describe( 'CLI: studio site stop --all', () => { .mockResolvedValueOnce( undefined ) // site-2 not running .mockResolvedValueOnce( testProcessDescription ); // site-3 running - await runCommand( Mode.STOP_ALL_SITES, undefined, true ); + await runCommand( Mode.STOP_ALL_SITES, undefined ); expect( isServerRunning ).toHaveBeenCalledTimes( 3 ); @@ -391,21 +366,9 @@ describe( 'CLI: studio site stop --all', () => { sites: expect.arrayContaining( [ expect.objectContaining( { id: 'site-1', - autoStart: true, } ), expect.objectContaining( { id: 'site-3', - autoStart: true, - } ), - ] ), - } ) - ); - expect( saveCliConfig ).toHaveBeenCalledWith( - expect.objectContaining( { - sites: expect.not.arrayContaining( [ - expect.objectContaining( { - id: 'site-2', - autoStart: true, } ), ] ), } ) diff --git a/apps/cli/lib/cli-config/sites.ts b/apps/cli/lib/cli-config/sites.ts index a968dae05f..1ac142268a 100644 --- a/apps/cli/lib/cli-config/sites.ts +++ b/apps/cli/lib/cli-config/sites.ts @@ -78,23 +78,6 @@ export async function clearSiteLatestCliPid( siteId: string ): Promise< void > { } } -export async function updateSiteAutoStart( siteId: string, autoStart: boolean ): Promise< void > { - try { - await lockCliConfig(); - const config = await readCliConfig(); - const site = config.sites.find( ( s ) => s.id === siteId ); - - if ( ! site ) { - throw new LoggerError( __( 'Site not found' ) ); - } - - site.autoStart = autoStart; - await saveCliConfig( config ); - } finally { - await unlockCliConfig(); - } -} - export async function updateSitePhpVersion( siteId: string, phpVersion: string ): Promise< void > { try { await lockCliConfig(); diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 5322739846..ec2844deb7 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -16,7 +16,7 @@ import { PROTOCOL_PREFIX } from '@studio/common/constants'; import { runMigrations } from '@studio/common/lib/migration'; import { getCurrentUserId } from '@studio/common/lib/shared-config'; import { suppressPunycodeWarning } from '@studio/common/lib/suppress-punycode-warning'; -import { __, _n, sprintf } from '@wordpress/i18n'; +import { __, _n } from '@wordpress/i18n'; import { installExtension, REACT_DEVELOPER_TOOLS, @@ -52,13 +52,19 @@ import { autoInstallLinuxCliIfNeeded } from 'src/modules/cli/lib/linux-installat import { autoInstallMacOSCliIfNeeded } from 'src/modules/cli/lib/macos-installation-manager'; import { autoInstallWindowsCliIfNeeded } from 'src/modules/cli/lib/windows-installation-manager'; import { startRemoteSessionStatusPolling } from 'src/modules/remote-session/daemon-status-poller'; -import { getRunningSiteCount, SiteServer, stopAllServers } from 'src/site-server'; +import { + getRunningSiteCount, + persistAutoStartForRunningSites, + SiteServer, + stopAllServers, +} from 'src/site-server'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata, updateAppdata, + type QuitSitesBehavior, } from 'src/storage/user-data'; import { getAutoUpdaterState, setupUpdates } from 'src/updates'; // eslint-disable-next-line import-x/order @@ -428,8 +434,14 @@ async function appBoot() { * - There are running sites, and the user has confirmed they want to stop them upon closing the app */ let shouldStopSitesOnQuit = true; + let clearAutoStartOnQuit = false; let isQuittingConfirmed = false; + const applyQuitSitesBehavior = ( behavior: QuitSitesBehavior ) => { + shouldStopSitesOnQuit = behavior !== 'leave-running'; + clearAutoStartOnQuit = behavior === 'stop'; + }; + app.on( 'before-quit', ( event ) => { if ( isQuittingConfirmed ) { return; @@ -478,8 +490,8 @@ async function appBoot() { void ( async () => { const userData = await loadUserData(); - if ( userData.stopSitesOnQuit !== undefined ) { - shouldStopSitesOnQuit = userData.stopSitesOnQuit; + if ( userData.quitSitesBehavior !== undefined ) { + applyQuitSitesBehavior( userData.quitSitesBehavior ); isQuittingConfirmed = true; app.quit(); return; @@ -491,38 +503,39 @@ async function appBoot() { return; } - const STOP_SITES_BUTTON_INDEX = 0; - const LEAVE_RUNNING_BUTTON_INDEX = 1; - const CANCEL_BUTTON_INDEX = 2; + const quitChoices: { label: string; behavior: QuitSitesBehavior }[] = [ + { label: __( 'Stop' ), behavior: 'stop' }, + { label: __( 'Auto-start' ), behavior: 'stop-and-auto-start' }, + { label: __( 'Leave running' ), behavior: 'leave-running' }, + ]; + const cancelButtonIndex = quitChoices.length; + const defaultButtonIndex = quitChoices.findIndex( + ( choice ) => choice.behavior === 'leave-running' + ); const { response, checkboxChecked } = await dialog.showMessageBox( { type: 'question', message: _n( 'You have a running site', 'You have running sites', runningSiteCount ), - detail: sprintf( - _n( - '%d site is currently running. Do you want to stop it before quitting?', - '%d sites are currently running. Do you want to stop them before quitting?', - runningSiteCount - ), - runningSiteCount + detail: __( + 'Choose what to do with your running sites when Studio quits:\n\n• Leave running — they keep running while Studio is closed.\n• Auto-start — they restart when you reopen Studio.\n• Stop — they stay stopped next time you open Studio.' ), - buttons: [ __( 'Stop sites' ), __( 'Leave running' ), __( 'Cancel' ) ], + buttons: [ ...quitChoices.map( ( choice ) => choice.label ), __( 'Cancel' ) ], checkboxLabel: __( "Don't ask again" ), - cancelId: CANCEL_BUTTON_INDEX, - defaultId: LEAVE_RUNNING_BUTTON_INDEX, + cancelId: cancelButtonIndex, + defaultId: defaultButtonIndex, } ); - if ( response === CANCEL_BUTTON_INDEX ) { + if ( response === cancelButtonIndex ) { return; } - const stopSites = response === STOP_SITES_BUTTON_INDEX; + const { behavior } = quitChoices[ response ]; if ( checkboxChecked ) { - await updateAppdata( { stopSitesOnQuit: stopSites } ); + await updateAppdata( { quitSitesBehavior: behavior } ); } - shouldStopSitesOnQuit = stopSites; + applyQuitSitesBehavior( behavior ); isQuittingConfirmed = true; app.quit(); } )(); @@ -539,13 +552,17 @@ async function appBoot() { if ( shouldStopSitesOnQuit ) { event.preventDefault(); - stopAllServers( true, STOP_ALL_SERVERS_ON_QUIT_TIMEOUT_MS ) - .then( () => { - app.exit(); - } ) - .catch( () => { + void ( async () => { + try { + // The events subscriber is already stopped, so the "Stop" choice clears autoStart here. + if ( clearAutoStartOnQuit ) { + await persistAutoStartForRunningSites( false ); + } + await stopAllServers( STOP_ALL_SERVERS_ON_QUIT_TIMEOUT_MS ); + } finally { app.exit(); - } ); + } + } )(); } } ); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 0b2d00abee..73dae06189 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -768,6 +768,7 @@ export async function getSiteDetails( _event: IpcMainInvokeEvent ): Promise< Sit site.sortOrder = appdataSite.sortOrder; site.themeDetails = appdataSite.themeDetails; site.siteIconPath = appdataSite.siteIconPath; + site.autoStart = appdataSite.autoStart; // Read the icon file from disk and hand the renderer a data URL. // Keeping the base64 out of the persisted appdata avoids bloating @@ -1075,10 +1076,13 @@ export async function stopServer( event: IpcMainInvokeEvent, id: string ): Promi } await server.stop(); + // Stopping a single site by hand clears its auto-start. SiteServer.stop() pre-empts the running + // transition the events subscriber relies on, so persist it explicitly here. + await server.persistAutoStart( false ); } export async function stopAllServers(): Promise< void > { - await triggerStopAllServers( false ); + await triggerStopAllServers(); } export interface FolderDialogResponse { diff --git a/apps/studio/src/migrations/02-migrate-to-split-config.ts b/apps/studio/src/migrations/02-migrate-to-split-config.ts index fd6678a57a..a5277f09fb 100644 --- a/apps/studio/src/migrations/02-migrate-to-split-config.ts +++ b/apps/studio/src/migrations/02-migrate-to-split-config.ts @@ -35,6 +35,9 @@ const sharedConfigExtractSchema = z.object( { const cliSiteSchema = siteDetailsSchema.extend( { url: z.string().optional(), latestCliPid: z.number().optional(), + // Kept after autoStart was removed from the shared schema so this migration still routes it to + // cli.json (and excludes it from app.json). Migration 07 then relocates it into app.json. + autoStart: z.boolean().optional(), } ); function buildSharedConfig( oldData: Record< string, unknown > ): Record< string, unknown > { diff --git a/apps/studio/src/migrations/07-relocate-autostart-to-app-json.ts b/apps/studio/src/migrations/07-relocate-autostart-to-app-json.ts new file mode 100644 index 0000000000..f12bdc2489 --- /dev/null +++ b/apps/studio/src/migrations/07-relocate-autostart-to-app-json.ts @@ -0,0 +1,104 @@ +/** + * Relocates the per-site `autoStart` flag out of CLI-owned `cli.json` into Studio's Desktop-only + * `app.json` (`siteMetadata[id].autoStart`), and converts the legacy boolean `stopSitesOnQuit` quit + * preference into the tri-state `quitSitesBehavior`. + * + * `autoStart` is a desktop-launch concept — only Studio acts on it — so it now lives with Studio's + * other per-site metadata. The migration is self-gating without a stored marker: it runs whenever + * `cli.json` still carries an `autoStart` flag (or `app.json` still has the legacy `stopSitesOnQuit`), + * and strips those source fields once relocated, so it can't re-run or clobber freshly-tracked values. + * + * Like the 02/04 migrations it validates with a local, loose zod schema (so unrelated cli.json fields + * survive the write-back) and reads/writes the file directly, since migrations run at startup before + * the CLI daemon touches it. + */ + +import fs from 'node:fs'; +import { getCliConfigPath } from '@studio/common/lib/well-known-paths'; +import { readFile, writeFile } from 'atomically'; +import { z } from 'zod'; +import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; +import type { Migration } from '@studio/common/lib/migration'; +import type { UserData } from 'src/storage/storage-types'; + +const cliConfigSchema = z + .object( { + sites: z + .array( z.object( { id: z.string(), autoStart: z.boolean().optional() } ).loose() ) + .optional(), + } ) + .loose(); + +type CliConfig = z.infer< typeof cliConfigSchema >; + +async function readCliConfig(): Promise< CliConfig | null > { + const cliPath = getCliConfigPath(); + if ( ! fs.existsSync( cliPath ) ) { + return null; + } + try { + const parsed = cliConfigSchema.safeParse( + JSON.parse( await readFile( cliPath, { encoding: 'utf8' } ) ) + ); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +const autoStartSites = ( config: CliConfig | null ) => + ( config?.sites ?? [] ).filter( ( site ) => site.autoStart !== undefined ); + +export const relocateAutostartToAppJson: Migration = { + async needsToRun() { + if ( autoStartSites( await readCliConfig() ).length > 0 ) { + return true; + } + const userData = ( await loadUserData() ) as UserData & { stopSitesOnQuit?: boolean }; + return userData.stopSitesOnQuit !== undefined; + }, + async run() { + const cliConfig = await readCliConfig(); + const flagged = autoStartSites( cliConfig ); + + try { + await lockAppdata(); + const userData = ( await loadUserData() ) as UserData & { stopSitesOnQuit?: boolean }; + + // Seed per-site autoStart into app.json, skipping anything Studio already tracks so a retry + // after a crash can't clobber a fresher value. + for ( const site of flagged ) { + if ( ! site.id || userData.siteMetadata[ site.id ]?.autoStart !== undefined ) { + continue; + } + userData.siteMetadata[ site.id ] = { + ...userData.siteMetadata[ site.id ], + autoStart: site.autoStart, + }; + } + + // The old "Stop sites" stopped sites but kept them flagged to auto-start, so a truthy + // preference maps to 'stop-and-auto-start'; falsey was "Leave running". + if ( userData.stopSitesOnQuit !== undefined && userData.quitSitesBehavior === undefined ) { + userData.quitSitesBehavior = userData.stopSitesOnQuit + ? 'stop-and-auto-start' + : 'leave-running'; + } + delete userData.stopSitesOnQuit; + + await saveUserData( userData ); + } finally { + await unlockAppdata(); + } + + // Strip the relocated flags from cli.json so the migration stays one-shot. + if ( flagged.length > 0 && cliConfig?.sites ) { + cliConfig.sites.forEach( ( site ) => { + delete site.autoStart; + } ); + await writeFile( getCliConfigPath(), JSON.stringify( cliConfig, null, 2 ) + '\n', { + encoding: 'utf8', + } ); + } + }, +}; diff --git a/apps/studio/src/migrations/index.ts b/apps/studio/src/migrations/index.ts index 1d317424dc..2270a7e83e 100644 --- a/apps/studio/src/migrations/index.ts +++ b/apps/studio/src/migrations/index.ts @@ -4,6 +4,7 @@ import { copyHttpsCertsToWellKnown } from './03-copy-https-certs-to-well-known'; import { migrateConnectedSitesToShared } from './04-migrate-connected-sites-to-shared'; import { removeOldServerFilesAndCertificates } from './05-remove-old-server-files-and-certificates'; import { setCliUserUninstalled } from './06-set-cli-user-uninstalled'; +import { relocateAutostartToAppJson } from './07-relocate-autostart-to-app-json'; import type { Migration } from '@studio/common/lib/migration'; export const migrations: Migration[] = [ @@ -13,4 +14,5 @@ export const migrations: Migration[] = [ migrateConnectedSitesToShared, removeOldServerFilesAndCertificates, setCliUserUninstalled, + relocateAutostartToAppJson, ]; diff --git a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index f380078e3d..5d83ae45c8 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -15,13 +15,14 @@ import { SiteServer } from 'src/site-server'; // Fields owned by Studio that the CLI never emits and that must survive a // site-event merge (TLS material, renderer-computed theme info, sort order, -// transient runtime flags). +// auto-start preference, transient runtime flags). const STUDIO_ONLY_DETAIL_KEYS = [ 'tlsKey', 'tlsCert', 'themeDetails', 'siteIconPath', 'sortOrder', + 'autoStart', 'isAddingSite', 'latestCliPid', ] as const satisfies readonly ( keyof SiteServer[ 'details' ] )[]; @@ -82,6 +83,8 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > void sendIpcEventToRenderer( 'site-event', event ); if ( running ) { void captureSiteThumbnail( siteId ); + // A freshly created site that's running should auto-start on the next Studio launch. + await SiteServer.get( siteId )?.persistAutoStart( true ); } return; } @@ -106,6 +109,10 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > void captureSiteThumbnail( siteId ); await server.getThemeDetails(); await server.getSiteIcon(); + // Mirror "is running" into the Studio-owned autoStart flag so the site resumes next launch. + await server.persistAutoStart( true ); + } else if ( ! wasNotRunning && ! running ) { + await server.persistAutoStart( false ); } } ); diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 34b0b4907d..88619ae21b 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -27,17 +27,13 @@ const deletedServers: string[] = []; /** * Stop all running sites using the CLI `site stop --all` command. * - * @param shouldSaveAutoStartProp Makes it so sites are automatically started the next time Studio launches. Typically only true when this function runs during the application close sequence. * @param timeoutAfterMs Optional timeout in milliseconds. */ -export async function stopAllServers( shouldSaveAutoStartProp: boolean, timeoutAfterMs?: number ) { +export async function stopAllServers( timeoutAfterMs?: number ) { let timeoutId: NodeJS.Timeout | undefined; return new Promise< void >( ( resolve ) => { const args = [ 'site', 'stop', '--all' ]; - if ( shouldSaveAutoStartProp ) { - args.push( '--auto-start' ); - } const [ emitter, childProcess ] = executeCliCommand( args, { output: 'ignore' } ); emitter.on( 'success', () => resolve() ); emitter.on( 'failure', () => resolve() ); @@ -63,6 +59,32 @@ export function getRunningSiteCount(): number { return Array.from( servers.values() ).filter( ( server ) => server.details.running ).length; } +// Persist autoStart for every currently-running site in a single locked write. Used on quit, where the +// CLI events subscriber (which normally mirrors autoStart into app.json) has already been stopped. +export async function persistAutoStartForRunningSites( autoStart: boolean ): Promise< void > { + const runningServers = Array.from( servers.values() ).filter( + ( server ) => server.details.running + ); + if ( ! runningServers.length ) { + return; + } + try { + await lockAppdata(); + const userData = await loadUserData(); + for ( const server of runningServers ) { + const siteId = server.details.id; + userData.siteMetadata[ siteId ] = { + ...userData.siteMetadata[ siteId ], + autoStart, + }; + server.details.autoStart = autoStart; + } + await saveUserData( userData ); + } finally { + await unlockAppdata(); + } +} + function getAbsoluteUrl( details: SiteDetails ): string { if ( details.customDomain ) { const protocol = details.enableHttps ? 'https' : 'http'; @@ -258,7 +280,7 @@ export class SiteServer { console.error( error ); } - const { running, autoStart, ...rest } = this.details; + const { running, ...rest } = this.details; if ( 'url' in rest ) { const { url, ...stoppedRest } = rest; this.details = { running: false, ...stoppedRest }; @@ -494,6 +516,22 @@ export class SiteServer { } } + async persistAutoStart( autoStart: boolean ): Promise< void > { + this.details.autoStart = autoStart; + try { + await lockAppdata(); + const userData = await loadUserData(); + const siteId = this.details.id; + userData.siteMetadata[ siteId ] = { + ...userData.siteMetadata[ siteId ], + autoStart, + }; + await saveUserData( userData ); + } finally { + await unlockAppdata(); + } + } + async hasSQLitePlugin(): Promise< boolean > { const wpContentPath = nodePath.join( this.details.path, 'wp-content' ); diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index 92aa0269cd..d6cf090d7d 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -11,10 +11,13 @@ export interface WindowBounds { isFullScreen?: boolean; } +export type QuitSitesBehavior = 'stop' | 'stop-and-auto-start' | 'leave-running'; + export interface AppdataSiteData { themeDetails?: SiteDetails[ 'themeDetails' ]; siteIconPath?: SiteDetails[ 'siteIconPath' ]; sortOrder?: number; + autoStart?: boolean; } export interface AiSessionSitePlacement { @@ -43,7 +46,7 @@ export interface UserData { preferredEditor?: SupportedEditor; colorScheme?: 'system' | 'light' | 'dark'; betaFeatures?: BetaFeatures; - stopSitesOnQuit?: boolean; + quitSitesBehavior?: QuitSitesBehavior; defaultSiteDirectory?: string; /** @deprecated Used only for migration to cliUserUninstalled. Do not write; remove after one release cycle. */ cliAutoInstalled?: boolean; diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index 3d0dac2185..08f57a6390 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -8,7 +8,14 @@ import { getAppConfigLockFilePath } from '@studio/common/lib/well-known-paths'; import { readFile, writeFile } from 'atomically'; import { sanitizeUnstructuredData, sanitizeUserpath } from 'src/lib/sanitize-for-logging'; import { getUserDataFilePath } from 'src/storage/paths'; -import { EMPTY_USER_DATA, type UserData, type WindowBounds } from 'src/storage/storage-types'; +import { + EMPTY_USER_DATA, + type QuitSitesBehavior, + type UserData, + type WindowBounds, +} from 'src/storage/storage-types'; + +export type { QuitSitesBehavior }; export async function loadUserData(): Promise< UserData > { const filePath = getUserDataFilePath(); @@ -65,7 +72,7 @@ type UserDataSafeKeys = | 'windowBounds' | 'onboardingCompleted' | 'promptWindowsSpeedUpResult' - | 'stopSitesOnQuit' + | 'quitSitesBehavior' | 'sentryUserId' | 'lastSeenVersion' | 'preferredTerminal' diff --git a/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts index 89a2850796..517490243e 100644 --- a/tools/common/lib/cli-events.ts +++ b/tools/common/lib/cli-events.ts @@ -24,7 +24,6 @@ export const siteDetailsSchema = z.object( { adminPassword: z.string().optional(), adminEmail: z.string().optional(), isWpAutoUpdating: z.boolean().optional(), - autoStart: z.boolean().optional(), enableXdebug: z.boolean().optional(), enableDebugLog: z.boolean().optional(), enableDebugDisplay: z.boolean().optional(),