diff --git a/cli/commands/site/list.ts b/cli/commands/site/list.ts index 838d8e73fc..50d3be6a93 100644 --- a/cli/commands/site/list.ts +++ b/cli/commands/site/list.ts @@ -126,6 +126,9 @@ export async function runCommand( format: 'table' | 'json', watch: boolean ): Pr }, { debounceMs: 500 } ); + + process.on( 'SIGINT', disconnect ); + process.on( 'SIGTERM', disconnect ); } } finally { if ( ! watch ) { diff --git a/cli/commands/wp.ts b/cli/commands/wp.ts index d6d11abb8c..a15da59487 100644 --- a/cli/commands/wp.ts +++ b/cli/commands/wp.ts @@ -26,6 +26,9 @@ export async function runCommand( const useCustomPhpVersion = options.phpVersion && options.phpVersion !== site.phpVersion; if ( ! useCustomPhpVersion ) { + process.on( 'SIGINT', disconnect ); + process.on( 'SIGTERM', disconnect ); + try { await connect(); @@ -40,6 +43,9 @@ export async function runCommand( } } + process.on( 'SIGINT', () => process.exit( 1 ) ); + process.on( 'SIGTERM', () => process.exit( 1 ) ); + // …If not, instantiate a new Playground instance const [ response, closeWpCliServer ] = await runWpCliCommand( siteFolder, diff --git a/cli/index.ts b/cli/index.ts index e1a96dd685..1dec872b64 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -4,6 +4,7 @@ import { bumpAggregatedUniqueStat, AppdataProvider, LastBumpStatsData } from 'co import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning'; import { StatsGroup, StatsMetric } from 'common/types/stats'; import yargs from 'yargs'; +import { disconnect } from 'cli/lib/pm2-manager'; import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login'; import { registerCommand as registerAuthLogoutCommand } from 'cli/commands/auth/logout'; import { registerCommand as registerAuthStatusCommand } from 'cli/commands/auth/status'; @@ -28,6 +29,23 @@ import { version } from '../package.json'; suppressPunycodeWarning(); +// Handle shutdown message from parent process (Electron app). +// On Windows, child.kill() doesn't send SIGTERM, so we use IPC to notify +// the CLI to clean up (e.g., disconnect from PM2) before terminating. +// Only add this listener when running with IPC channel (from Electron app). +if ( process.send ) { + process.on( 'message', ( message: unknown ) => { + if ( message && typeof message === 'object' && 'type' in message && message.type === 'shutdown' ) { + disconnect(); + process.exit( 0 ); + } + } ); + // Allow the process to exit naturally when the main work is done, + // even though we have a message listener. The IPC channel will be + // cleaned up when the parent terminates. + process.channel?.unref(); +} + const cliAppdataProvider: AppdataProvider< LastBumpStatsData > = { load: readAppdata, lock: lockAppdata, diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index c84c8e81bc..3a29317469 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -34,11 +34,8 @@ const SITE_PROCESS_PREFIX = 'studio-site-'; // Get an abort signal that's triggered on SIGINT/SIGTERM. This is useful for aborting and cleaning // up async operations. const abortController = new AbortController(); -function handleProcessTermination() { - abortController.abort(); -} -process.on( 'SIGINT', handleProcessTermination ); -process.on( 'SIGTERM', handleProcessTermination ); +process.on( 'SIGINT', () => abortController.abort() ); +process.on( 'SIGTERM', () => abortController.abort() ); function getProcessName( siteId: string ): string { return `${ SITE_PROCESS_PREFIX }${ siteId }`; diff --git a/e2e/blueprints.test.ts b/e2e/blueprints.test.ts index 221910e44a..46d1cccf90 100644 --- a/e2e/blueprints.test.ts +++ b/e2e/blueprints.test.ts @@ -18,7 +18,7 @@ test.describe( 'Blueprints', () => { await onboarding.closeWhatsNew(); const siteContent = new SiteContent( session.mainWindow, DEFAULT_SITE_NAME ); - await expect( siteContent.siteNameHeading ).toBeVisible( { timeout: 120_000 } ); + await expect( siteContent.siteNameHeading ).toBeVisible( { timeout: 200_000 } ); } ); test.afterAll( async () => { @@ -49,7 +49,7 @@ test.describe( 'Blueprints', () => { // Wait for site to be created and running const siteContent = new SiteContent( session.mainWindow, siteName ); - await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); + await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } ); // Navigate to Settings tab to get admin URL const settingsTab = await siteContent.navigateToTab( 'Settings' ); @@ -85,7 +85,7 @@ test.describe( 'Blueprints', () => { // Wait for site to be created and running const siteContent = new SiteContent( session.mainWindow, siteName ); - await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); + await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } ); // Navigate to Settings tab to get admin URL const settingsTab = await siteContent.navigateToTab( 'Settings' ); @@ -123,7 +123,7 @@ test.describe( 'Blueprints', () => { // Wait for site to be created and running const siteContent = new SiteContent( session.mainWindow, siteName ); - await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); + await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } ); // Navigate to Settings tab to get admin URL const settingsTab = await siteContent.navigateToTab( 'Settings' ); @@ -159,7 +159,7 @@ test.describe( 'Blueprints', () => { // Wait for site to be created and running const siteContent = new SiteContent( session.mainWindow, siteName ); - await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); + await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } ); // Navigate to Settings tab to get admin URL const settingsTab = await siteContent.navigateToTab( 'Settings' ); @@ -197,7 +197,7 @@ test.describe( 'Blueprints', () => { // Wait for site to be created and running const siteContent = new SiteContent( session.mainWindow, siteName ); - await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); + await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } ); // Navigate to Settings tab to verify site is accessible const settingsTab = await siteContent.navigateToTab( 'Settings' ); @@ -236,7 +236,7 @@ test.describe( 'Blueprints', () => { // Wait for site to be created and running const siteContent = new SiteContent( session.mainWindow, siteName ); - await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); + await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } ); // Navigate to Settings tab to verify site is accessible const settingsTab = await siteContent.navigateToTab( 'Settings' ); diff --git a/e2e/e2e-helpers.ts b/e2e/e2e-helpers.ts index dcd4bbdedf..7220c80ffe 100644 --- a/e2e/e2e-helpers.ts +++ b/e2e/e2e-helpers.ts @@ -39,40 +39,43 @@ export class E2ESession { }; await fs.writeFile( appdataPath, JSON.stringify( initialAppdata, null, 2 ) ); - // find the latest build in the out directory - const latestBuild = findLatestBuild(); + await this.launchFirstWindow( testEnv ); + } - // parse the packaged Electron app and find paths and other info - const appInfo = parseElectronApp( latestBuild ); - let executablePath = appInfo.executable; - if ( appInfo.platform === 'win32' ) { - // `parseElectronApp` function obtains the executable path by finding the first executable from the build folder. - // We need to ensure that the executable is the Studio app. - executablePath = executablePath.replace( 'Squirrel.exe', 'Studio.exe' ); + // Close the app but keep the data for persistence testing + async restart() { + await this.closeApp(); + await this.launchFirstWindow(); + } + + private async closeApp() { + console.log( 'closeApp: starting' ); + if ( ! this.electronApp ) { + console.log( 'closeApp: no electronApp' ); + return; } - this.electronApp = await electron.launch( { - args: [ appInfo.main ], // main file from package.json - executablePath, // path to the Electron executable - env: { - ...process.env, - ...testEnv, - E2E: 'true', // allow app to determine whether it's running as an end-to-end test - E2E_APP_DATA_PATH: this.appDataPath, - E2E_HOME_PATH: this.homePath, - }, - timeout: 60_000, + const childProcess = this.electronApp.process(); + console.log( 'closeApp: calling electronApp.close() for pid', childProcess.pid ); + // Cap `ElectronApplication::close` call at 5s to prevent timeout issues on Windows + await new Promise( ( resolve, reject ) => { + Promise.race( [ + this.electronApp.close(), + new Promise( ( resolve ) => setTimeout( resolve, 5000 ) ), + ] ) + .then( resolve ) + .catch( reject ); } ); - this.mainWindow = await this.electronApp.firstWindow( { timeout: 60_000 } ); + console.log( 'closeApp: close() returned' ); } - // Close the app but keep the data for persistence testing - async restart() { - await this.electronApp?.close(); + private async launchFirstWindow( testEnv: NodeJS.ProcessEnv = {} ) { const latestBuild = findLatestBuild(); const appInfo = parseElectronApp( latestBuild ); let executablePath = appInfo.executable; if ( appInfo.platform === 'win32' ) { + // `parseElectronApp` function obtains the executable path by finding the first executable from + // the build folder. We need to ensure that the executable is the Studio app. executablePath = executablePath.replace( 'Squirrel.exe', 'Studio.exe' ); } @@ -81,6 +84,7 @@ export class E2ESession { executablePath, env: { ...process.env, + ...testEnv, E2E: 'true', E2E_APP_DATA_PATH: this.appDataPath, E2E_HOME_PATH: this.homePath, @@ -91,8 +95,11 @@ export class E2ESession { } async cleanup() { - await this.electronApp?.close(); - // Clean up temporary folder to hold application data - fs.rmSync( this.sessionPath, { recursive: true, force: true } ); + await this.closeApp(); + + // Removing the `sessionPath` directory has proven to be difficult, especially on Windows. Since + // session paths are unique, the WordPress installations are relatively small, and the E2E tests + // primarily run in ephemeral CI workers, we've decided to fix this issue by simply not removing + // the `sessionPath` directory. } } diff --git a/e2e/fixtures/blueprints/activate-plugin.json b/e2e/fixtures/blueprints/activate-plugin.json index 6b4f6b326c..111bd4eefc 100644 --- a/e2e/fixtures/blueprints/activate-plugin.json +++ b/e2e/fixtures/blueprints/activate-plugin.json @@ -4,7 +4,7 @@ "steps": [ { "step": "installPlugin", - "pluginZipFile": { + "pluginData": { "resource": "wordpress.org/plugins", "slug": "hello-dolly" } @@ -14,4 +14,4 @@ "pluginPath": "hello-dolly/hello.php" } ] -} \ No newline at end of file +} diff --git a/e2e/fixtures/blueprints/activate-theme.json b/e2e/fixtures/blueprints/activate-theme.json index fe60126e93..2600eab205 100644 --- a/e2e/fixtures/blueprints/activate-theme.json +++ b/e2e/fixtures/blueprints/activate-theme.json @@ -4,7 +4,7 @@ "steps": [ { "step": "installTheme", - "themeZipFile": { + "themeData": { "resource": "wordpress.org/themes", "slug": "twentytwentyone" } @@ -14,4 +14,4 @@ "themeFolderName": "twentytwentyone" } ] -} \ No newline at end of file +} diff --git a/e2e/fixtures/blueprints/install-plugin.json b/e2e/fixtures/blueprints/install-plugin.json index 76c840c7f5..2dd6e971bf 100644 --- a/e2e/fixtures/blueprints/install-plugin.json +++ b/e2e/fixtures/blueprints/install-plugin.json @@ -4,10 +4,10 @@ "steps": [ { "step": "installPlugin", - "pluginZipFile": { + "pluginData": { "resource": "wordpress.org/plugins", "slug": "akismet" } } ] -} \ No newline at end of file +} diff --git a/e2e/fixtures/blueprints/install-theme.json b/e2e/fixtures/blueprints/install-theme.json index 5bb3492410..d718e55e37 100644 --- a/e2e/fixtures/blueprints/install-theme.json +++ b/e2e/fixtures/blueprints/install-theme.json @@ -4,10 +4,10 @@ "steps": [ { "step": "installTheme", - "themeZipFile": { + "themeData": { "resource": "wordpress.org/themes", "slug": "twentytwentytwo" } } ] -} \ No newline at end of file +} diff --git a/e2e/localization.test.ts b/e2e/localization.test.ts index cab3c30e63..572452977f 100644 --- a/e2e/localization.test.ts +++ b/e2e/localization.test.ts @@ -139,7 +139,7 @@ test.describe( 'Localization', () => { // Wait for site to be created const siteContent = new SiteContent( session.mainWindow, siteName ); - await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); + await expect( siteContent.runningButton ).toBeAttached( { timeout: 200_000 } ); const settingsTabButton = session.mainWindow.getByRole( 'tab', { name: /Settings|設定/i } ); await settingsTabButton.click(); diff --git a/e2e/site-navigation.test.ts b/e2e/site-navigation.test.ts index cf7cd9ebdc..f123a9a7ac 100644 --- a/e2e/site-navigation.test.ts +++ b/e2e/site-navigation.test.ts @@ -49,7 +49,7 @@ test.describe( 'Site Navigation', () => { // Wait for default site to be ready and get URLs const siteContent = new SiteContent( session.mainWindow, siteName ); - await expect( siteContent.siteNameHeading ).toBeVisible( { timeout: 120_000 } ); + await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); // Get site URLs for tests const settingsTab = await siteContent.navigateToTab( 'Settings' ); diff --git a/package-lock.json b/package-lock.json index b8e1f40a99..989f3ce96a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -475,7 +475,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -2529,7 +2528,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2553,7 +2551,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -4152,7 +4149,6 @@ "version": "11.11.3", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.3.tgz", "integrity": "sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -5233,7 +5229,6 @@ "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", "license": "MIT", - "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", @@ -6425,6 +6420,23 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@npmcli/move-file/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@octokit/app": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@octokit/app/-/app-14.1.0.tgz", @@ -6593,7 +6605,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.1.tgz", "integrity": "sha512-dKYCMuPO1bmrpuogcjQ8z7ICCH3FP6WmxpwC03yjzGfZhj9fTJg6+bS1+UAplekbN2C+M61UNllGOOoAfGCrdQ==", - "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -6906,7 +6917,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -6928,7 +6938,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" }, @@ -6941,7 +6950,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, @@ -6966,7 +6974,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -7372,7 +7379,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" @@ -7398,7 +7404,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", @@ -7425,7 +7430,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.37.0.tgz", "integrity": "sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -7462,6 +7466,7 @@ "hasInstallScript": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", @@ -7504,6 +7509,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7525,6 +7531,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7546,6 +7553,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7567,6 +7575,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7588,6 +7597,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7609,6 +7619,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7630,6 +7641,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7651,6 +7663,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7672,6 +7685,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7693,6 +7707,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7714,6 +7729,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7735,6 +7751,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7756,6 +7773,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -7771,6 +7789,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "bin": { "detect-libc": "bin/detect-libc.js" }, @@ -9476,7 +9495,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" @@ -9701,8 +9719,7 @@ "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.13.5.tgz", "integrity": "sha512-ZBZcxieydxNwgEU9eFAXGMaDb1Xoh+ZkZcUQ27LNJzc2lPSByoL6CSVqnYiaVo+n9JgqbYyHlMq+i7z0wRNTfA==", "devOptional": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", @@ -9904,7 +9921,8 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true + "dev": true, + "peer": true }, "node_modules/@types/aws-lambda": { "version": "8.10.159", @@ -10271,7 +10289,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", "integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -10322,7 +10339,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -10333,7 +10349,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -10502,7 +10517,6 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -12281,7 +12295,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -13215,7 +13228,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -13408,6 +13420,69 @@ "node": ">=10" } }, + "node_modules/cacache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/cacache/node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/cacache/node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -14790,7 +14865,8 @@ "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/dot-case": { "version": "3.0.4", @@ -15816,7 +15892,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -15876,7 +15951,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -16001,7 +16075,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -16142,7 +16215,6 @@ "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18150,7 +18222,8 @@ "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/import-fresh": { "version": "3.3.0", @@ -19271,7 +19344,6 @@ "integrity": "sha512-y2mfcJywuTUkvLm2Lp1/pFX8kTgMO5yyQGq/Sk/n2mN7XWYp4JsCZ/QXW34M8YScgk8bPZlREH04f6blPnoHnQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.0.5", "@jest/types": "30.0.5", @@ -20809,7 +20881,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -21611,6 +21682,7 @@ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -23047,7 +23119,8 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/node-api-version": { "version": "0.2.1", @@ -23623,10 +23696,11 @@ } }, "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", - "dev": true + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" }, "node_modules/pako": { "version": "1.0.11", @@ -24151,7 +24225,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -24356,7 +24429,6 @@ "resolved": "https://registry.npmjs.org/wp-prettier/-/wp-prettier-3.0.3.tgz", "integrity": "sha512-X4UlrxDTH8oom9qXlcjnydsjAOD2BmB6yFmvS4Z2zdTzqqpRWb+fbqrH412+l+OUXmbzJlSXjlMFYPgYG12IAA==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -24384,6 +24456,7 @@ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -24398,6 +24471,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -24409,7 +24483,8 @@ "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/proc-log": { "version": "2.0.1", @@ -24659,7 +24734,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -24711,7 +24785,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -24755,7 +24828,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -25004,8 +25076,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -25430,21 +25501,6 @@ "dev": true, "license": "MIT" }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -25469,7 +25525,6 @@ "integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -25621,6 +25676,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -25643,6 +25699,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -25660,6 +25717,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -27088,7 +27146,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -27268,7 +27325,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -27561,7 +27617,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -27789,7 +27844,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -28096,7 +28150,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -28360,7 +28413,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -29010,7 +29062,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -29176,7 +29227,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/src/hooks/use-site-details.tsx b/src/hooks/use-site-details.tsx index 706412a893..dee009d6d7 100644 --- a/src/hooks/use-site-details.tsx +++ b/src/hooks/use-site-details.tsx @@ -188,21 +188,6 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { } } ); - /* - * Site Update Listeners - * - * Two complementary watchers keep the UI in sync with external changes: - * - * 1. 'site-status-changed' (from Site Status Watcher - execute-site-watch-command.ts): - * - Source: PM2 process events via `studio site list --watch` - * - Detects: Site start/stop/crash events - * - * 2. 'user-data-updated' (from User Data Watcher - user-data-watcher.ts): - * - Source: fs.watch on the appdata file - * - Detects: ALL changes (new sites, edits, deletions) - * - Used for: CLI site creation, property changes, external modifications - * - */ useIpcListener( 'site-status-changed', ( _, { siteId, status, url } ) => { setSites( ( prevSites ) => prevSites.map( ( site ) => @@ -211,16 +196,6 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { ); } ); - useIpcListener( 'user-data-updated', async () => { - const updatedSites = await getIpcApi().getSiteDetails(); - setSites( updatedSites ); - - // Handle case where selected site was deleted externally - if ( selectedSiteId && ! updatedSites.find( ( site ) => site.id === selectedSiteId ) ) { - setSelectedSiteId( updatedSites.length ? updatedSites[ 0 ].id : '' ); - } - } ); - const toggleLoadingServerForSite = useCallback( ( siteId: string ) => { setLoadingServer( ( currentLoading ) => ( { ...currentLoading, diff --git a/src/index.ts b/src/index.ts index 8d90a02538..69542b3b27 100644 --- a/src/index.ts +++ b/src/index.ts @@ -349,9 +349,11 @@ async function appBoot() { await renameLaunchUniquesStat(); - await createMainWindow(); await startUserDataWatcher(); - startSiteWatcher(); + + await startSiteWatcher(); + + await createMainWindow(); const userData = await loadUserData(); // Bump stats for the first time the app runs - this is when no lastBumpStats are available diff --git a/src/ipc-handlers.ts b/src/ipc-handlers.ts index 8c9ce861d3..fcaa6e5601 100644 --- a/src/ipc-handlers.ts +++ b/src/ipc-handlers.ts @@ -173,19 +173,7 @@ function mergeSiteDetailsWithRunningDetails( sites: SiteDetails[] ): SiteDetails return sites.map( ( site ) => { const server = SiteServer.get( site.id ); if ( server ) { - // Merge fresh data from disk with running state from server - // This ensures external changes (e.g., from CLI) are reflected - if ( server.details.running ) { - return { - ...site, - running: true as const, - url: server.details.url, - }; - } - return { - ...site, - running: false as const, - }; + return server.details; } return site; } ); diff --git a/src/modules/cli/lib/execute-command.ts b/src/modules/cli/lib/execute-command.ts index f249ccef08..f23cbfd9f6 100644 --- a/src/modules/cli/lib/execute-command.ts +++ b/src/modules/cli/lib/execute-command.ts @@ -1,3 +1,4 @@ +import { app } from 'electron'; import { fork, ChildProcess, StdioOptions } from 'node:child_process'; import EventEmitter from 'node:events'; import * as Sentry from '@sentry/electron/main'; @@ -10,8 +11,9 @@ export interface CliCommandResult { } type CliCommandEventMap = { - data: { data: unknown }; + started: void; error: { error: Error }; + data: { data: unknown }; success: { result?: CliCommandResult }; failure: { result?: CliCommandResult }; }; @@ -40,12 +42,13 @@ export interface ExecuteCliCommandOptions { * - 'capture': capture stdout/stderr, available in success/failure events */ output: 'ignore' | 'capture'; + detached?: boolean; logPrefix?: string; } export function executeCliCommand( args: string[], - options: ExecuteCliCommandOptions = { output: 'ignore' } + options: ExecuteCliCommandOptions = { output: 'ignore', detached: false } ): [ CliCommandEventEmitter, ChildProcess ] { const cliPath = getCliPath(); @@ -58,10 +61,21 @@ export function executeCliCommand( const child = fork( cliPath, [ ...args, '--avoid-telemetry' ], { stdio, + detached: options.detached, execPath: getBundledNodeBinaryPath(), } ); const eventEmitter = new CliCommandEventEmitter(); + child.on( 'spawn', () => { + eventEmitter.emit( 'started' ); + } ); + + child.on( 'error', ( error ) => { + console.error( 'Child process error:', error ); + Sentry.captureException( error ); + eventEmitter.emit( 'error', { error } ); + } ); + let stdout = ''; let stderr = ''; @@ -84,12 +98,6 @@ export function executeCliCommand( eventEmitter.emit( 'data', { data: message } ); } ); - child.on( 'error', ( error ) => { - console.error( 'Child process error:', error ); - Sentry.captureException( error ); - eventEmitter.emit( 'error', { error } ); - } ); - let capturedExitCode: number | null = null; child.on( 'exit', ( code ) => { @@ -110,9 +118,24 @@ export function executeCliCommand( } } ); - process.on( 'exit', () => { - child.kill(); - } ); + if ( options.detached ) { + child.unref(); + } else { + app.on( 'will-quit', () => { + // On Windows, child.kill() immediately terminates the process without sending + // SIGTERM, so signal handlers in the child never run. Use IPC to notify the + // child to clean up (e.g., disconnect from PM2) before terminating. + if ( child.connected ) { + try { + child.send( { type: 'shutdown' } ); + } catch { + // Process may have already exited + } + child.disconnect(); + } + child.kill(); + } ); + } return [ eventEmitter, child ]; } diff --git a/src/modules/cli/lib/execute-site-watch-command.ts b/src/modules/cli/lib/execute-site-watch-command.ts index 4a310de1b7..2b12d4ba50 100644 --- a/src/modules/cli/lib/execute-site-watch-command.ts +++ b/src/modules/cli/lib/execute-site-watch-command.ts @@ -1,31 +1,3 @@ -/** - * Site Status Watcher - * - * This module monitors site running/stopped status changes by subscribing to PM2 process events - * via `studio site list --watch`. It's primarily used to detect status changes that occur outside - * of Studio's direct control, such as: - * - Sites started/stopped via CLI commands - * - Site crashes or unexpected process terminations - * - * IMPORTANT: Architecture Notes - * ----------------------------- - * There are currently TWO separate watchers that update the UI with site changes: - * - * 1. Site Status Watcher (this file): - * - Monitors PM2 process events (start/stop/crash) - * - Only detects running/stopped status changes - * - Sends 'site-status-changed' IPC events to the renderer - * - * 2. User Data Watcher (src/lib/user-data-watcher.ts): - * - Monitors the appdata file directly via fs.watch - * - Detects ALL changes to site data (new sites, edits, deletions) - * - Sends 'user-data-updated' IPC events to the renderer - * - * The renderer (use-site-details.tsx) listens to BOTH: - * - 'site-status-changed': Updates running/stopped status for existing sites - * - 'user-data-updated': Refreshes the entire site list (handles new sites, edits, deletions) - * - */ import { z } from 'zod'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; @@ -92,37 +64,44 @@ async function updateSiteServerStatus( await current; } -export function startSiteWatcher(): void { - if ( watcher ) { - return; - } +export async function startSiteWatcher(): Promise< void > { + return new Promise( ( resolve, reject ) => { + if ( watcher ) { + return resolve(); + } - watcher = executeCliCommand( [ 'site', 'list', '--watch', '--format', 'json' ], { - output: 'ignore', - } ); - const [ eventEmitter ] = watcher; + watcher = executeCliCommand( [ 'site', 'list', '--watch', '--format', 'json' ], { + output: 'ignore', + } ); + const [ eventEmitter ] = watcher; - eventEmitter.on( 'data', ( { data } ) => { - const parsed = siteStatusEventSchema.safeParse( data ); - if ( ! parsed.success ) { - return; - } + eventEmitter.on( 'started', () => { + resolve(); + } ); - const { siteId, status, url } = parsed.data.value; - const isRunning = status === 'running'; + eventEmitter.on( 'error', ( { error } ) => { + reject(); + console.error( 'Site watcher error:', error ); + watcher = null; + } ); - void updateSiteServerStatus( siteId, isRunning, url ); - void sendIpcEventToRenderer( 'site-status-changed', parsed.data.value ); - } ); + eventEmitter.on( 'data', ( { data } ) => { + const parsed = siteStatusEventSchema.safeParse( data ); + if ( ! parsed.success ) { + return; + } - eventEmitter.on( 'error', ( { error } ) => { - console.error( 'Site watcher error:', error ); - watcher = null; - } ); + const { siteId, status, url } = parsed.data.value; + const isRunning = status === 'running'; - eventEmitter.on( 'failure', () => { - console.warn( 'Site watcher exited unexpectedly' ); - watcher = null; + void updateSiteServerStatus( siteId, isRunning, url ); + void sendIpcEventToRenderer( 'site-status-changed', parsed.data.value ); + } ); + + eventEmitter.on( 'failure', () => { + console.warn( 'Site watcher exited unexpectedly' ); + watcher = null; + } ); } ); } diff --git a/src/site-server.ts b/src/site-server.ts index 1fc603f528..b2c43e710a 100644 --- a/src/site-server.ts +++ b/src/site-server.ts @@ -41,6 +41,7 @@ export async function stopAllServersOnQuit() { return new Promise< void >( ( resolve ) => { const [ emitter ] = executeCliCommand( [ 'site', 'stop-all', '--auto-start' ], { output: 'ignore', + detached: true, } ); emitter.on( 'success', () => resolve() ); emitter.on( 'failure', () => resolve() ); diff --git a/src/tests/ipc-handlers.test.ts b/src/tests/ipc-handlers.test.ts index b95905b902..67c0659484 100644 --- a/src/tests/ipc-handlers.test.ts +++ b/src/tests/ipc-handlers.test.ts @@ -283,11 +283,9 @@ describe( 'getXdebugEnabledSite', () => { const result = await getXdebugEnabledSite( mockIpcMainInvokeEvent ); expect( result ).toEqual( { - autoStart: false, id: 'site-2', name: 'Site 2', path: '/path/to/site-2', - phpVersion: '8.0', running: true, enableXdebug: true, } ); @@ -304,11 +302,9 @@ describe( 'getXdebugEnabledSite', () => { ( fs.existsSync as jest.Mock ).mockReturnValue( true ); ( SiteServer.get as jest.Mock ).mockReturnValue( { details: { - autoStart: false, id: 'site-1', name: 'Site 1', path: '/path/to/site-1', - phpVersion: '8.0', running: false, enableXdebug: true, }, @@ -317,11 +313,9 @@ describe( 'getXdebugEnabledSite', () => { const result = await getXdebugEnabledSite( mockIpcMainInvokeEvent ); expect( result ).toEqual( { - autoStart: false, id: 'site-1', name: 'Site 1', path: '/path/to/site-1', - phpVersion: '8.0', running: false, enableXdebug: true, } );