From 360784c2af9f2deeccedc76706beed64aa984636 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 25 Jun 2026 12:26:49 +0100 Subject: [PATCH 01/16] Add studio ui local web server reusing apps/ui with a desktop-converged @studio/common backend Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +- apps/cli/commands/ui.ts | 82 ++ apps/cli/index.ts | 3 + apps/cli/lib/bump-stat.ts | 4 +- apps/cli/lib/run-wp-cli-command.ts | 35 +- apps/cli/lib/tests/run-wp-cli-command.test.ts | 43 + apps/cli/package.json | 1 + apps/cli/tsconfig.json | 3 +- apps/cli/vite.config.base.ts | 11 + apps/hosted/README.md | 8 +- apps/hosted/src/index.ts | 10 +- apps/local/globals.d.ts | 4 + apps/local/package.json | 30 + apps/local/src/index.ts | 1160 +++++++++++++++++ apps/local/src/open-in-os.ts | 122 ++ apps/local/tsconfig.json | 12 + apps/studio/src/ipc-handlers.ts | 190 +-- apps/studio/src/lib/ai-session-placement.ts | 98 +- apps/studio/src/lib/bump-stats.ts | 4 +- apps/studio/src/lib/is-installed.ts | 189 +-- .../lib/tests/ai-session-placement.test.ts | 92 -- apps/studio/src/lib/wordpress-rest-api.ts | 255 +--- .../src/modules/ai-agent/run-manager.ts | 363 +----- .../src/modules/cli/lib/cli-site-creator.ts | 111 +- .../src/modules/cli/lib/cli-site-editor.ts | 83 +- .../src/modules/cli/lib/execute-command.ts | 255 +--- .../cli/lib/execute-preview-command.ts | 74 -- .../modules/preview-site/lib/ipc-handlers.ts | 122 +- .../src/modules/sync/lib/ipc-handlers.ts | 59 +- apps/studio/src/preload.ts | 2 + apps/studio/src/site-server.ts | 30 +- apps/ui/{index.web.html => index.hosted.html} | 2 +- apps/ui/index.local.html | 12 + apps/ui/package.json | 9 +- apps/ui/src/app/app-providers.tsx | 21 +- .../src/components/create-site-form/index.tsx | 51 +- .../ui/src/components/settings-view/index.tsx | 10 +- .../components/settings-view/style.module.css | 4 +- .../src/components/sidebar-header/index.tsx | 7 +- .../sidebar-header/style.module.css | 23 +- .../src/components/sidebar-layout/index.tsx | 6 +- .../sidebar-layout/style.module.css | 7 +- .../components/site-preview/index.test.tsx | 42 + apps/ui/src/components/site-preview/index.tsx | 46 +- .../components/site-settings-view/index.tsx | 10 +- .../site-settings-view/style.module.css | 4 +- apps/ui/src/components/user-menu/index.tsx | 9 +- .../core/connectors/{web => hosted}/index.ts | 78 +- apps/ui/src/data/core/connectors/ipc/index.ts | 34 +- .../src/data/core/connectors/local/index.ts | 662 ++++++++++ .../data/core/connectors/unsupported-error.ts | 12 + apps/ui/src/data/core/types.ts | 34 + apps/ui/src/hooks/use-color-scheme.ts | 27 + apps/ui/src/hooks/use-traffic-light-space.ts | 18 + apps/ui/src/{main.web.tsx => main.hosted.tsx} | 10 +- apps/ui/src/main.local.tsx | 49 + .../components/session-view/index.tsx | 10 +- .../components/session-view/style.module.css | 4 +- apps/ui/vite.config.ts | 38 +- package-lock.json | 23 + tools/common/ai/sessions/agent-stats.ts | 88 ++ tools/common/ai/sessions/manage.ts | 115 ++ tools/common/ai/sessions/placement.ts | 145 +++ tools/common/ai/sessions/run-manager.ts | 315 +++++ .../ai/sessions/tests/placement.test.ts | 144 ++ tools/common/constants.ts | 6 + tools/common/lib/cli-process.ts | 275 ++++ tools/common/lib/error-reporting.ts | 16 + tools/common/lib/media-mime.ts | 39 + tools/common/lib/oauth.ts | 5 +- .../common/lib/tests/installed-apps.test.ts | 46 +- tools/common/lib/typed-event-emitter.ts | 19 + .../lib/user-settings/installed-apps.ts | 202 +++ tools/common/lib/wordpress-rest.ts | 263 ++++ tools/common/package.json | 2 + tools/common/sites/blueprint-extract.ts | 53 + tools/common/sites/create.ts | 102 ++ tools/common/sites/edit.ts | 75 ++ tools/common/sites/index.ts | 73 ++ tools/common/sites/snapshots.ts | 151 +++ tools/common/sites/sync.ts | 97 ++ 81 files changed, 5094 insertions(+), 1857 deletions(-) create mode 100644 apps/cli/commands/ui.ts create mode 100644 apps/cli/lib/tests/run-wp-cli-command.test.ts create mode 100644 apps/local/globals.d.ts create mode 100644 apps/local/package.json create mode 100644 apps/local/src/index.ts create mode 100644 apps/local/src/open-in-os.ts create mode 100644 apps/local/tsconfig.json delete mode 100644 apps/studio/src/lib/tests/ai-session-placement.test.ts delete mode 100644 apps/studio/src/modules/cli/lib/execute-preview-command.ts rename apps/ui/{index.web.html => index.hosted.html} (78%) create mode 100644 apps/ui/index.local.html rename apps/ui/src/data/core/connectors/{web => hosted}/index.ts (83%) create mode 100644 apps/ui/src/data/core/connectors/local/index.ts create mode 100644 apps/ui/src/data/core/connectors/unsupported-error.ts create mode 100644 apps/ui/src/hooks/use-color-scheme.ts create mode 100644 apps/ui/src/hooks/use-traffic-light-space.ts rename apps/ui/src/{main.web.tsx => main.hosted.tsx} (80%) create mode 100644 apps/ui/src/main.local.tsx create mode 100644 tools/common/ai/sessions/agent-stats.ts create mode 100644 tools/common/ai/sessions/manage.ts create mode 100644 tools/common/ai/sessions/placement.ts create mode 100644 tools/common/ai/sessions/run-manager.ts create mode 100644 tools/common/ai/sessions/tests/placement.test.ts create mode 100644 tools/common/lib/cli-process.ts create mode 100644 tools/common/lib/error-reporting.ts create mode 100644 tools/common/lib/media-mime.ts rename apps/studio/src/lib/tests/is-installed.test.ts => tools/common/lib/tests/installed-apps.test.ts (87%) create mode 100644 tools/common/lib/typed-event-emitter.ts create mode 100644 tools/common/lib/user-settings/installed-apps.ts create mode 100644 tools/common/lib/wordpress-rest.ts create mode 100644 tools/common/sites/blueprint-extract.ts create mode 100644 tools/common/sites/create.ts create mode 100644 tools/common/sites/edit.ts create mode 100644 tools/common/sites/index.ts create mode 100644 tools/common/sites/snapshots.ts create mode 100644 tools/common/sites/sync.ts diff --git a/.gitignore b/.gitignore index cde946ea9d..c47aadf3cc 100644 --- a/.gitignore +++ b/.gitignore @@ -112,7 +112,8 @@ cli/vendor/ # Build output dist -dist-web +dist-hosted +dist-local # Playwright traces test-results diff --git a/apps/cli/commands/ui.ts b/apps/cli/commands/ui.ts new file mode 100644 index 0000000000..0e16cb70b7 --- /dev/null +++ b/apps/cli/commands/ui.ts @@ -0,0 +1,82 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { LOCAL_UI_CLIENT_ID } from '@studio/common/constants'; +import { __ } from '@wordpress/i18n'; +import { openBrowser } from 'cli/lib/browser'; +import { StudioArgv } from 'cli/types'; + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'ui', + describe: __( 'Start the local Studio web UI and open it in your browser' ), + builder: ( uiYargs: StudioArgv ) => + uiYargs + .option( 'port', { + type: 'number', + description: __( 'Port to listen on' ), + } ) + .option( 'open', { + type: 'boolean', + default: true, + description: __( 'Open the UI in your default browser' ), + } ) + .option( 'path', { hidden: true } ), + handler: async ( argv ) => { + // `@studio/local` is bundled into the CLI from source (see the vite + // alias); the dynamic import keeps Express off the startup path of + // every other command. + const [ + { startLocalServer }, + { getAiSessionsRootDirectory }, + { lastBumpStatsProvider }, + { STUDIO_SITES_ROOT }, + ] = await Promise.all( [ + import( '@studio/local' ), + import( 'cli/ai/sessions/paths' ), + import( 'cli/lib/bump-stat' ), + import( 'cli/lib/site-paths' ), + ] ); + + // The server forks this same CLI for site + agent operations. When run + // from the packaged CLI, `process.argv[1]` is that binary; + // `STUDIO_CLI_BIN` overrides it for development. + const cliBinary = process.env.STUDIO_CLI_BIN ?? process.argv[ 1 ]; + + // The built browser UI (apps/ui `dist-local`) is copied next to the CLI + // bundle at build time. In dev it may be absent — the server then + // serves the API only and the UI is run via + // `npm run dev:local --workspace=apps/ui`. + const uiDist = + process.env.STUDIO_LOCAL_UI_DIST ?? + path.join( path.dirname( fileURLToPath( import.meta.url ) ), 'ui' ); + + const server = await startLocalServer( { + cliBinary, + sessionsRoot: getAiSessionsRootDirectory(), + sitesRoot: STUDIO_SITES_ROOT, + port: argv.port as number | undefined, + uiDist, + lastBumpStatsProvider, + // WordPress.com OAuth client id for browser login (redirect flow). + // Defaults to the shipped public client; override for dev/testing. + uiClientId: process.env.STUDIO_LOCAL_UI_CLIENT_ID ?? LOCAL_UI_CLIENT_ID, + } ); + + console.log( '' ); + console.log( __( 'WordPress Studio is running at:' ) ); + console.log( ` ${ server.url }` ); + console.log( '' ); + console.log( __( 'Press Ctrl+C to stop.' ) ); + + if ( argv.open ) { + await openBrowser( server.url ).catch( () => undefined ); + } + + const shutdown = () => { + void server.close().finally( () => process.exit( 0 ) ); + }; + process.on( 'SIGINT', shutdown ); + process.on( 'SIGTERM', shutdown ); + }, + } ); +}; diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 39f29c1214..fbc8ab42e4 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -15,6 +15,7 @@ import { registerCommand as registerSiteListCommand } from 'cli/commands/site/li import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start'; import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status'; import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop'; +import { registerCommand as registerUiCommand } from 'cli/commands/ui'; import { bumpAggregatedUniqueStat, bumpStat, @@ -215,6 +216,8 @@ async function main() { registerImportCommand( studioArgv ); registerExportCommand( studioArgv ); + registerUiCommand( studioArgv ); + studioArgv.command( 'preview', __( 'Manage preview sites' ), async ( previewYargs ) => { const [ { registerCommand: registerPreviewCreateCommand }, diff --git a/apps/cli/lib/bump-stat.ts b/apps/cli/lib/bump-stat.ts index 3b47ad7d09..a9dbdecab1 100644 --- a/apps/cli/lib/bump-stat.ts +++ b/apps/cli/lib/bump-stat.ts @@ -12,7 +12,9 @@ import { } from 'cli/lib/cli-config/core'; import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats'; -const lastBumpStatsProvider: LastBumpStatsProvider = { +// Exported so the shared agent run-manager (used by the `studio ui` server) can +// record weekly/monthly unique stats against the CLI's cli.json store. +export const lastBumpStatsProvider: LastBumpStatsProvider = { load: async () => { const { lastBumpStats } = await readCliConfig(); return lastBumpStats ?? {}; diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index bcf78975bc..897166784e 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -1,4 +1,5 @@ import { ChildProcess, spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; import path from 'node:path'; import { PassThrough, Readable } from 'node:stream'; import { buffer, text } from 'node:stream/consumers'; @@ -167,6 +168,31 @@ type DisposableExitCode = Disposable & { exitCode: Promise< number >; }; +// A Studio site's wp-config.php often omits DB_NAME: Playground provides the DB +// connection at runtime, and sites that were pulled/imported (or only ever run +// under Playground) can arrive without it. The v3+ SQLite integration drop-in +// requires a non-empty DB_NAME, so a one-off native WP-CLI command that boots +// WordPress (e.g. `plugin list` while building a full export's meta.json) would +// otherwise fail with "Error establishing a database connection". The Playground +// path injects this exact fallback via `php.defineConstant`; here we hand it to +// WP-CLI's `--exec`, which runs before WordPress loads. We inject it only when +// wp-config.php doesn't already define DB_NAME, so a real value is never +// redefined and sites that do define it are untouched. +export function getNativeDbNameFallbackArgs( sitePath: string ): string[] { + let wpConfig: string; + try { + wpConfig = readFileSync( path.join( sitePath, 'wp-config.php' ), 'utf8' ); + } catch { + return []; + } + + if ( /define\s*\(\s*['"]DB_NAME['"]/.test( wpConfig ) ) { + return []; + } + + return [ "--exec=defined('DB_NAME') || define('DB_NAME', 'wordpress');" ]; +} + async function runNativeWpCliCommand( site: SiteData, args: string[], @@ -189,9 +215,16 @@ async function runNativeWpCliCommand( // Don't apply open_basedir or disable_functions to the WP-CLI process const defaultArgs = getDefaultPhpArgs( phpVersion ); const nativeArgs = applyWpCliCommandOptions( 'native', args, options ); + const dbNameFallbackArgs = getNativeDbNameFallbackArgs( site.path ); const child = spawn( getPhpBinaryPath( phpVersion ), - [ ...defaultArgs, getWpCliPharPath(), `--path=${ site.path }`, ...nativeArgs ], + [ + ...defaultArgs, + getWpCliPharPath(), + `--path=${ site.path }`, + ...dbNameFallbackArgs, + ...nativeArgs, + ], { cwd: site.path, stdio: options.stdio === 'inherit' ? 'inherit' : [ 'ignore', 'pipe', 'pipe' ], diff --git a/apps/cli/lib/tests/run-wp-cli-command.test.ts b/apps/cli/lib/tests/run-wp-cli-command.test.ts new file mode 100644 index 0000000000..b57f5ac9b4 --- /dev/null +++ b/apps/cli/lib/tests/run-wp-cli-command.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getNativeDbNameFallbackArgs } from 'cli/lib/run-wp-cli-command'; + +const DB_NAME_EXEC_ARG = "--exec=defined('DB_NAME') || define('DB_NAME', 'wordpress');"; + +describe( 'getNativeDbNameFallbackArgs', () => { + let siteDir: string; + + beforeEach( () => { + siteDir = mkdtempSync( path.join( tmpdir(), 'studio-dbname-test-' ) ); + } ); + + afterEach( () => { + rmSync( siteDir, { recursive: true, force: true } ); + } ); + + function writeWpConfig( contents: string ): void { + writeFileSync( path.join( siteDir, 'wp-config.php' ), contents ); + } + + it( 'injects the DB_NAME fallback when wp-config.php does not define it', () => { + // Studio strips DB_NAME from wp-config.php; only comments reference it. + writeWpConfig( ` { + writeWpConfig( ` { + writeWpConfig( ` { + expect( getNativeDbNameFallbackArgs( siteDir ) ).toEqual( [] ); + } ); +} ); diff --git a/apps/cli/package.json b/apps/cli/package.json index 7b050dacd0..f8159c49dc 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -74,6 +74,7 @@ }, "devDependencies": { "@studio/common": "file:../../tools/common", + "@studio/local": "file:../local", "@types/archiver": "^8.0.0", "@types/express": "^4.17.23", "@types/http-proxy": "^1.17.17", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 732525ee61..eeb4a2ccf5 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -5,7 +5,8 @@ "moduleResolution": "bundler", "paths": { "cli/*": [ "./*" ], - "@studio/common/*": [ "../../tools/common/*" ] + "@studio/common/*": [ "../../tools/common/*" ], + "@studio/local": [ "../local/src/index.ts" ] } }, "include": [ "**/*" ], diff --git a/apps/cli/vite.config.base.ts b/apps/cli/vite.config.base.ts index da26c71082..00b09aedfa 100644 --- a/apps/cli/vite.config.base.ts +++ b/apps/cli/vite.config.base.ts @@ -31,6 +31,11 @@ const phpSourceCodePath = resolve( __dirname, 'php' ); // The Skill tool loads skills from `/skills` at runtime (see // `ai/skills.ts`), so they must sit directly next to the built chunks. const skillsSourcePath = resolve( __dirname, 'ai/skills' ); +// The `studio ui` command serves the built browser UI (apps/ui `dist-local`) +// from `/ui`, so it must sit next to the built chunks too. Built +// separately (`npm run build:local --workspace=apps/ui`); absent in API-only +// or dev-server setups, which is fine. +const localUiDistPath = resolve( __dirname, '../ui/dist-local' ); export const baseConfig = defineConfig( { oxc: { @@ -56,6 +61,9 @@ export const baseConfig = defineConfig( { if ( existsSync( skillsSourcePath ) ) { cpSync( skillsSourcePath, resolve( outDir, 'skills' ), { recursive: true } ); } + if ( existsSync( localUiDistPath ) ) { + cpSync( localUiDistPath, resolve( outDir, 'ui' ), { recursive: true } ); + } }, }, ], @@ -120,6 +128,9 @@ export const baseConfig = defineConfig( { alias: { cli: resolve( __dirname, '.' ), '@studio/common': resolve( __dirname, '../../tools/common' ), + // The `studio ui` local server (apps/local) is bundled into the CLI + // from source, the same way `@studio/common` is. + '@studio/local': resolve( __dirname, '../local/src' ), '@wp-playground/blueprints/blueprint-schema-validator': resolve( __dirname, '../../node_modules/@wp-playground/blueprints/blueprint-schema-validator.js' diff --git a/apps/hosted/README.md b/apps/hosted/README.md index 4eee9763c9..c1b7ba065d 100644 --- a/apps/hosted/README.md +++ b/apps/hosted/README.md @@ -3,15 +3,15 @@ `apps/hosted` is the experimental **Studio Web** backend: an HTTP + SSE server that drives Studio's agent from a browser. It exposes the same capabilities the desktop app reaches over IPC, but over HTTP, so the portable `apps/ui` renderer -can talk to it through the **web connector** -(`apps/ui/src/data/core/connectors/web`). +can talk to it through the **hosted connector** +(`apps/ui/src/data/core/connectors/hosted`). Unlike the desktop app and CLI, this targets a hosted deployment — WordPress.com / Telex APIs and a server-side agent sandbox — not a local WordPress install. It deliberately depends on nothing in `apps/cli`. ``` -npm run build:web --workspace=apps/ui # once, or after UI changes +npm run build:hosted --workspace=apps/ui # once, or after UI changes npm run build --workspace=apps/hosted # build the server bundle npm run start --workspace=apps/hosted # listens on 127.0.0.1:8088 (STUDIO_WEB_SERVER_PORT) ``` @@ -26,7 +26,7 @@ For UI development with hot reload, run the Vite dev server instead (it targets the backend's default port cross-origin): ``` -cd apps/ui && npm run dev:web # serves the browser entry on :5300 +cd apps/ui && npm run dev:hosted # serves the browser entry on :5300 ``` ## Status: agent runtime is stubbed diff --git a/apps/hosted/src/index.ts b/apps/hosted/src/index.ts index aa5429a631..3a025cb6fa 100644 --- a/apps/hosted/src/index.ts +++ b/apps/hosted/src/index.ts @@ -328,14 +328,14 @@ app.use( '/api', api ); // --- Web UI ------------------------------------------------------------------ -// Serve the built browser UI (apps/ui `npm run build:web`) so the server is the -// only process needed: API and SPA share one origin. When the build output +// Serve the built browser UI (apps/ui `npm run build:hosted`) so the server is +// the only process needed: API and SPA share one origin. When the build output // isn't there (API-only usage, or UI served by the Vite dev server on :5300), // the server still works and the startup message says how to get the UI. const uiDist = process.env.STUDIO_WEB_UI_DIST ?? - path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '../../ui/dist-web' ); -const uiIndex = path.join( uiDist, 'index.web.html' ); + path.resolve( path.dirname( fileURLToPath( import.meta.url ) ), '../../ui/dist-hosted' ); +const uiIndex = path.join( uiDist, 'index.hosted.html' ); const hasUi = existsSync( uiIndex ); if ( hasUi ) { app.use( express.static( uiDist ) ); @@ -374,7 +374,7 @@ const server = app.listen( port, '127.0.0.1', () => { if ( ! hasUi ) { console.log( `No web UI build found at ${ uiDist }.` ); console.log( - `Build it with \`npm run build:web --workspace=apps/ui\`, or run the dev server with \`npm run dev:web --workspace=apps/ui\` and open http://localhost:5300.` + `Build it with \`npm run build:hosted --workspace=apps/ui\`, or run the dev server with \`npm run dev:hosted --workspace=apps/ui\` and open http://localhost:5300.` ); console.log( '' ); } diff --git a/apps/local/globals.d.ts b/apps/local/globals.d.ts new file mode 100644 index 0000000000..d2991511b9 --- /dev/null +++ b/apps/local/globals.d.ts @@ -0,0 +1,4 @@ +// `wpcom-xhr-request` ships no types; `@studio/common`'s sync code imports it +// (pulled in here via the syncable-sites fetch). Each app declares it for its +// own compilation, mirroring apps/cli/globals.d.ts and apps/studio. +declare module 'wpcom-xhr-request'; diff --git a/apps/local/package.json b/apps/local/package.json new file mode 100644 index 0000000000..bd202d1220 --- /dev/null +++ b/apps/local/package.json @@ -0,0 +1,30 @@ +{ + "name": "@studio/local", + "author": "Automattic Inc.", + "version": "0.0.0", + "productName": "Studio Local Server", + "description": "Local Studio web server (HTTP/SSE) bundled into the CLI and launched by `studio ui`", + "license": "GPL-2.0-or-later", + "private": true, + "type": "module", + "engines": { + "node": ">=22.0.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Automattic/studio.git", + "directory": "apps/local" + }, + "scripts": { + "lint": "eslint .", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "express": "^4.22.0", + "express-rate-limit": "^8.5.2" + }, + "devDependencies": { + "@studio/common": "file:../../tools/common", + "@types/express": "^4.17.23" + } +} diff --git a/apps/local/src/index.ts b/apps/local/src/index.ts new file mode 100644 index 0000000000..d5077ae62c --- /dev/null +++ b/apps/local/src/index.ts @@ -0,0 +1,1160 @@ +import crypto from 'node:crypto'; +import { createWriteStream, existsSync, mkdtempSync, rm } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { isAiModelId } from '@studio/common/ai/models'; +import { + createOrReuseAiSession, + hydrateAiSessionSummary, + listHydratedAiSessions, + loadHydratedAiSession, +} from '@studio/common/ai/sessions/manage'; +import { + deleteAiSessionPlacement, + readAiSessionPlacement, +} from '@studio/common/ai/sessions/placement'; +import { createAgentRunManager } from '@studio/common/ai/sessions/run-manager'; +import { + appendModelChangeEntry, + deleteAiSession, + loadAiSession, +} from '@studio/common/ai/sessions/store'; +import { DEFAULT_TOKEN_LIFETIME_MS } from '@studio/common/constants'; +import { createCliRunner } from '@studio/common/lib/cli-process'; +import { + addConnectedWpcomSite, + getConnectedWpcomSitesForLocalSite, + removeConnectedWpcomSite, +} from '@studio/common/lib/connected-sites'; +import { + arePathsEqual, + isEmptyDir, + isWordPressDirectory, + recursiveCopyDirectory, +} from '@studio/common/lib/fs-utils'; +import { generateNumberedName, generateSiteName } from '@studio/common/lib/generate-site-name'; +import { isErrnoException } from '@studio/common/lib/is-errno-exception'; +import { getLocalMediaMimeType } from '@studio/common/lib/media-mime'; +import { getAuthenticationUrl } from '@studio/common/lib/oauth'; +import { decodePassword } from '@studio/common/lib/passwords'; +import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; +import { + deleteSharedSession, + readAuthToken, + updateSharedConfig, + updateSharedSession, +} from '@studio/common/lib/shared-config'; +import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api'; +import { detectInstalledApps } from '@studio/common/lib/user-settings/installed-apps'; +import { createJsonResponse, fetchSiteRest } from '@studio/common/lib/wordpress-rest'; +import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; +import { listSites, startSite, stopSite } from '@studio/common/sites'; +import { + cleanupBlueprintTempDir, + extractBlueprintBundle, +} from '@studio/common/sites/blueprint-extract'; +import { buildSiteCreateArgs, type SiteCreateOptions } from '@studio/common/sites/create'; +import { buildSiteSetArgs } from '@studio/common/sites/edit'; +import { createSnapshotManager, fetchSnapshots } from '@studio/common/sites/snapshots'; +import { pullSite, pushSite } from '@studio/common/sites/sync'; +import express from 'express'; +import { rateLimit } from 'express-rate-limit'; +import { isEditor, isTerminal, openInEditor, openInTerminal, openPath } from './open-in-os'; +import type { LastBumpStatsProvider } from '@studio/common/lib/bump-stat'; +import type { SiteListItem } from '@studio/common/lib/cli-events'; +import type { EditSiteOptions } from '@studio/common/sites/edit'; +import type { SyncSite } from '@studio/common/types/sync'; +import type { SiteRestRequest } from '@studio/common/types/wordpress-rest'; +import type { Request, Response } from 'express'; + +/** + * The local Studio web server: the browser analog of the desktop app, exposed + * over HTTP/SSE instead of IPC. It is bundled into the Studio CLI and started by + * the `studio ui` command, which injects how to reach the CLI binary and where + * sessions live. + * + * The business logic is NOT reimplemented here — every route delegates to the + * same `@studio/common` code the desktop app uses (the session store, the site + * operations, and the agent run-manager). The CLI binary is forked exactly the + * way the desktop forks it; only the transport differs. + */ + +export interface LocalServerOptions { + // Absolute path to the CLI entry to fork for site/agent operations. + cliBinary: string; + // Node binary to fork the CLI with. Defaults to `process.execPath` (correct + // when the server runs inside the CLI, which is itself a Node process). + nodeBinary?: string; + // Root directory where AI sessions are stored (the CLI's appdata sessions + // dir), so the browser sees the same sessions the CLI and desktop write. + sessionsRoot: string; + // Default root for new site folders (the CLI's `~/Studio`), used to propose + // paths/names in the create form. + sitesRoot: string; + // Port to listen on. Defaults to STUDIO_LOCAL_SERVER_PORT or 8081. + port?: number; + // Host to bind. Defaults to loopback (127.0.0.1) — the server is + // unauthenticated and must not be reachable from the network. + host?: string; + // Path to the built browser UI (apps/ui `dist-local`). Served when present + // so the server is the only process needed; omitted in dev (Vite serves it). + uiDist?: string; + // Store for the agent's weekly/monthly unique-user stat dedup. The `studio ui` + // command passes the CLI's cli.json-backed provider; omit to skip those stats. + lastBumpStatsProvider?: LastBumpStatsProvider; + // WordPress.com OAuth client id for the browser (redirect-based) login flow. + // When unset, login falls back to pasting the token (`studio auth login`-style). + uiClientId?: string; +} + +export interface LocalServer { + url: string; + port: number; + close(): Promise< void >; +} + +const DEFAULT_PORT = 8081; + +// Served at /auth/callback — the OAuth redirect target for the browser +// login flow. WordPress.com lands here with the token in the URL fragment +// (implicit grant), which never reaches the server, so this page reads it +// client-side, hands it to /api/auth/login to validate + store, then notifies +// the opener window and closes. +const AUTH_CALLBACK_HTML = ` +Signing in… + +

Signing you in…

`; + +// The agentic UI's SiteDetails shape. The CLI's `site list` already reports +// nearly all of it; thumbnails/theme details are desktop-only enrichments. +function toSiteDetails( site: SiteListItem ) { + return { + id: site.id, + name: site.name, + path: site.path, + port: site.port, + running: site.running, + url: site.url, + phpVersion: site.phpVersion, + customDomain: site.customDomain, + enableHttps: site.enableHttps, + adminUsername: site.adminUsername, + adminPassword: site.adminPassword, + adminEmail: site.adminEmail, + isWpAutoUpdating: site.isWpAutoUpdating, + enableXdebug: site.enableXdebug, + enableDebugLog: site.enableDebugLog, + enableDebugDisplay: site.enableDebugDisplay, + siteIcon: null, + }; +} + +// `studio-backup--`, matching the desktop's backup naming. +function backupFilename( siteName: string ): string { + const now = new Date(); + const pad = ( n: number ) => String( n ).padStart( 2, '0' ); + const ts = + `${ now.getFullYear() }-${ pad( now.getMonth() + 1 ) }-${ pad( now.getDate() ) }` + + `_${ pad( now.getHours() ) }_${ pad( now.getMinutes() ) }_${ pad( now.getSeconds() ) }`; + return sanitizeFolderName( `studio-backup-${ siteName }-${ ts }` ); +} + +// Express 4 doesn't forward async rejections to the error middleware — an +// unhandled rejection would take the whole process down — so async routes go +// through this wrapper. +function asyncHandler( fn: ( req: Request, res: Response ) => Promise< void > ) { + return ( req: Request, res: Response, next: ( e?: unknown ) => void ) => { + fn( req, res ).catch( next ); + }; +} + +export async function startLocalServer( options: LocalServerOptions ): Promise< LocalServer > { + const { cliBinary, nodeBinary, sessionsRoot, sitesRoot, uiDist, uiClientId } = options; + const port = options.port ?? Number( process.env.STUDIO_LOCAL_SERVER_PORT ?? DEFAULT_PORT ); + const host = options.host ?? '127.0.0.1'; + + const cliRunner = createCliRunner( { cliBinary, nodeBinary } ); + const execute = cliRunner.executeCliCommand; + + // --- Server-Sent Events: one stream carries every run's output ------------ + // The web connector expects the same envelope on both channels: agent run + // events on `agent`, session-placement updates on `placement`. + const sseClients = new Set< Response >(); + function sseSend( message: { channel: string; payload: unknown } ): void { + if ( sseClients.size === 0 ) { + return; + } + const data = JSON.stringify( message ); + for ( const client of sseClients ) { + client.write( `data: ${ data }\n\n` ); + } + } + + const runManager = createAgentRunManager( { + cliBinary, + nodeBinary, + surface: 'cliui', + lastBumpStatsProvider: options.lastBumpStatsProvider, + // Agent-run events on `agent`, session-placement updates on `placement`. + emit: ( output ) => sseSend( { channel: output.kind, payload: output.event } ), + } ); + + // Preview snapshots stream their progress on the `snapshot` channel, the + // same shared manager + emit the desktop wires to IPC. + const snapshotManager = createSnapshotManager( { + executeCliCommand: execute, + emit: ( output ) => sseSend( { channel: 'snapshot', payload: output } ), + } ); + + const app = express(); + // All API routes live under `/api` so they can't collide with the SPA's + // real-path routes (`/sessions/:id` is both an app URL and an API resource). + const api = express.Router(); + + // Permissive CORS for local development: the SPA dev server (5400) and this + // server live on different ports. + app.use( ( req: Request, res: Response, next ) => { + res.setHeader( 'Access-Control-Allow-Origin', req.headers.origin ?? '*' ); + res.setHeader( 'Access-Control-Allow-Methods', 'GET,POST,PATCH,DELETE,OPTIONS' ); + res.setHeader( 'Access-Control-Allow-Headers', 'Content-Type' ); + if ( req.method === 'OPTIONS' ) { + res.sendStatus( 204 ); + return; + } + next(); + } ); + + // Generous ceiling a single local user never hits in practice — the server + // is loopback-only, but a runaway client shouldn't be able to hammer it. + app.use( + rateLimit( { windowMs: 60_000, limit: 1_000, standardHeaders: true, legacyHeaders: false } ) + ); + + app.use( express.json() ); + + api.get( '/events', ( req: Request, res: Response ) => { + res.setHeader( 'Content-Type', 'text/event-stream' ); + res.setHeader( 'Cache-Control', 'no-cache' ); + res.setHeader( 'Connection', 'keep-alive' ); + res.flushHeaders?.(); + res.write( ': connected\n\n' ); + sseClients.add( res ); + req.on( 'close', () => { + sseClients.delete( res ); + } ); + } ); + + api.get( '/health', ( _req: Request, res: Response ) => { + res.json( { status: 'ok' } ); + } ); + + // --- Auth — the WordPress.com user the CLI is already logged in as --------- + // Read straight from the shared auth token (`~/.studio/shared.json`); the + // stored token carries the user's id/email/displayName, so no API call. + api.get( + '/auth/user', + asyncHandler( async ( _req: Request, res: Response ) => { + const token = await readAuthToken(); + res.json( + token ? { id: token.id, email: token.email, displayName: token.displayName } : null + ); + } ) + ); + + // Build the WordPress.com authorize URL for the browser (redirect) login flow, + // targeting the caller-provided redirect (its own origin + /auth/callback). + // Returns { url: null } when no OAuth client is configured, so the connector + // falls back to the paste flow. + api.get( '/auth/login-url', ( req: Request, res: Response ) => { + const redirectUri = typeof req.query.redirect_uri === 'string' ? req.query.redirect_uri : ''; + if ( ! uiClientId || ! redirectUri ) { + res.json( { url: null } ); + return; + } + res.json( { url: getAuthenticationUrl( 'en', redirectUri, uiClientId ) } ); + } ); + + // Log in by storing a token from the redirect callback (or pasted from + // WordPress.com's copy-token page — the same implicit-OAuth flow + // `studio auth login` uses). The token is validated against /me before being + // written to the shared config. + api.post( + '/auth/login', + asyncHandler( async ( req: Request, res: Response ) => { + const token = typeof req.body?.token === 'string' ? req.body.token.trim() : ''; + if ( ! token ) { + res.status( 400 ).json( { error: 'token required' } ); + return; + } + let me: { ID: number; email: string; display_name: string }; + try { + const meResponse = await fetch( + 'https://public-api.wordpress.com/rest/v1.1/me?fields=ID,email,display_name', + { headers: { Authorization: `Bearer ${ token }` } } + ); + if ( ! meResponse.ok ) { + res.status( 401 ).json( { error: 'Invalid authentication token' } ); + return; + } + me = ( await meResponse.json() ) as { ID: number; email: string; display_name: string }; + } catch ( error ) { + console.error( 'Auth login: failed to reach WordPress.com:', error ); + res.status( 502 ).json( { error: 'Could not reach WordPress.com to validate the token.' } ); + return; + } + await updateSharedConfig( { + authToken: { + accessToken: token, + id: me.ID, + email: me.email, + displayName: me.display_name, + expiresIn: DEFAULT_TOKEN_LIFETIME_MS / 1000, + expirationTime: Date.now() + DEFAULT_TOKEN_LIFETIME_MS, + }, + } ); + res.json( { id: me.ID, email: me.email, displayName: me.display_name } ); + } ) + ); + + // Log out: best-effort revoke the token on WordPress.com (like the CLI), then + // clear it from the shared config. + api.post( + '/auth/logout', + asyncHandler( async ( _req: Request, res: Response ) => { + const token = await readAuthToken(); + if ( token?.accessToken ) { + await fetch( 'https://public-api.wordpress.com/wpcom/v2/studio-app/token', { + method: 'DELETE', + headers: { Authorization: `Bearer ${ token.accessToken }` }, + } ).catch( () => undefined ); + } + await updateSharedConfig( { authToken: undefined } ); + res.status( 204 ).end(); + } ) + ); + + // --- Sites — the local machine's real Studio sites, via the CLI ----------- + api.get( + '/sites', + asyncHandler( async ( _req: Request, res: Response ) => { + const sites = await listSites( execute ); + res.json( sites.map( toSiteDetails ) ); + } ) + ); + + api.post( + '/sites/:id/start', + asyncHandler( async ( req: Request, res: Response ) => { + const sites = await listSites( execute ); + const site = sites.find( ( candidate ) => candidate.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + await startSite( execute, site.path ); + res.sendStatus( 204 ); + } ) + ); + + api.post( + '/sites/:id/stop', + asyncHandler( async ( req: Request, res: Response ) => { + const sites = await listSites( execute ); + const site = sites.find( ( candidate ) => candidate.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + await stopSite( execute, site.path ); + res.sendStatus( 204 ); + } ) + ); + + // --- Site creation helpers + create --------------------------------------- + // Pure server-side filesystem logic (the server runs on the user's machine), + // plus the CLI `create`. The browser has no native folder picker, so the UI + // proposes/edits the path as text (see capabilities.nativeFolderPicker). + api.get( + '/site-defaults/name', + asyncHandler( async ( _req: Request, res: Response ) => { + const sites = await listSites( execute ); + res.json( { + name: await generateSiteName( + sites.map( ( s ) => s.name ), + sitesRoot + ), + } ); + } ) + ); + + api.get( + '/site-defaults/path', + asyncHandler( async ( req: Request, res: Response ) => { + const sitePath = path.join( sitesRoot, sanitizeFolderName( String( req.query.name ?? '' ) ) ); + try { + res.json( { + path: sitePath, + isEmpty: await isEmptyDir( sitePath ), + isWordPress: isWordPressDirectory( sitePath ), + } ); + } catch ( err ) { + if ( isErrnoException( err ) && err.code === 'ENOENT' ) { + res.json( { path: sitePath, isEmpty: true, isWordPress: false } ); + } else if ( isErrnoException( err ) && err.code === 'ENAMETOOLONG' ) { + res.json( { path: sitePath, isEmpty: false, isWordPress: false, isNameTooLong: true } ); + } else { + throw err; + } + } + } ) + ); + + api.post( '/paths/compare', ( req: Request, res: Response ) => { + const { path1, path2 } = req.body as { path1?: string; path2?: string }; + res.json( { equal: !! path1 && !! path2 && arePathsEqual( path1, path2 ) } ); + } ); + + api.post( + '/sites', + asyncHandler( async ( req: Request, res: Response ) => { + const body = req.body as { + name?: string; + path?: string; + phpVersion?: string; + wpVersion?: string; + customDomain?: string; + enableHttps?: boolean; + adminUsername?: string; + adminPassword?: string; + adminEmail?: string; + // Optional Blueprint to apply on creation: `blueprint` is the parsed + // blueprint JSON; `filePath` (set for uploaded ZIP bundles) lets the + // CLI resolve relative assets. + blueprint?: { + blueprint?: SiteCreateOptions[ 'blueprint' ]; + slug?: string; + filePath?: string; + }; + }; + if ( ! body.name || ! body.path ) { + res.status( 400 ).json( { error: 'name and path are required' } ); + return; + } + const siteId = crypto.randomUUID(); + // Build the create args with the same shared helper the desktop uses, so + // Blueprints (and --wp dev→nightly, etc.) are handled identically. + const { args, cleanup } = buildSiteCreateArgs( { + path: body.path, + name: body.name, + siteId, + wpVersion: body.wpVersion, + phpVersion: body.phpVersion, + customDomain: body.customDomain, + enableHttps: body.enableHttps, + adminUsername: body.adminUsername, + adminPassword: body.adminPassword, + adminEmail: body.adminEmail, + blueprint: body.blueprint?.blueprint, + originalBlueprintPath: body.blueprint?.filePath, + } ); + + try { + await new Promise< void >( ( resolve, reject ) => { + const [ emitter ] = execute( args, { output: 'capture' } ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + } finally { + cleanup(); + } + + const created = ( await listSites( execute ) ).find( ( s ) => s.id === siteId ); + if ( ! created ) { + res.status( 500 ).json( { error: 'Site was created but could not be found.' } ); + return; + } + res.json( toSiteDetails( created ) ); + } ) + ); + + // Delete a site (and, by default, its files) — the CLI `site delete` the + // desktop uses. The connector passes ?deleteFiles=false to keep the files. + api.delete( + '/sites/:id', + asyncHandler( async ( req: Request, res: Response ) => { + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + const keepFiles = req.query.deleteFiles === 'false'; + await new Promise< void >( ( resolve, reject ) => { + const [ emitter ] = execute( + [ 'site', 'delete', '--path', site.path, keepFiles ? '--no-files' : '--files' ], + { output: 'capture' } + ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + res.status( 204 ).end(); + } ) + ); + + // Edit a site's settings — the same CLI `site set` the desktop uses, built + // from the shared arg builder. Mirrors the desktop's diff: only changed + // fields are forwarded (the agentic UI doesn't edit runtime/file-access). + api.post( + '/sites/:id/update', + asyncHandler( async ( req: Request, res: Response ) => { + const { site: updated, wpVersion } = ( req.body ?? {} ) as { + site?: Partial< SiteListItem >; + wpVersion?: string; + }; + const current = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! current ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + if ( ! updated ) { + res.status( 400 ).json( { error: 'Missing site payload' } ); + return; + } + + const options: EditSiteOptions = { path: current.path, siteId: current.id }; + if ( updated.name !== undefined && updated.name !== current.name ) { + options.name = updated.name; + } + if ( ( updated.customDomain ?? '' ) !== ( current.customDomain ?? '' ) ) { + options.domain = updated.customDomain ?? ''; + } + if ( ( updated.enableHttps ?? false ) !== ( current.enableHttps ?? false ) ) { + options.https = updated.enableHttps ?? false; + } + if ( updated.phpVersion !== undefined && updated.phpVersion !== current.phpVersion ) { + options.php = updated.phpVersion; + } + if ( wpVersion ) { + options.wp = isWordPressDevVersion( wpVersion ) ? 'nightly' : wpVersion; + } + if ( ( updated.enableXdebug ?? false ) !== ( current.enableXdebug ?? false ) ) { + options.xdebug = updated.enableXdebug ?? false; + } + if ( ( updated.adminUsername ?? 'admin' ) !== ( current.adminUsername ?? 'admin' ) ) { + options.adminUsername = updated.adminUsername; + } + if ( ( updated.adminPassword ?? '' ) !== ( current.adminPassword ?? '' ) ) { + // The CLI expects a plaintext password (it encodes before saving). + options.adminPassword = decodePassword( updated.adminPassword ?? '' ); + } + if ( ( updated.adminEmail ?? '' ) !== ( current.adminEmail ?? '' ) ) { + options.adminEmail = updated.adminEmail; + } + if ( ( updated.enableDebugLog ?? false ) !== ( current.enableDebugLog ?? false ) ) { + options.debugLog = updated.enableDebugLog ?? false; + } + if ( ( updated.enableDebugDisplay ?? false ) !== ( current.enableDebugDisplay ?? false ) ) { + options.debugDisplay = updated.enableDebugDisplay ?? false; + } + + // More than path + siteId means a real change to apply. + if ( Object.keys( options ).length > 2 ) { + await new Promise< void >( ( resolve, reject ) => { + const [ emitter ] = execute( buildSiteSetArgs( options ), { output: 'capture' } ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + } + const refreshed = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + res.json( toSiteDetails( refreshed ?? current ) ); + } ) + ); + + // Duplicate a site: copy its folder, then register the copy via the CLI + // `create`, which adopts the existing WordPress files (only core is + // refreshed; the copied wp-content + database are preserved). + api.post( + '/sites/:id/copy', + asyncHandler( async ( req: Request, res: Response ) => { + const sites = await listSites( execute ); + const source = sites.find( ( s ) => s.id === req.params.id ); + if ( ! source ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + // ` Copy`, bumped to `Copy 2`, `Copy 3`… — mirrors the desktop. + const newName = await generateNumberedName( + `${ source.name } Copy`, + sites.map( ( s ) => s.name ), + sitesRoot + ); + const newPath = path.join( sitesRoot, sanitizeFolderName( newName ) ); + const newId = crypto.randomUUID(); + await recursiveCopyDirectory( source.path, newPath ); + + // Build the copy's create args with the same shared helper createSite + // uses (no blueprint here, so there's nothing to clean up). + const { args } = buildSiteCreateArgs( { + path: newPath, + name: newName, + siteId: newId, + phpVersion: source.phpVersion, + adminUsername: source.adminUsername || undefined, + adminPassword: source.adminPassword ? decodePassword( source.adminPassword ) : undefined, + adminEmail: source.adminEmail || undefined, + } ); + await new Promise< void >( ( resolve, reject ) => { + const [ emitter ] = execute( args, { output: 'capture' } ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + + const created = ( await listSites( execute ) ).find( ( s ) => s.id === newId ); + // The copied database still points at the source site's URL, so the copy + // would 301-redirect back to the source. Rewrite the URL across the DB to + // the copy's own — the same search-replace the desktop's updateSiteUrl does. + if ( created?.url && source.url && created.url !== source.url ) { + await new Promise< void >( ( resolve, reject ) => { + const [ emitter ] = execute( + [ + 'wp', + '--path', + newPath, + 'search-replace', + source.url, + created.url, + '--skip-columns=guid', + ], + { output: 'capture' } + ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + } + res.json( toSiteDetails( created ?? source ) ); + } ) + ); + + // Export a site to a backup file. The browser has no native Save-As dialog, + // so the server exports to a temp file via the CLI and streams it back as a + // download (the connector turns it into a browser download). + api.get( + '/sites/:id/export', + asyncHandler( async ( req: Request, res: Response ) => { + const mode = req.query.mode === 'database' ? 'db' : 'full'; + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + const dir = mkdtempSync( path.join( os.tmpdir(), 'studio-export-' ) ); + const filename = `${ backupFilename( site.name ) }.${ mode === 'db' ? 'sql' : 'tar.gz' }`; + const dest = path.join( dir, filename ); + const cleanup = () => rm( dir, { recursive: true, force: true }, () => undefined ); + try { + await new Promise< void >( ( resolve, reject ) => { + const [ emitter ] = execute( [ 'export', '--path', site.path, dest, '--mode', mode ], { + output: 'capture', + } ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + } catch ( error ) { + cleanup(); + throw error; + } + res.download( dest, filename, () => cleanup() ); + } ) + ); + + // --- File uploads + import + local media ---------------------------------- + // The browser can't hand the server a filesystem path, so it streams the file + // bytes here; the server saves them to a temp file and returns its path. That + // path then flows into the same path-based CLI operations the desktop uses + // (import a backup, etc.). The raw body streams straight to disk — Express's + // JSON parser ignores the non-JSON content type, so the stream is intact. + api.post( + '/uploads', + asyncHandler( async ( req: Request, res: Response ) => { + // `path.basename` strips any directory components, so the (untrusted) + // name can't escape the unique temp dir; the extension is preserved so + // the importer can infer the backup type. + const rawName = typeof req.query.name === 'string' ? req.query.name : ''; + const safeName = path.basename( rawName ) || 'upload'; + const dir = mkdtempSync( path.join( os.tmpdir(), 'studio-upload-' ) ); + const dest = path.join( dir, safeName ); + try { + await pipeline( req, createWriteStream( dest ) ); + } catch ( error ) { + rm( dir, { recursive: true, force: true }, () => undefined ); + throw error; + } + res.json( { path: dest } ); + } ) + ); + + // Extract an uploaded Blueprint ZIP (the path comes from /uploads) and return + // its parsed blueprint.json — the shared extractor the desktop uses. + api.post( + '/blueprints/extract', + asyncHandler( async ( req: Request, res: Response ) => { + const zipFilePath = req.body?.zipFilePath; + if ( typeof zipFilePath !== 'string' || ! zipFilePath ) { + res.status( 400 ).json( { error: 'Missing zipFilePath' } ); + return; + } + res.json( await extractBlueprintBundle( zipFilePath ) ); + } ) + ); + + api.post( + '/blueprints/cleanup', + asyncHandler( async ( req: Request, res: Response ) => { + const tempDir = req.body?.tempDir; + if ( typeof tempDir === 'string' && tempDir ) { + await cleanupBlueprintTempDir( tempDir ); + } + res.status( 204 ).end(); + } ) + ); + + // Import a backup archive into an already-created site — the same CLI `import` + // the desktop forks. The archive path is normally an upload from `/uploads`, + // which is cleaned up afterwards. + api.post( + '/sites/:id/import', + asyncHandler( async ( req: Request, res: Response ) => { + const archivePath = req.body?.path; + if ( typeof archivePath !== 'string' || ! archivePath ) { + res.status( 400 ).json( { error: 'Missing backup path' } ); + return; + } + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + try { + await new Promise< void >( ( resolve, reject ) => { + const [ emitter ] = execute( [ 'import', '--path', site.path, archivePath ], { + output: 'capture', + } ); + emitter.on( 'success', () => resolve() ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + } finally { + // Drop the uploaded temp copy (only ours, under the OS temp dir). + if ( archivePath.startsWith( os.tmpdir() ) ) { + rm( path.dirname( archivePath ), { recursive: true, force: true }, () => undefined ); + } + } + const imported = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + res.json( toSiteDetails( imported ?? site ) ); + } ) + ); + + // Read a local media file (by absolute path) and stream its bytes back — the + // desktop's `readLocalMediaFile`, used by the agent's media widget. The path + // is already on this machine, so this is a read, not an upload. + api.get( + '/media/read', + asyncHandler( async ( req: Request, res: Response ) => { + const filePath = typeof req.query.path === 'string' ? req.query.path : ''; + if ( ! filePath ) { + res.status( 400 ).json( { error: 'Missing path' } ); + return; + } + const mimeType = getLocalMediaMimeType( filePath ); + if ( ! mimeType ) { + res.status( 415 ).json( { error: 'Local media file type is not supported.' } ); + return; + } + const stats = await stat( filePath ); + if ( ! stats.isFile() ) { + res.status( 400 ).json( { error: 'Local media path must be a file.' } ); + return; + } + res.setHeader( 'Content-Type', mimeType ); + res.setHeader( 'X-Media-Name', encodeURIComponent( path.basename( filePath ) ) ); + res.sendFile( filePath ); + } ) + ); + + // Proxy a WordPress REST request to a running local site — the shared proxy + // the desktop uses, with the target resolved from the site list. + api.post( + '/sites/:id/rest', + asyncHandler( async ( req: Request, res: Response ) => { + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.json( + createJsonResponse( 404, 'studio_site_not_found', `Site ${ req.params.id } not found.` ) + ); + return; + } + const publicUrl = site.url.replace( /\/+$/, '' ); + const baseUrl = site.port > 0 ? `http://127.0.0.1:${ site.port }` : publicUrl; + const response = await fetchSiteRest( + { siteId: site.id, running: site.running, baseUrl, publicUrl }, + ( req.body ?? {} ) as SiteRestRequest + ); + res.json( response ); + } ) + ); + + // --- Open in OS: folder / editor / terminal + app detection --------------- + // The browser can't reach the filesystem, but the server runs on the user's + // machine, so it opens paths in OS apps on the browser's behalf. + + // Editors + terminals detected on disk, so the preferences picker only offers + // what's actually installed. + api.get( + '/installed-apps', + asyncHandler( async ( _req: Request, res: Response ) => { + res.json( detectInstalledApps() ); + } ) + ); + + api.post( + '/sites/:id/open-folder', + asyncHandler( async ( req: Request, res: Response ) => { + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + await openPath( site.path ); + res.status( 204 ).end(); + } ) + ); + + api.post( + '/sites/:id/open-in-editor', + asyncHandler( async ( req: Request, res: Response ) => { + const editor = req.body?.editor; + if ( typeof editor !== 'string' || ! isEditor( editor ) ) { + res.status( 400 ).json( { error: `Unsupported editor: ${ String( editor ) }` } ); + return; + } + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + await openInEditor( editor, site.path ); + res.status( 204 ).end(); + } ) + ); + + api.post( + '/sites/:id/open-in-terminal', + asyncHandler( async ( req: Request, res: Response ) => { + const requested = req.body?.terminal; + // No preference (or an unknown one) falls back to the system terminal. + const terminal = + typeof requested === 'string' && isTerminal( requested ) ? requested : 'terminal'; + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + await openInTerminal( terminal, site.path ); + res.status( 204 ).end(); + } ) + ); + + // --- WordPress.com sync: connected + syncable sites ----------------------- + // All backed by the same @studio/common code the desktop uses. Reads the + // shared auth token (the user logs in via `studio auth login`). + api.get( + '/wpcom/syncable-sites', + asyncHandler( async ( _req: Request, res: Response ) => { + const token = await readAuthToken(); + if ( ! token?.accessToken ) { + res.json( [] ); + return; + } + res.json( await fetchSyncableSites( token.accessToken ) ); + } ) + ); + + api.get( + '/sites/:id/connected-sites', + asyncHandler( async ( req: Request, res: Response ) => { + res.json( await getConnectedWpcomSitesForLocalSite( req.params.id ) ); + } ) + ); + + api.post( + '/sites/:id/connected-sites', + asyncHandler( async ( req: Request, res: Response ) => { + const site = req.body as SyncSite; + res.json( await addConnectedWpcomSite( req.params.id, site ) ); + } ) + ); + + api.delete( + '/sites/:id/connected-sites/:remoteSiteId', + asyncHandler( async ( req: Request, res: Response ) => { + await removeConnectedWpcomSite( req.params.id, Number( req.params.remoteSiteId ) ); + res.sendStatus( 204 ); + } ) + ); + + // --- Preview sites (snapshots) -------------------------------------------- + // Kick off the CLI command and return its operationId immediately; progress + // + the final url/success stream over the SSE `snapshot` channel. + api.get( + '/snapshots', + asyncHandler( async ( _req: Request, res: Response ) => { + res.json( await fetchSnapshots( execute ) ); + } ) + ); + + api.post( + '/sites/:id/preview', + asyncHandler( async ( req: Request, res: Response ) => { + const { hostname, name } = req.body as { hostname?: string; name?: string }; + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + // A hostname means "refresh this existing preview"; otherwise create one. + const { operationId } = hostname + ? snapshotManager.updateSnapshot( site.path, hostname ) + : snapshotManager.createSnapshot( site.path, name ); + res.json( { operationId } ); + } ) + ); + + api.delete( '/snapshots/:hostname', ( req: Request, res: Response ) => { + const { operationId } = snapshotManager.deleteSnapshot( req.params.hostname ); + res.json( { operationId } ); + } ); + + // --- Sync: pull from a connected WordPress.com live site ------------------ + api.post( + '/sites/:id/pull', + asyncHandler( async ( req: Request, res: Response ) => { + const { remoteSiteId } = req.body as { remoteSiteId?: number }; + if ( ! remoteSiteId ) { + res.status( 400 ).json( { error: 'remoteSiteId is required' } ); + return; + } + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + await pullSite( execute, site.path, remoteSiteId ); + res.sendStatus( 204 ); + } ) + ); + + // Push the local site to its connected WordPress.com live site. Long-running + // (export → upload → import); progress streams on the SSE `sync` channel. + // Resolves once the import is initiated. + api.post( + '/sites/:id/push', + asyncHandler( async ( req: Request, res: Response ) => { + const { remoteSiteId } = req.body as { remoteSiteId?: number }; + if ( ! remoteSiteId ) { + res.status( 400 ).json( { error: 'remoteSiteId is required' } ); + return; + } + const token = await readAuthToken(); + if ( ! token?.accessToken ) { + res.status( 401 ).json( { error: 'Authentication required to push.' } ); + return; + } + const site = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + if ( ! site ) { + res.status( 404 ).json( { error: `Site ${ req.params.id } not found` } ); + return; + } + await pushSite( + { + executeCliCommand: execute, + accessToken: token.accessToken, + emit: ( output ) => + sseSend( { + channel: 'sync', + payload: { ...output, siteId: req.params.id, remoteSiteId }, + } ), + }, + { sitePath: site.path, remoteSiteId } + ); + res.sendStatus( 204 ); + } ) + ); + + // --- AI sessions ---------------------------------------------------------- + api.get( + '/sessions', + asyncHandler( async ( _req: Request, res: Response ) => { + res.json( await listHydratedAiSessions( sessionsRoot ) ); + } ) + ); + + api.post( + '/sessions', + asyncHandler( async ( req: Request, res: Response ) => { + // Bind the new chat to the requested local site (the same way the + // desktop does), so "new chat" on a site is placed under it. An empty + // existing draft for that site is reused instead of piling up orphans. + const { siteId } = req.body as { siteId?: string }; + let site; + if ( siteId ) { + const found = ( await listSites( execute ) ).find( ( s ) => s.id === siteId ); + if ( found ) { + site = { id: found.id, name: found.name, path: found.path }; + } + } + res.json( await createOrReuseAiSession( sessionsRoot, { site } ) ); + } ) + ); + + api.get( + '/sessions/:id', + asyncHandler( async ( req: Request, res: Response ) => { + res.json( await loadHydratedAiSession( sessionsRoot, req.params.id ) ); + } ) + ); + + api.delete( + '/sessions/:id', + asyncHandler( async ( req: Request, res: Response ) => { + const deleted = await deleteAiSession( sessionsRoot, req.params.id ); + await deleteSharedSession( deleted.id ); + await deleteAiSessionPlacement( deleted.id ); + res.sendStatus( 204 ); + } ) + ); + + api.patch( + '/sessions/:id', + asyncHandler( async ( req: Request, res: Response ) => { + const { summary } = await loadAiSession( sessionsRoot, req.params.id ); + const patch = req.body as { starred?: boolean; archived?: boolean }; + const [ metadata, placement ] = await Promise.all( [ + updateSharedSession( summary.id, patch ), + readAiSessionPlacement( summary.id ), + ] ); + res.json( hydrateAiSessionSummary( summary, metadata, placement ) ); + } ) + ); + + api.post( + '/sessions/:id/model', + asyncHandler( async ( req: Request, res: Response ) => { + const { model } = req.body as { model?: string }; + if ( ! model || ! isAiModelId( model ) ) { + res.status( 400 ).json( { error: `Unknown AI model: ${ model }` } ); + return; + } + await appendModelChangeEntry( sessionsRoot, req.params.id, '', model ); + res.sendStatus( 204 ); + } ) + ); + + api.post( '/sessions/:id/messages', ( req: Request, res: Response ) => { + const { prompt, displayMessage } = req.body as { prompt?: string; displayMessage?: string }; + if ( ! prompt ) { + res.status( 400 ).json( { error: 'prompt is required' } ); + return; + } + const { runId } = runManager.startAgentRun( { + sessionId: req.params.id, + prompt, + displayMessage, + } ); + res.json( { runId } ); + } ); + + // --- Runs ----------------------------------------------------------------- + api.get( '/runs/active', ( _req: Request, res: Response ) => { + res.json( runManager.listActiveAgentRuns() ); + } ); + + api.post( '/runs/:runId/interrupt', ( req: Request, res: Response ) => { + runManager.interruptAgentRun( req.params.runId ); + res.sendStatus( 204 ); + } ); + + api.post( '/runs/:runId/answer', ( req: Request, res: Response ) => { + const { answers } = req.body as { answers?: Record< string, string > }; + runManager.answerAgentRun( req.params.runId, answers ?? {} ); + res.sendStatus( 204 ); + } ); + + app.use( '/api', api ); + + // OAuth redirect target for the browser login flow (registered before the SPA + // fallback so it isn't swallowed by it). + app.get( '/auth/callback', ( _req: Request, res: Response ) => { + res.setHeader( 'Content-Type', 'text/html; charset=utf-8' ); + res.send( AUTH_CALLBACK_HTML ); + } ); + + // --- Web UI --------------------------------------------------------------- + const uiIndex = uiDist ? path.join( uiDist, 'index.local.html' ) : undefined; + const hasUi = !! uiIndex && existsSync( uiIndex ); + if ( uiDist && hasUi ) { + app.use( express.static( uiDist ) ); + // SPA fallback: the app uses real-path routing, so any unmatched HTML + // navigation reloads into the app shell. API routes keep precedence. + app.get( '*', ( req: Request, res: Response, next ) => { + if ( ( req.headers.accept ?? '' ).includes( 'text/html' ) ) { + res.sendFile( uiIndex! ); + return; + } + next(); + } ); + } + + app.use( ( err: unknown, _req: Request, res: Response, _next: ( e?: unknown ) => void ) => { + const message = err instanceof Error ? err.message : String( err ); + res.status( 500 ).json( { error: message } ); + } ); + + const server = await new Promise< ReturnType< typeof app.listen > >( ( resolve ) => { + const listening = app.listen( port, host, () => resolve( listening ) ); + } ); + + const url = `http://localhost:${ port }`; + + return { + url, + port, + async close() { + cliRunner.killAll(); + for ( const client of sseClients ) { + client.end(); + } + sseClients.clear(); + await new Promise< void >( ( resolve ) => server.close( () => resolve() ) ); + }, + }; +} diff --git a/apps/local/src/open-in-os.ts b/apps/local/src/open-in-os.ts new file mode 100644 index 0000000000..78e76bce40 --- /dev/null +++ b/apps/local/src/open-in-os.ts @@ -0,0 +1,122 @@ +import { execFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { SUPPORTED_EDITORS, supportedEditorConfig } from '@studio/common/lib/user-settings/editor'; +import { SUPPORTED_TERMINALS, terminalConfig } from '@studio/common/lib/user-settings/terminal'; +import type { SupportedEditor } from '@studio/common/lib/user-settings/editor'; +import type { SupportedTerminal } from '@studio/common/lib/user-settings/terminal'; + +/** + * The browser can't touch the filesystem, but the local server runs on the + * user's own machine, so "open in Finder / editor / terminal" still works — the + * server just delegates to the OS instead of Electron's `shell`. The launch + * targets (bundle IDs, $PATH commands, Windows paths) come from the same shared + * `@studio/common/lib/user-settings` config the desktop uses; only the syscall + * differs (Electron `shell.openPath` → `child_process`), the same runtime seam + * as IPC-vs-HTTP. App DETECTION is shared (`installed-apps.ts`); this file is + * only the launch side. macOS is exercised by `studio ui`; the Windows/Linux + * branches mirror the desktop handlers but aren't covered here. + */ + +const execFileAsync = promisify( execFile ); + +// macOS bundle IDs for terminals (editors carry theirs in the shared config). +const MACOS_TERMINAL_BUNDLE_IDS: Record< SupportedTerminal, string > = { + warp: 'dev.warp.Warp-Stable', + ghostty: 'com.mitchellh.ghostty', + iterm: 'com.googlecode.iterm2', + terminal: 'com.apple.Terminal', +}; + +export function isEditor( key: string ): key is SupportedEditor { + return ( SUPPORTED_EDITORS as readonly string[] ).includes( key ); +} + +export function isTerminal( key: string ): key is SupportedTerminal { + return ( SUPPORTED_TERMINALS as readonly string[] ).includes( key ); +} + +function existsOnPath( command: string ): boolean { + const dirs = ( process.env.PATH ?? '' ).split( path.delimiter ).filter( Boolean ); + return dirs.some( ( dir ) => existsSync( path.join( dir, command ) ) ); +} + +function expandWinEnv( p: string ): string { + return p.replace( /%([^%]+)%/g, ( _m, name ) => process.env[ name ] ?? '' ); +} + +async function openUrl( url: string ): Promise< void > { + if ( process.platform === 'darwin' ) { + await execFileAsync( 'open', [ url ] ); + } else if ( process.platform === 'win32' ) { + await execFileAsync( 'cmd', [ '/c', 'start', '""', url ] ); + } else { + await execFileAsync( 'xdg-open', [ url ] ); + } +} + +// Open a path in the OS file manager (the desktop's Electron `shell.openPath`). +export async function openPath( target: string ): Promise< void > { + if ( process.platform === 'darwin' ) { + await execFileAsync( 'open', [ target ] ); + return; + } + if ( process.platform === 'win32' ) { + // explorer.exe returns a non-zero exit code even on success. + await execFileAsync( 'explorer', [ target ] ).catch( () => undefined ); + return; + } + await execFileAsync( 'xdg-open', [ target ] ); +} + +export async function openInEditor( editor: SupportedEditor, target: string ): Promise< void > { + const config = supportedEditorConfig[ editor ]; + if ( process.platform === 'darwin' ) { + await execFileAsync( 'open', [ '-b', config.macOSBundleId, target ] ); + return; + } + if ( process.platform === 'linux' ) { + const command = config.linuxCommands.find( ( c ) => existsOnPath( c ) ); + if ( command ) { + await execFileAsync( command, [ target ] ); + return; + } + } + if ( process.platform === 'win32' ) { + const exe = config.winPaths.map( expandWinEnv ).find( ( p ) => existsSync( p ) ); + if ( exe ) { + await execFileAsync( exe, [ target ] ); + return; + } + } + // Fall back to the editor's URL scheme via the OS opener. + await openUrl( config.url( target ) ); +} + +export async function openInTerminal( + terminal: SupportedTerminal, + target: string +): Promise< void > { + if ( process.platform === 'darwin' ) { + await execFileAsync( 'open', [ '-b', MACOS_TERMINAL_BUNDLE_IDS[ terminal ], target ] ); + return; + } + if ( process.platform === 'win32' ) { + await execFileAsync( + 'cmd', + [ '/c', 'start', 'Command Prompt', process.env.ComSpec || 'cmd.exe' ], + { + cwd: target, + } + ); + return; + } + // Linux: prefer the chosen terminal's command, falling back to gnome-terminal. + const command = terminalConfig[ terminal ].linuxCommands.find( ( c ) => existsOnPath( c ) ); + if ( command === 'warp-terminal' ) { + await execFileAsync( command, [], { cwd: target } ); + } else { + await execFileAsync( command ?? 'gnome-terminal', [ `--working-directory=${ target }` ] ); + } +} diff --git a/apps/local/tsconfig.json b/apps/local/tsconfig.json new file mode 100644 index 0000000000..8ddaa5c19e --- /dev/null +++ b/apps/local/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "paths": { + "@studio/common/*": [ "../../tools/common/*" ] + } + }, + "include": [ "**/*" ], + "exclude": [ "**/node_modules/**/*", "**/dist/**/*", "**/out/**/*" ] +} diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 3cc74ae643..3628a19c2a 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -22,12 +22,16 @@ import { validateStudioChatFiles } from '@studio/common/ai/chat-files'; import { validateStudioChatImages } from '@studio/common/ai/chat-images'; import { isAiModelId } from '@studio/common/ai/models'; import { deriveEffectiveEnvironment } from '@studio/common/ai/sessions/effective-site'; +import { + createOrReuseAiSession, + hydrateAiSessionSummary, + listHydratedAiSessions, + loadHydratedAiSession, +} from '@studio/common/ai/sessions/manage'; import { appendModelChangeEntry, appendStudioEntry, - createAiSession as createAiSessionInStore, deleteAiSession as deleteAiSessionFromStore, - listAiSessions as listAiSessionsFromStore, loadAiSession as loadAiSessionFromStore, } from '@studio/common/ai/sessions/store'; import { AI_SKILL_COMMANDS, buildSkillInvocationPrompt } from '@studio/common/ai/slash-commands'; @@ -44,7 +48,6 @@ import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error'; import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites'; import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore'; -import { extractZip } from '@studio/common/lib/extract-zip'; import { calculateDirectorySizeForArchive, isWordPressDirectory, @@ -58,6 +61,7 @@ import { generateNumberedName, generateSiteName } from '@studio/common/lib/gener import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { isMultisite } from '@studio/common/lib/is-multisite'; +import { getLocalMediaMimeType } from '@studio/common/lib/media-mime'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; import { decodePassword, encodePassword } from '@studio/common/lib/passwords'; import { @@ -72,8 +76,6 @@ import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; import { deleteSharedSession, readSharedConfig, - readSharedSession, - readSharedSessions, updateSharedConfig, updateSharedSession, } from '@studio/common/lib/shared-config'; @@ -83,6 +85,10 @@ import { SYNC_IGNORE_DEFAULTS } from '@studio/common/lib/sync/constants'; import { shouldExcludeFromSync } from '@studio/common/lib/sync/exclude-from-sync'; import { shouldLimitDepth } from '@studio/common/lib/sync/tree-utils'; import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; +import { + cleanupBlueprintTempDir as cleanupBlueprintTempDirShared, + extractBlueprintBundle as extractBlueprintBundleShared, +} from '@studio/common/sites/blueprint-extract'; import { __, sprintf, LocaleData, defaultI18n } from '@wordpress/i18n'; import { MACOS_TRAFFIC_LIGHT_POSITION, @@ -91,13 +97,7 @@ import { WINDOWS_TITLEBAR_HEIGHT, } from 'src/constants'; import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; -import { - deleteAiSessionPlacement, - hydrateAiSessionSummaryWithPlacement, - readAiSessionPlacement, - readAiSessionPlacements, - setAiSessionSitePlacement, -} from 'src/lib/ai-session-placement'; +import { deleteAiSessionPlacement, readAiSessionPlacement } from 'src/lib/ai-session-placement'; import { getAiSessionsRootDirectory } from 'src/lib/ai-sessions'; import { getBetaFeatures as getBetaFeaturesFromLib } from 'src/lib/beta-features'; import { @@ -202,6 +202,7 @@ export { pauseSyncUpload, pullSiteFromLive, pushArchive, + pushSiteToLive, removeSyncBackup, resumeSyncUpload, updateConnectedWpcomSites, @@ -237,49 +238,6 @@ export { importSite, exportSite } from 'src/modules/import-export/lib/ipc-handle export { fetchSiteRest as fetchSiteRestApi } from 'src/lib/wordpress-rest-api'; -function hydrateAiSessionSummary( - summary: AiSessionSummary, - metadata?: Pick< AiSessionSummary, 'starred' | 'archived' > -): AiSessionSummary { - return { - ...summary, - starred: metadata?.starred, - archived: metadata?.archived, - }; -} - -async function listHydratedAiSessions( rootDirectory: string ): Promise< AiSessionSummary[] > { - const [ sessions, sessionMetadata, placements ] = await Promise.all( [ - listAiSessionsFromStore( rootDirectory ), - readSharedSessions(), - readAiSessionPlacements(), - ] ); - return sessions.map( ( session ) => - hydrateAiSessionSummary( - hydrateAiSessionSummaryWithPlacement( session, placements[ session.id ] ), - sessionMetadata[ session.id ] - ) - ); -} - -async function loadHydratedAiSession( - rootDirectory: string, - sessionIdOrPrefix: string -): Promise< LoadedAiSession > { - const session = await loadAiSessionFromStore( rootDirectory, sessionIdOrPrefix ); - const [ metadata, placement ] = await Promise.all( [ - readSharedSession( session.summary.id ), - readAiSessionPlacement( session.summary.id ), - ] ); - return { - ...session, - summary: hydrateAiSessionSummary( - hydrateAiSessionSummaryWithPlacement( session.summary, placement ), - metadata - ), - }; -} - export async function listAiSessions( _event: IpcMainInvokeEvent ): Promise< AiSessionSummary[] > { return listHydratedAiSessions( getAiSessionsRootDirectory() ); } @@ -307,58 +265,24 @@ export async function createAiSession( ): Promise< AiSessionSummary > { const sitesRoot = getAiSessionsRootDirectory(); if ( ! siteId ) { - const existing = await listHydratedAiSessions( sitesRoot ); - const emptyUserSession = existing - .filter( - ( session ) => ! session.ownerSitePath && ! session.firstPrompt && ! session.archived - ) - .sort( ( a, b ) => Date.parse( b.updatedAt ) - Date.parse( a.updatedAt ) )[ 0 ]; - - if ( emptyUserSession ) { - return emptyUserSession; - } - - return hydrateAiSessionSummary( await createAiSessionInStore( sitesRoot ) ); + return createOrReuseAiSession( sitesRoot ); } const server = SiteServer.get( siteId ); if ( ! server ) { throw new Error( `Site not found: ${ siteId }` ); } - const sitePath = server.details.path; - - // Reuse the newest existing empty session for this site (one that has - // never received a user prompt) instead of creating another one. This - // lets `/sites/$siteId/new` act as a stable "draft slot" per site — the - // UI can redirect to it eagerly without piling up orphan sessions. - const existing = await listHydratedAiSessions( sitesRoot ); - const emptyForSite = existing - .filter( - ( session ) => - session.ownerSitePath === sitePath && ! session.firstPrompt && ! session.archived - ) - .sort( ( a, b ) => Date.parse( b.updatedAt ) - Date.parse( a.updatedAt ) )[ 0 ]; - if ( emptyForSite ) { - return emptyForSite; - } - const created = await createAiSessionInStore( sitesRoot, { + // Binds the session to the site and reuses an existing empty draft for it + // instead of piling up orphans — the shared logic the `studio ui` server + // uses too. + return createOrReuseAiSession( sitesRoot, { site: { + id: server.details.id, name: server.details.name, - path: sitePath, + path: server.details.path, }, } ); - await setAiSessionSitePlacement( created.id, { - siteId: server.details.id, - siteName: server.details.name, - sitePath, - } ); - return hydrateAiSessionSummaryWithPlacement( created, { - kind: 'site', - siteId: server.details.id, - siteName: server.details.name, - sitePath, - } ); } export async function updateAiSessionMetadata( @@ -374,10 +298,7 @@ export async function updateAiSessionMetadata( updateSharedSession( summary.id, patch ), readAiSessionPlacement( summary.id ), ] ); - return hydrateAiSessionSummary( - hydrateAiSessionSummaryWithPlacement( summary, placement ), - metadata - ); + return hydrateAiSessionSummary( summary, metadata, placement ); } /** @@ -1461,36 +1382,6 @@ export async function readLocalMediaFile( }; } -function getLocalMediaMimeType( path: string ) { - const extension = nodePath.extname( path ).toLowerCase().slice( 1 ); - const mimeTypes: Record< string, string > = { - avif: 'image/avif', - avi: 'video/x-msvideo', - bmp: 'image/bmp', - gif: 'image/gif', - heic: 'image/heic', - heif: 'image/heif', - ico: 'image/x-icon', - jpeg: 'image/jpeg', - jpg: 'image/jpeg', - m4v: 'video/x-m4v', - mkv: 'video/x-matroska', - mov: 'video/quicktime', - mp4: 'video/mp4', - mpeg: 'video/mpeg', - mpg: 'video/mpeg', - ogv: 'video/ogg', - png: 'image/png', - svg: 'image/svg+xml', - tif: 'image/tiff', - tiff: 'image/tiff', - webm: 'video/webm', - webp: 'image/webp', - }; - - return mimeTypes[ extension ] ?? ''; -} - // Update a site's theme details and thumbnail. Emit the appropriate IPC events to the renderer // process. export async function loadThemeDetails( @@ -2307,48 +2198,15 @@ export async function readBlueprintFile( return JSON.parse( fileContents ); } -export async function extractBlueprintBundle( - _event: IpcMainInvokeEvent, - zipFilePath: string -): Promise< { - blueprintJson: Blueprint[ 'blueprint' ]; - blueprintJsonPath: string; - tempDir: string; -} > { - const resolvedZipPath = nodePath.resolve( zipFilePath ); - const tempDir = await fsPromises.mkdtemp( - nodePath.join( os.tmpdir(), 'studio-blueprint-bundle-' ) - ); - - try { - await extractZip( resolvedZipPath, tempDir ); - - const blueprintJsonPath = nodePath.join( tempDir, 'blueprint.json' ); - try { - await fsPromises.access( blueprintJsonPath ); - } catch { - throw new Error( - __( - 'No blueprint.json found in the ZIP file. Please ensure the ZIP contains a blueprint.json at its root.' - ) - ); - } - - const fileContents = await fsPromises.readFile( blueprintJsonPath, 'utf-8' ); - const blueprintJson = JSON.parse( fileContents ); - - return { blueprintJson, blueprintJsonPath, tempDir }; - } catch ( error ) { - await fsPromises.rm( tempDir, { recursive: true, force: true } ); - throw error; - } +export async function extractBlueprintBundle( _event: IpcMainInvokeEvent, zipFilePath: string ) { + return extractBlueprintBundleShared( zipFilePath ); } export async function cleanupBlueprintTempDir( _event: IpcMainInvokeEvent, tempDir: string ): Promise< void > { - await removeBlueprintTempDir( tempDir ); + await cleanupBlueprintTempDirShared( tempDir ); } export async function setWindowControlVisibility( event: IpcMainInvokeEvent, visible: boolean ) { diff --git a/apps/studio/src/lib/ai-session-placement.ts b/apps/studio/src/lib/ai-session-placement.ts index cc30a254dc..c67f37bdc3 100644 --- a/apps/studio/src/lib/ai-session-placement.ts +++ b/apps/studio/src/lib/ai-session-placement.ts @@ -1,85 +1,13 @@ -import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; -import type { AiSessionSummary } from '@studio/common/ai/sessions/types'; -import type { AiSessionSitePlacement } from 'src/storage/storage-types'; - -export type { AiSessionSitePlacement }; - -export interface AiSessionPlacementUpdatedEvent { - sessionId: string; - placement: AiSessionSitePlacement; -} - -export async function readAiSessionPlacements(): Promise< - Record< string, AiSessionSitePlacement > -> { - const userData = await loadUserData(); - return userData.aiSessionPlacements ?? {}; -} - -export async function readAiSessionPlacement( - sessionId: string -): Promise< AiSessionSitePlacement | undefined > { - const placements = await readAiSessionPlacements(); - return placements[ sessionId ]; -} - -export async function setAiSessionSitePlacement( - sessionId: string, - placement: Omit< AiSessionSitePlacement, 'kind' > -): Promise< AiSessionSitePlacement > { - try { - await lockAppdata(); - const userData = await loadUserData(); - const nextPlacement: AiSessionSitePlacement = { - kind: 'site', - ...placement, - }; - await saveUserData( { - ...userData, - aiSessionPlacements: { - ...( userData.aiSessionPlacements ?? {} ), - [ sessionId ]: nextPlacement, - }, - } ); - return nextPlacement; - } finally { - await unlockAppdata(); - } -} - -export async function deleteAiSessionPlacement( sessionId: string ): Promise< void > { - try { - await lockAppdata(); - const userData = await loadUserData(); - if ( ! userData.aiSessionPlacements?.[ sessionId ] ) { - return; - } - const { [ sessionId ]: _deleted, ...remainingPlacements } = userData.aiSessionPlacements; - await saveUserData( { - ...userData, - aiSessionPlacements: - Object.keys( remainingPlacements ).length > 0 ? remainingPlacements : undefined, - } ); - } finally { - await unlockAppdata(); - } -} - -export function hydrateAiSessionSummaryWithPlacement( - summary: AiSessionSummary, - placement?: AiSessionSitePlacement -): AiSessionSummary { - if ( ! placement ) { - return { - ...summary, - ownerSitePath: undefined, - ownerSiteName: undefined, - }; - } - - return { - ...summary, - ownerSitePath: placement.sitePath, - ownerSiteName: placement.siteName, - }; -} +// AI session placement now lives in `@studio/common` so the desktop app and the +// `studio ui` server record and read it identically (app.json, coordinated by +// the same lockfile). This module is a re-export kept so existing desktop +// imports (`src/lib/ai-session-placement`) keep resolving. +export { + readAiSessionPlacements, + readAiSessionPlacement, + setAiSessionSitePlacement, + deleteAiSessionPlacement, + hydrateAiSessionSummaryWithPlacement, + type AiSessionSitePlacement, + type AiSessionPlacementUpdatedEvent, +} from '@studio/common/ai/sessions/placement'; diff --git a/apps/studio/src/lib/bump-stats.ts b/apps/studio/src/lib/bump-stats.ts index ce3ccc3223..9a9a3b672c 100644 --- a/apps/studio/src/lib/bump-stats.ts +++ b/apps/studio/src/lib/bump-stats.ts @@ -58,7 +58,9 @@ export enum StatsMetric { NO_BLUEPRINT = 'no-blueprint', } -const lastBumpStatsProvider: LastBumpStatsProvider = { +// Exported so the shared agent run-manager can record weekly/monthly unique +// stats against the desktop's app.json store. +export const lastBumpStatsProvider: LastBumpStatsProvider = { load: async () => { const { lastBumpStats } = await loadUserData(); return lastBumpStats ?? {}; diff --git a/apps/studio/src/lib/is-installed.ts b/apps/studio/src/lib/is-installed.ts index d572f4b6eb..e2b75374c8 100644 --- a/apps/studio/src/lib/is-installed.ts +++ b/apps/studio/src/lib/is-installed.ts @@ -1,180 +1,9 @@ -import { app } from 'electron'; -import fs from 'fs'; -import path from 'path'; -import { findOnPath } from 'src/lib/find-on-path'; -import { SUPPORTED_EDITORS, supportedEditorConfig } from 'src/modules/user-settings/lib/editor'; -import { SUPPORTED_TERMINALS, terminalConfig } from 'src/modules/user-settings/lib/terminal'; - -type PlatformPaths = { - [ K in keyof InstalledApps ]: string[]; -}; - -function getProgramFilesPath(): string { - if ( process.platform !== 'win32' ) { - return 'C:\\Program Files'; - } - - // This env var dinamically points to the Program Files path - // See https://stackoverflow.com/a/9608782 - const programFiles = process.env.ProgramFiles; - if ( programFiles ) { - return programFiles; - } - - // Fallback to default path if environment variable is not available - return 'C:\\Program Files'; -} - -function getLocalProgramsPath(): string { - if ( process.platform !== 'win32' ) { - return app.getPath( 'appData' ); - } - - const localAppData = process.env.LOCALAPPDATA; - if ( localAppData ) { - return path.win32.join( localAppData, 'Programs' ); - } - - // Fallback to electron's appData path if environment variable is not available - return path.win32.join( app.getPath( 'appData' ), 'Local', 'Programs' ); -} - -// Define installation paths for each IDE by platform -const installationPaths: Record< string, PlatformPaths > = { - darwin: { - antigravity: [ 'Antigravity.app' ], - vscode: [ 'Visual Studio Code.app' ], - phpstorm: [ 'PhpStorm.app' ], - cursor: [ 'Cursor.app' ], - windsurf: [ 'Windsurf.app' ], - webstorm: [ 'WebStorm.app' ], - sublime: [ 'Sublime Text.app' ], - zed: [ 'Zed.app' ], - iterm: [ 'iTerm.app' ], - terminal: [ 'Terminal.app' ], - warp: [ 'Warp.app' ], - ghostty: [ 'Ghostty.app' ], - }, - // Linux editor and terminal detection is driven by `linuxCommands` - // (resolved via $PATH in `isInstalled`), so those entries are intentionally - // omitted from this map. Only the non-resolvable iterm entry lives here. - linux: { - antigravity: [], - vscode: [], - phpstorm: [], - cursor: [], - windsurf: [], - webstorm: [], - sublime: [], - zed: [], - iterm: [], - terminal: [], - warp: [], - ghostty: [], - }, - win32: { - antigravity: [ - path.win32.join( getLocalProgramsPath(), 'Antigravity' ), - path.win32.join( getProgramFilesPath(), 'Google\\Antigravity' ), - ], - vscode: [ - path.win32.join( getProgramFilesPath(), 'Microsoft VS Code' ), - path.win32.join( getLocalProgramsPath(), 'Microsoft VS Code' ), - ], - phpstorm: [ - path.win32.join( getProgramFilesPath(), 'JetBrains\\PhpStorm' ), - path.win32.join( getLocalProgramsPath(), 'PhpStorm' ), - ], - cursor: [ - path.win32.join( getProgramFilesPath(), 'Cursor' ), - path.win32.join( getLocalProgramsPath(), 'cursor' ), - ], - windsurf: [ - path.win32.join( getProgramFilesPath(), 'Windsurf' ), - path.win32.join( getLocalProgramsPath(), 'Windsurf' ), - ], - webstorm: [ - path.win32.join( getProgramFilesPath(), 'JetBrains\\WebStorm' ), - path.win32.join( getLocalProgramsPath(), 'WebStorm' ), - ], - sublime: [ - path.win32.join( getProgramFilesPath(), 'Sublime Text' ), - path.win32.join( getProgramFilesPath(), 'Sublime Text 4' ), - path.win32.join( getProgramFilesPath(), 'Sublime Text 3' ), - ], - zed: [ path.win32.join( getLocalProgramsPath(), 'Zed' ) ], - iterm: [], - terminal: [], - warp: [ - path.win32.join( getLocalProgramsPath(), 'Warp' ), - path.win32.join( getProgramFilesPath(), 'Warp' ), - ], - ghostty: [], - }, -}; - -if ( process.platform === 'darwin' ) { - const systemApplications = '/Applications'; - const userApplications = path.join( app.getPath( 'home' ), 'Applications' ); - - Object.keys( installationPaths.darwin ).forEach( ( ide ) => { - const appName = installationPaths.darwin[ ide as keyof InstalledApps ][ 0 ]; - if ( appName ) { - installationPaths.darwin[ ide as keyof InstalledApps ] = [ - path.join( systemApplications, appName ), - path.join( userApplications, appName ), - ]; - } - } ); -} else if ( process.platform === 'win32' ) { - // For JetBrains IDEs, check for version-specific folders - [ 'phpstorm', 'webstorm' ].forEach( ( ide ) => { - const basePaths = installationPaths.win32[ ide as keyof InstalledApps ]; - const jetbrainsDir = path.win32.join( getProgramFilesPath(), 'JetBrains' ); - - if ( fs.existsSync( jetbrainsDir ) ) { - const entries = fs.readdirSync( jetbrainsDir ); - - entries.forEach( ( entry ) => { - if ( entry.toLowerCase().includes( ide ) ) { - basePaths.push( path.win32.join( jetbrainsDir, entry ) ); - } - } ); - } - } ); -} - -function isSupportedEditor( - key: keyof InstalledApps -): key is ( typeof SUPPORTED_EDITORS )[ number ] { - return ( SUPPORTED_EDITORS as readonly string[] ).includes( key ); -} - -function isSupportedTerminal( - key: keyof InstalledApps -): key is ( typeof SUPPORTED_TERMINALS )[ number ] { - return ( SUPPORTED_TERMINALS as readonly string[] ).includes( key ); -} - -export function isInstalled( key: keyof InstalledApps ): boolean { - const platform = process.platform; - - // On Linux, editors and terminals live in many places (apt, snap, flatpak, - // ~/.local/bin, …). Resolve them against $PATH using their `linuxCommands` - // instead of hardcoded paths. - if ( platform === 'linux' ) { - if ( isSupportedEditor( key ) ) { - const editor = supportedEditorConfig[ key ]; - return editor.linuxCommands.some( ( command ) => findOnPath( command ) !== null ); - } - if ( isSupportedTerminal( key ) ) { - const terminal = terminalConfig[ key ]; - return terminal.linuxCommands.some( ( command ) => findOnPath( command ) !== null ); - } - } - - const paths = installationPaths[ platform ]?.[ key ]; - - // Return true if any of the possible paths exist - return paths.some( ( pathStr: string ) => pathStr && fs.existsSync( pathStr ) ); -} +// Installed-app detection now lives in @studio/common so the desktop app and +// the local web server (`studio ui`) share one implementation. This re-export +// keeps existing desktop imports — and the tests that mock this module — working. +export { + detectInstalledApps, + isInstalled, + type AppKey, + type InstalledApps, +} from '@studio/common/lib/user-settings/installed-apps'; diff --git a/apps/studio/src/lib/tests/ai-session-placement.test.ts b/apps/studio/src/lib/tests/ai-session-placement.test.ts deleted file mode 100644 index a2fbe59f0c..0000000000 --- a/apps/studio/src/lib/tests/ai-session-placement.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - deleteAiSessionPlacement, - hydrateAiSessionSummaryWithPlacement, - readAiSessionPlacements, - setAiSessionSitePlacement, -} from 'src/lib/ai-session-placement'; -import { EMPTY_USER_DATA, type UserData } from 'src/storage/storage-types'; -import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; -import type { AiSessionSummary } from '@studio/common/ai/sessions/types'; - -vi.mock( 'src/storage/user-data', () => ( { - loadUserData: vi.fn(), - lockAppdata: vi.fn(), - saveUserData: vi.fn(), - unlockAppdata: vi.fn(), -} ) ); - -describe( 'ai session placement', () => { - let userData: UserData; - - beforeEach( () => { - userData = structuredClone( EMPTY_USER_DATA ); - vi.mocked( loadUserData ).mockImplementation( async () => structuredClone( userData ) ); - vi.mocked( saveUserData ).mockImplementation( async ( nextUserData ) => { - userData = structuredClone( nextUserData ); - } ); - vi.mocked( lockAppdata ).mockResolvedValue( undefined ); - vi.mocked( unlockAppdata ).mockResolvedValue( undefined ); - } ); - - it( 'stores site placements in appdata', async () => { - const placement = await setAiSessionSitePlacement( 'session-1', { - siteId: 'site-1', - sitePath: '/sites/site-1', - siteName: 'Site One', - } ); - - expect( placement ).toEqual( { - kind: 'site', - siteId: 'site-1', - sitePath: '/sites/site-1', - siteName: 'Site One', - } ); - await expect( readAiSessionPlacements() ).resolves.toEqual( { - 'session-1': placement, - } ); - expect( lockAppdata ).toHaveBeenCalled(); - expect( unlockAppdata ).toHaveBeenCalled(); - } ); - - it( 'removes empty placement maps after deletion', async () => { - await setAiSessionSitePlacement( 'session-1', { - siteId: 'site-1', - sitePath: '/sites/site-1', - siteName: 'Site One', - } ); - - await deleteAiSessionPlacement( 'session-1' ); - - expect( userData.aiSessionPlacements ).toBeUndefined(); - } ); - - it( 'hydrates owner fields only from desktop placement', () => { - const summary = { - id: 'session-1', - filePath: '/sessions/session-1.jsonl', - createdAt: '2026-05-13T00:00:00.000Z', - updatedAt: '2026-05-13T00:00:00.000Z', - ownerSitePath: '/ignored/from-jsonl', - ownerSiteName: 'Ignored', - activeEnvironment: 'local', - eventCount: 1, - } satisfies AiSessionSummary; - - expect( hydrateAiSessionSummaryWithPlacement( summary ) ).toMatchObject( { - ownerSitePath: undefined, - ownerSiteName: undefined, - } ); - expect( - hydrateAiSessionSummaryWithPlacement( summary, { - kind: 'site', - siteId: 'site-1', - sitePath: '/sites/site-1', - siteName: 'Site One', - } ) - ).toMatchObject( { - ownerSitePath: '/sites/site-1', - ownerSiteName: 'Site One', - } ); - } ); -} ); diff --git a/apps/studio/src/lib/wordpress-rest-api.ts b/apps/studio/src/lib/wordpress-rest-api.ts index fbcb00c6e2..7b79ff9319 100644 --- a/apps/studio/src/lib/wordpress-rest-api.ts +++ b/apps/studio/src/lib/wordpress-rest-api.ts @@ -1,15 +1,14 @@ +import { + createJsonResponse, + fetchSiteRest as fetchSiteRestShared, +} from '@studio/common/lib/wordpress-rest'; import { SiteServer } from 'src/site-server'; import type { SiteRestRequest, SiteRestResponse } from '@studio/common/types/wordpress-rest'; import type { IpcMainInvokeEvent } from 'electron'; -interface SiteRestAuth { - baseUrl: string; - cookie: string; - nonce: string; -} - -const siteRestAuthCache = new Map< string, SiteRestAuth >(); - +// Thin desktop wrapper: resolve the running site's URLs from its SiteServer, then +// hand off to the shared, transport-agnostic REST proxy (also used by the local +// web server). Only site resolution is Electron-specific here. export async function fetchSiteRest( _event: IpcMainInvokeEvent, siteId: string, @@ -20,238 +19,16 @@ export async function fetchSiteRest( return createJsonResponse( 404, 'studio_site_not_found', `Site ${ siteId } not found.` ); } - if ( ! server.details.running ) { - return createJsonResponse( 503, 'studio_site_not_running', `Site ${ siteId } is not running.` ); - } - - let url: URL; - try { - url = getSiteRestUrl( server, request ); - } catch ( error ) { - return createJsonResponse( - 400, - 'studio_invalid_rest_request', - error instanceof Error ? error.message : 'Invalid REST request.' - ); - } - - return fetchSiteRestWithAuth( server, url, request, true ); -} - -async function fetchSiteRestWithAuth( - server: SiteServer, - url: URL, - request: SiteRestRequest, - allowAuthRefresh: boolean -): Promise< SiteRestResponse > { - const baseUrl = getSiteBaseUrl( server ); - const headers = await getRequestHeaders( server, baseUrl, request.headers ); - if ( request.data !== undefined && ! hasHeader( headers, 'content-type' ) ) { - headers[ 'Content-Type' ] = 'application/json'; - } - const response = await fetch( url, { - method: request.method ?? 'GET', - headers, - body: getRequestBody( request ), - redirect: 'follow', - } ); - - if ( allowAuthRefresh && ( response.status === 401 || response.status === 403 ) ) { - siteRestAuthCache.delete( server.details.id ); - return fetchSiteRestWithAuth( server, url, request, false ); - } - - return serializeResponse( response ); -} - -function getSiteBaseUrl( server: SiteServer ) { - if ( server.details.port > 0 ) { - return `http://127.0.0.1:${ server.details.port }`; - } - - return server.server.url.replace( /\/+$/, '' ); -} - -function getSitePublicBaseUrl( server: SiteServer ) { - return server.server.url.replace( /\/+$/, '' ); -} - -function getSiteRestUrl( server: SiteServer, request: SiteRestRequest ) { - const baseUrl = getSiteBaseUrl( server ); - const restRoot = new URL( '/wp-json/', baseUrl ); - const publicRestRoot = new URL( '/wp-json/', getSitePublicBaseUrl( server ) ); - - if ( request.path ) { - const path = request.path.replace( /^\/+/, '' ); - return new URL( path, restRoot ); - } - - if ( request.url ) { - const url = new URL( request.url ); - if ( isRestUrlForRoot( url, restRoot ) ) { - return url; - } - if ( isRestUrlForRoot( url, publicRestRoot ) ) { - return translateRestUrl( url, publicRestRoot, restRoot ); - } - throw new Error( 'REST URL must target the selected site REST API.' ); - } - - throw new Error( 'REST request requires a path or URL.' ); -} - -function isRestUrlForRoot( url: URL, restRoot: URL ) { - return url.origin === restRoot.origin && url.pathname.startsWith( restRoot.pathname ); -} - -function translateRestUrl( url: URL, fromRestRoot: URL, toRestRoot: URL ) { - const relativePath = url.pathname.slice( fromRestRoot.pathname.length ); - const translatedUrl = new URL( relativePath, toRestRoot ); - translatedUrl.search = url.search; - translatedUrl.hash = url.hash; - return translatedUrl; -} - -async function getRequestHeaders( - server: SiteServer, - baseUrl: string, - requestHeaders: Record< string, string > = {} -) { - const headers = sanitizeRequestHeaders( requestHeaders ); - const auth = await getSiteRestAuth( server, baseUrl ); - - if ( auth ) { - headers.Cookie = auth.cookie; - headers[ 'X-WP-Nonce' ] = auth.nonce; - } - - return headers; -} - -function sanitizeRequestHeaders( requestHeaders: Record< string, string > ) { - const headers: Record< string, string > = { - Accept: 'application/json, */*;q=0.1', - }; - - for ( const [ key, value ] of Object.entries( requestHeaders ) ) { - const lowerKey = key.toLowerCase(); - if ( lowerKey === 'cookie' || lowerKey === 'host' ) { - continue; - } - headers[ key ] = value; - } - - return headers; -} - -function hasHeader( headers: Record< string, string >, headerName: string ) { - const normalizedHeaderName = headerName.toLowerCase(); - return Object.keys( headers ).some( ( key ) => key.toLowerCase() === normalizedHeaderName ); -} - -function getRequestBody( request: SiteRestRequest ) { - if ( request.body !== undefined ) { - return request.body; - } - - if ( request.data !== undefined ) { - return JSON.stringify( request.data ); - } + const publicUrl = server.server.url.replace( /\/+$/, '' ); + const baseUrl = server.details.port > 0 ? `http://127.0.0.1:${ server.details.port }` : publicUrl; - return undefined; -} - -async function getSiteRestAuth( server: SiteServer, baseUrl: string ) { - const cached = siteRestAuthCache.get( server.details.id ); - if ( cached?.baseUrl === baseUrl ) { - return cached; - } - - try { - const cookie = await getAutoLoginCookie( baseUrl ); - const nonce = await getRestNonce( baseUrl, cookie ); - const auth = { + return fetchSiteRestShared( + { + siteId, + running: server.details.running, baseUrl, - cookie, - nonce, - }; - siteRestAuthCache.set( server.details.id, auth ); - return auth; - } catch ( error ) { - console.warn( `Failed to prepare REST auth for site ${ server.details.id }:`, error ); - return null; - } -} - -async function getAutoLoginCookie( baseUrl: string ) { - const loginUrl = new URL( '/studio-auto-login', baseUrl ); - loginUrl.searchParams.set( 'redirect_to', '/wp-admin/' ); - - const response = await fetch( loginUrl, { redirect: 'manual' } ); - const cookies = getSetCookies( response.headers ) - .map( ( cookie ) => cookie.split( ';' )[ 0 ] ) - .filter( Boolean ); - - if ( cookies.length === 0 ) { - throw new Error( 'Auto-login did not return authentication cookies.' ); - } - - return cookies.join( '; ' ); -} - -async function getRestNonce( baseUrl: string, cookie: string ) { - const nonceUrl = new URL( '/wp-admin/admin-ajax.php', baseUrl ); - nonceUrl.searchParams.set( 'action', 'rest-nonce' ); - - const response = await fetch( nonceUrl, { - headers: { - Cookie: cookie, - }, - } ); - if ( ! response.ok ) { - throw new Error( `REST nonce request failed with status ${ response.status }.` ); - } - - const nonce = ( await response.text() ).trim(); - if ( ! nonce ) { - throw new Error( 'REST nonce response was empty.' ); - } - return nonce; -} - -function getSetCookies( headers: Headers ) { - const headersWithSetCookie = headers as Headers & { getSetCookie?: () => string[] }; - const setCookies = headersWithSetCookie.getSetCookie?.(); - if ( setCookies?.length ) { - return setCookies; - } - - const setCookieHeader = headers.get( 'set-cookie' ); - return setCookieHeader ? splitCombinedSetCookieHeader( setCookieHeader ) : []; -} - -function splitCombinedSetCookieHeader( header: string ) { - return header.split( /,(?=\s*[^;,]+=)/ ).map( ( value ) => value.trim() ); -} - -async function serializeResponse( response: Response ): Promise< SiteRestResponse > { - return { - status: response.status, - statusText: response.statusText, - headers: Object.fromEntries( response.headers.entries() ), - body: await response.text(), - url: response.url, - }; -} - -function createJsonResponse( status: number, code: string, message: string ): SiteRestResponse { - return { - status, - statusText: status >= 500 ? 'Server Error' : 'Error', - headers: { - 'content-type': 'application/json', + publicUrl, }, - body: JSON.stringify( { code, message } ), - url: '', - }; + request + ); } diff --git a/apps/studio/src/modules/ai-agent/run-manager.ts b/apps/studio/src/modules/ai-agent/run-manager.ts index 21efdf498a..167bc9ac73 100644 --- a/apps/studio/src/modules/ai-agent/run-manager.ts +++ b/apps/studio/src/modules/ai-agent/run-manager.ts @@ -1,148 +1,44 @@ -import crypto from 'crypto'; -import { fork, type ChildProcess } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import * as Sentry from '@sentry/electron/main'; -import { setAiSessionSitePlacement } from 'src/lib/ai-session-placement'; -import { - bumpStat, - bumpAggregatedUniqueStat, - getPlatformMetric, - StatsGroup, - StatsMetric, -} from 'src/lib/bump-stats'; +import { createAgentRunManager } from '@studio/common/ai/sessions/run-manager'; +import { lastBumpStatsProvider } from 'src/lib/bump-stats'; import { getBundledNodeBinaryPath, getCliPath } from 'src/storage/paths'; -import type { ActiveAgentRun, AgentRunEvent } from '@studio/common/ai/agent-events'; -import type { StudioChatArtifactData } from '@studio/common/ai/chat-artifacts'; +import type { ActiveAgentRun } from '@studio/common/ai/agent-events'; import type { StudioChatFileAttachment } from '@studio/common/ai/chat-files'; -import type { StudioAiSessionInputPayload, StudioChatImage } from '@studio/common/ai/chat-images'; -import type { JsonEvent } from '@studio/common/ai/json-events'; +import type { StudioChatImage } from '@studio/common/ai/chat-images'; import type { WebContents } from 'electron'; -interface AgentRun { - runId: string; - sessionId: string; - child: ChildProcess; - webContents: WebContents; - interrupted: boolean; - interruptAttempts: number; - eventQueue: Promise< void >; - startedAt: number; -} - -// Two subprocesses resuming the same session id would race on the JSONL -// recorder, so we reject the second one here. -const runsBySessionId = new Map< string, AgentRun >(); -const runsById = new Map< string, AgentRun >(); - -function nowIso(): string { - return new Date().toISOString(); -} - -// The CLI subprocess runs with `--avoid-telemetry`, so the desktop side is the -// only place that records Studio Code assistant usage. Bump stats are simple -// counters: usage volume, run outcome, and unique active users. -function bumpCodeSendStat(): void { - bumpStat( StatsGroup.STUDIO_CODE_UI_SEND, getPlatformMetric() ); - bumpAggregatedUniqueStat( - StatsGroup.STUDIO_CODE_UI_WKLY_UNQ, - getPlatformMetric(), - 'weekly' - ).catch( ( err ) => Sentry.captureException( err ) ); - bumpAggregatedUniqueStat( - StatsGroup.STUDIO_CODE_UI_MON_UNQ, - getPlatformMetric(), - 'monthly' - ).catch( ( err ) => Sentry.captureException( err ) ); -} - -function bumpCodeRunStat( run: AgentRun, code: number | null ): void { - const outcome = run.interrupted - ? StatsMetric.INTERRUPTED - : code === 0 - ? StatsMetric.SUCCESS - : StatsMetric.FAILURE; - bumpStat( StatsGroup.STUDIO_CODE_UI_RUN, outcome ); -} - -function sendEvent( run: AgentRun, event: AgentRunEvent[ 'event' ] ): void { - if ( run.webContents.isDestroyed() ) { - return; - } - const payload: AgentRunEvent = { - runId: run.runId, - sessionId: run.sessionId, - event, - }; - run.webContents.send( 'ai-agent-event', payload ); -} - -function getCreatedSiteFromArtifact( artifact: StudioChatArtifactData ): - | { - siteId: string; - sitePath: string; - siteName: string; - } - | undefined { - for ( const widget of artifact.widgets ) { - if ( widget.type !== 'site-preview' ) { - continue; +/** + * Desktop binding for the shared agent run-manager + * (`@studio/common/ai/sessions/run-manager`). The fork, lifecycle, stats, + * placement, and error reporting all live in the shared core; this module only + * supplies the desktop's two host specifics — the bundled CLI/Node binaries and + * the IPC transport — plus per-window routing. The `studio ui` server wires the + * same core to SSE instead. + */ + +// The renderer that started each run, so events route back to the right window. +const sessionWebContents = new Map< string, WebContents >(); + +const runManager = createAgentRunManager( { + cliBinary: getCliPath(), + nodeBinary: getBundledNodeBinaryPath(), + surface: 'desktop', + lastBumpStatsProvider, + emit: ( output ) => { + const webContents = sessionWebContents.get( output.event.sessionId ); + if ( webContents && ! webContents.isDestroyed() ) { + if ( output.kind === 'agent' ) { + webContents.send( 'ai-agent-event', output.event ); + } else { + webContents.send( 'ai-session-placement-updated', output.event ); + } } - const { siteId, sitePath, siteName } = widget.widgetProps; - if ( - typeof siteId === 'string' && - typeof sitePath === 'string' && - typeof siteName === 'string' - ) { - return { siteId, sitePath, siteName }; + // The run is over once it exits; drop the mapping so a destroyed window + // doesn't linger. + if ( output.kind === 'agent' && output.event.event.type === 'run.exited' ) { + sessionWebContents.delete( output.event.sessionId ); } - } - return undefined; -} - -async function applySessionPlacementFromEvent( run: AgentRun, event: JsonEvent ): Promise< void > { - if ( event.type !== 'chat.artifact' ) { - return; - } - const createdSite = getCreatedSiteFromArtifact( event.artifact ); - if ( ! createdSite ) { - return; - } - const placement = await setAiSessionSitePlacement( run.sessionId, createdSite ); - if ( run.webContents.isDestroyed() ) { - return; - } - run.webContents.send( 'ai-session-placement-updated', { - sessionId: run.sessionId, - placement, - } ); -} - -async function sendQueuedJsonEvent( run: AgentRun, event: JsonEvent ): Promise< void > { - try { - await applySessionPlacementFromEvent( run, event ); - } catch ( error ) { - sendEvent( run, { - type: 'error', - timestamp: nowIso(), - message: error instanceof Error ? error.message : 'Failed to update session placement', - } ); - } - sendEvent( run, event ); -} - -function enqueueJsonEvent( run: AgentRun, event: JsonEvent ): void { - run.eventQueue = run.eventQueue - .then( () => sendQueuedJsonEvent( run, event ) ) - .catch( ( error ) => { - sendEvent( run, { - type: 'error', - timestamp: nowIso(), - message: error instanceof Error ? error.message : 'Failed to forward agent event', - } ); - } ); -} + }, +} ); export interface StartAgentRunOptions { sessionId: string; @@ -153,188 +49,31 @@ export interface StartAgentRunOptions { webContents: WebContents; } -// Attachments can be large (base64 image data) or numerous, so we hand them to -// the CLI child via a temp JSON file rather than process args, which have a -// platform-dependent length cap. The dir is removed when the run exits. -// -// The write is synchronous because `startAgentRun` is synchronous (it forks the -// child and returns a run id without awaiting). This blocks the main process for -// the write — bounded and one-time, and only meaningful at the max image batch -// (~12 MB → ~16 MB of base64 JSON). Kept sync to avoid a guard window where two -// concurrent sends for the same session could both pass the in-flight check. -function writeInputPayloadFile( payload: StudioAiSessionInputPayload ): { - dir: string; - path: string; -} { - const dir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-ai-run-' ) ); - const filePath = path.join( dir, 'input.json' ); - fs.writeFileSync( filePath, JSON.stringify( payload ), { encoding: 'utf8' } ); - return { dir, path: filePath }; -} - export function startAgentRun( options: StartAgentRunOptions ): { runId: string } { - const { sessionId, prompt, displayMessage, images = [], files = [], webContents } = options; - - if ( runsBySessionId.has( sessionId ) ) { - throw new Error( `A run is already in progress for session ${ sessionId }` ); - } - - const runId = crypto.randomUUID(); - const startedAt = Date.now(); - const cliPath = getCliPath(); - const inputPayload = - images.length > 0 || files.length > 0 - ? writeInputPayloadFile( { prompt, displayMessage, images, files } ) - : undefined; - const args = [ 'code', 'sessions', 'resume', sessionId ]; - if ( inputPayload ) { - args.push( '--input-payload', inputPayload.path ); - } else { - args.push( prompt ); - } - args.push( '--json', '--avoid-telemetry' ); - if ( displayMessage && ! inputPayload ) { - args.push( '--display-message', displayMessage ); + const { sessionId, prompt, displayMessage, images, files, webContents } = options; + // Route this session's events to the originating window before the run can + // emit anything (the child's first event is async, on the next tick). + sessionWebContents.set( sessionId, webContents ); + try { + const result = runManager.startAgentRun( { sessionId, prompt, displayMessage, images, files } ); + // Abort the run if the window that started it goes away. + webContents.once( 'destroyed', () => runManager.interruptAgentRun( result.runId ) ); + return result; + } catch ( error ) { + // Concurrent-run rejection (or any synchronous failure): undo the routing. + sessionWebContents.delete( sessionId ); + throw error; } - const child = fork( cliPath, args, { - // Agent events arrive over the Node IPC channel (via `process.send` - // in the child). stdout/stderr are ignored — the child's - // `emitEvent` falls back to stdout only when IPC isn't available. - stdio: [ 'ignore', 'ignore', 'ignore', 'ipc' ], - execPath: getBundledNodeBinaryPath(), - execArgv: [ '--experimental-wasm-jspi' ], - env: { ...process.env }, - } ); - - const run: AgentRun = { - runId, - sessionId, - child, - webContents, - interrupted: false, - interruptAttempts: 0, - eventQueue: Promise.resolve(), - startedAt, - }; - - runsBySessionId.set( sessionId, run ); - runsById.set( runId, run ); - - bumpCodeSendStat(); - - child.on( 'spawn', () => { - sendEvent( run, { type: 'run.started', timestamp: nowIso() } ); - } ); - - child.on( 'message', ( message ) => { - // The CLI's `Logger` also writes to this IPC channel with a different - // shape (`{ action, status, message }`) on error paths. Forward only - // messages that look like the CLI JSON transport envelope. - if ( message && typeof message === 'object' && 'type' in message ) { - enqueueJsonEvent( run, message as JsonEvent ); - } - } ); - - const cleanup = ( code: number | null ) => { - runsBySessionId.delete( sessionId ); - runsById.delete( runId ); - - bumpCodeRunStat( run, code ); - - if ( inputPayload ) { - fs.rm( inputPayload.dir, { recursive: true, force: true }, ( error ) => { - if ( error ) { - console.warn( 'Failed to clean AI session input payload', error ); - } - } ); - } - - void run.eventQueue.finally( () => { - if ( run.interrupted ) { - sendEvent( run, { type: 'run.interrupted', timestamp: nowIso() } ); - } - sendEvent( run, { - type: 'run.exited', - timestamp: nowIso(), - status: code === 0 ? 'success' : 'error', - code, - } ); - } ); - }; - - child.on( 'error', ( error ) => { - sendEvent( run, { - type: 'error', - timestamp: nowIso(), - message: error.message || 'CLI subprocess failed to start', - } ); - } ); - - child.on( 'exit', cleanup ); - - const abortOnDestroy = () => { - if ( ! runsById.has( runId ) ) { - return; - } - interruptAgentRun( runId ); - }; - webContents.once( 'destroyed', abortOnDestroy ); - - return { runId }; } export function listActiveAgentRuns(): ActiveAgentRun[] { - return Array.from( runsBySessionId.values() ).map( ( run ) => ( { - runId: run.runId, - sessionId: run.sessionId, - startedAt: run.startedAt, - phase: run.interrupted ? 'interrupting' : 'running', - } ) ); + return runManager.listActiveAgentRuns(); } -const INTERRUPT_FORCE_KILL_TIMEOUT_MS = 2000; - export function interruptAgentRun( runId: string ): void { - const run = runsById.get( runId ); - if ( ! run ) { - return; - } - run.interrupted = true; - run.interruptAttempts += 1; - if ( runsBySessionId.get( run.sessionId ) === run ) { - runsBySessionId.delete( run.sessionId ); - } - - // Second click escalates: the graceful path is in flight but evidently - // not landing fast enough, so skip the grace period. - if ( run.interruptAttempts > 1 ) { - run.child.kill( 'SIGKILL' ); - return; - } - - // First click: tell the child to interrupt via the Agent SDK and exit - // cleanly (so the session recorder flushes). SIGTERM is swallowed by - // module-level handlers that aren't wired to the SDK, so we use IPC. - if ( run.child.connected ) { - run.child.send( { type: 'interrupt' } ); - sendEvent( run, { type: 'run.interrupting', timestamp: nowIso() } ); - // Safety net: if the graceful path doesn't land quickly, force-kill - // so the renderer can't get stuck in a busy state. - setTimeout( () => { - if ( runsById.get( runId ) === run && ! run.child.killed ) { - run.child.kill( 'SIGKILL' ); - } - }, INTERRUPT_FORCE_KILL_TIMEOUT_MS ).unref(); - return; - } - - run.child.kill( 'SIGKILL' ); + runManager.interruptAgentRun( runId ); } export function answerAgentRun( runId: string, answers: Record< string, string > ): void { - const run = runsById.get( runId ); - if ( ! run || ! run.child.connected ) { - return; - } - run.child.send( { type: 'answer', answers } ); + runManager.answerAgentRun( runId, answers ); } diff --git a/apps/studio/src/modules/cli/lib/cli-site-creator.ts b/apps/studio/src/modules/cli/lib/cli-site-creator.ts index fb5fae3274..57f0e0aad0 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-creator.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-creator.ts @@ -1,14 +1,8 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; -import { siteModeFromRuntime, type SiteRuntime } from '@studio/common/lib/site-runtime'; -import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; import { SiteCommandLoggerAction } from '@studio/common/logger-actions'; +import { buildSiteCreateArgs, type SiteCreateOptions } from '@studio/common/sites/create'; import { z } from 'zod'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { executeCliCommand } from './execute-command'; -import type { Blueprint } from '@wp-playground/blueprints'; const cliEventSchema = z.discriminatedUnion( 'action', [ z.object( { @@ -29,38 +23,14 @@ interface CreateSiteResult { running: boolean; } -export interface CreateSiteOptions { - path: string; - name?: string; - wpVersion?: string; - phpVersion?: string; - runtime?: SiteRuntime; - fileAccess?: SiteFileAccess; - customDomain?: string; - enableHttps?: boolean; - siteId?: string; - blueprint?: Blueprint; - originalBlueprintPath?: string; - adminUsername?: string; - adminPassword?: string; - adminEmail?: string; - noStart?: boolean; -} +// The create options shape is shared with the local web server so both build +// the CLI args identically (see `buildSiteCreateArgs`). +export type CreateSiteOptions = SiteCreateOptions; export async function createSiteViaCli( options: CreateSiteOptions ): Promise< CreateSiteResult > { - const args = buildCliArgs( options ); + const { args, cleanup } = buildSiteCreateArgs( options ); const siteId = options.siteId; - let blueprintTempPath: string | undefined; - if ( options.blueprint ) { - blueprintTempPath = path.join( os.tmpdir(), `studio-blueprint-${ Date.now() }.json` ); - fs.writeFileSync( blueprintTempPath, JSON.stringify( options.blueprint ) ); - args.push( '--blueprint', blueprintTempPath ); - if ( options.originalBlueprintPath ) { - args.push( '--original-blueprint-path', options.originalBlueprintPath ); - } - } - return new Promise( ( resolve, reject ) => { const result: Partial< CreateSiteResult > = {}; const [ emitter ] = executeCliCommand( args, { output: 'capture', logPrefix: siteId } ); @@ -97,7 +67,7 @@ export async function createSiteViaCli( options: CreateSiteOptions ): Promise< C } ); emitter.on( 'success', () => { - cleanupTempFile( blueprintTempPath ); + cleanup(); if ( ! result.id ) { reject( new Error( 'CLI create site succeeded but no site ID received' ) ); return; @@ -110,79 +80,14 @@ export async function createSiteViaCli( options: CreateSiteOptions ): Promise< C } ); emitter.on( 'failure', ( { error } ) => { - cleanupTempFile( blueprintTempPath ); + cleanup(); error.baseMessage = 'Failed to create site'; reject( error ); } ); emitter.on( 'error', ( { error } ) => { - cleanupTempFile( blueprintTempPath ); + cleanup(); reject( error ); } ); } ); } - -function buildCliArgs( options: CreateSiteOptions ): string[] { - const args = [ 'site', 'create', '--path', options.path, '--skip-browser', '--skip-log-details' ]; - - if ( options.siteId ) { - args.push( '--id', options.siteId ); - } - - if ( options.name ) { - args.push( '--name', options.name ); - } - - if ( options.wpVersion ) { - const wp = isWordPressDevVersion( options.wpVersion ) ? 'nightly' : options.wpVersion; - args.push( '--wp', wp ); - } - - if ( options.phpVersion ) { - args.push( '--php', options.phpVersion ); - } - - if ( options.runtime ) { - args.push( '--runtime', siteModeFromRuntime( options.runtime ) ); - } - - if ( options.fileAccess ) { - args.push( '--file-access', options.fileAccess ); - } - - if ( options.customDomain ) { - args.push( '--domain', options.customDomain ); - } - - if ( options.enableHttps ) { - args.push( '--https' ); - } - - if ( options.adminUsername ) { - args.push( '--admin-username', options.adminUsername ); - } - - if ( options.adminPassword ) { - args.push( '--admin-password', options.adminPassword ); - } - - if ( options.adminEmail ) { - args.push( '--admin-email', options.adminEmail ); - } - - if ( options.noStart ) { - args.push( '--no-start' ); - } - - return args; -} - -function cleanupTempFile( filePath: string | undefined ): void { - if ( filePath && fs.existsSync( filePath ) ) { - try { - fs.unlinkSync( filePath ); - } catch ( error ) { - console.error( 'Failed to clean up temp Blueprint file:', error ); - } - } -} diff --git a/apps/studio/src/modules/cli/lib/cli-site-editor.ts b/apps/studio/src/modules/cli/lib/cli-site-editor.ts index 9649def25d..84f98760c9 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-editor.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-editor.ts @@ -1,35 +1,18 @@ -import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; -import { type SiteMode } from '@studio/common/lib/site-runtime'; import { SiteCommandLoggerAction } from '@studio/common/logger-actions'; +import { buildSiteSetArgs, type EditSiteOptions } from '@studio/common/sites/edit'; import { z } from 'zod'; import { executeCliCommand } from './execute-command'; +export type { EditSiteOptions }; + const cliEventSchema = z.object( { action: z.enum( SiteCommandLoggerAction ), status: z.enum( [ 'inprogress', 'fail', 'success', 'warning' ] ), message: z.string(), } ); -export interface EditSiteOptions { - path: string; - siteId: string; - name?: string; - domain?: string; - https?: boolean; - php?: string; - wp?: string; - runtime?: SiteMode; - fileAccess?: SiteFileAccess; - xdebug?: boolean; - adminUsername?: string; - adminPassword?: string; - adminEmail?: string; - debugLog?: boolean; - debugDisplay?: boolean; -} - export async function editSiteViaCli( options: EditSiteOptions ): Promise< void > { - const args = buildCliArgs( options ); + const args = buildSiteSetArgs( options ); console.log( `[CLI Site Editor] Executing: studio ${ args.join( ' ' ) }` ); return new Promise( ( resolve, reject ) => { @@ -60,61 +43,3 @@ export async function editSiteViaCli( options: EditSiteOptions ): Promise< void } ); } ); } - -function buildCliArgs( options: EditSiteOptions ): string[] { - const args = [ 'site', 'set', '--path', options.path ]; - - if ( options.name !== undefined ) { - args.push( '--name', options.name ); - } - - if ( options.domain !== undefined ) { - args.push( '--domain', options.domain ); - } - - if ( options.https !== undefined ) { - args.push( options.https ? '--https' : '--no-https' ); - } - - if ( options.php !== undefined ) { - args.push( '--php', options.php ); - } - - if ( options.wp !== undefined ) { - args.push( '--wp', options.wp ); - } - - if ( options.runtime !== undefined ) { - args.push( '--runtime', options.runtime ); - } - - if ( options.fileAccess !== undefined ) { - args.push( '--file-access', options.fileAccess ); - } - - if ( options.xdebug !== undefined ) { - args.push( options.xdebug ? '--xdebug' : '--no-xdebug' ); - } - - if ( options.adminUsername !== undefined ) { - args.push( '--admin-username', options.adminUsername ); - } - - if ( options.adminPassword !== undefined ) { - args.push( '--admin-password', options.adminPassword ); - } - - if ( options.adminEmail !== undefined ) { - args.push( '--admin-email', options.adminEmail ); - } - - if ( options.debugLog !== undefined ) { - args.push( options.debugLog ? '--debug-log' : '--no-debug-log' ); - } - - if ( options.debugDisplay !== undefined ) { - args.push( options.debugDisplay ? '--debug-display' : '--no-debug-display' ); - } - - return args; -} diff --git a/apps/studio/src/modules/cli/lib/execute-command.ts b/apps/studio/src/modules/cli/lib/execute-command.ts index 22603f3137..53fc388d1f 100644 --- a/apps/studio/src/modules/cli/lib/execute-command.ts +++ b/apps/studio/src/modules/cli/lib/execute-command.ts @@ -1,237 +1,34 @@ import { app } from 'electron'; -import { fork, spawnSync, ChildProcess, StdioOptions } from 'node:child_process'; import * as Sentry from '@sentry/electron/main'; -import { z } from 'zod'; -import { TypedEventEmitter } from 'src/modules/cli/lib/typed-event-emitter'; +import { createCliRunner, type CliRunner } from '@studio/common/lib/cli-process'; import { getBundledNodeBinaryPath, getCliPath } from 'src/storage/paths'; -export type CliCommandResult = { - stdout: string; - stderr: string; -}; - -export class CliCommandError extends Error { - baseMessage = 'CLI command failed'; - readonly lastErrorMessage: string | undefined; - readonly cliCommandResult: CliCommandResult | undefined; - readonly exitCode: number | null; - readonly signal: NodeJS.Signals | null; - - constructor( options: { - lastErrorMessage: string | undefined; - cliCommandResult: CliCommandResult | undefined; - exitCode: number | null; - signal: NodeJS.Signals | null; - } ) { - super(); - this.lastErrorMessage = options.lastErrorMessage; - this.cliCommandResult = options.cliCommandResult; - this.exitCode = options.exitCode; - this.signal = options.signal; - this.name = 'CliCommandError'; - // The stack trace for this error is misleading, because it's not actually thrown where the error - // happened - it's just a representation of an error that happened in a different process. - this.stack = undefined; - } - - get message(): string { - const messageParts: string[] = []; - - if ( this.lastErrorMessage ) { - messageParts.push( `[Last error message] ${ this.lastErrorMessage }` ); - } - - if ( this.baseMessage ) { - messageParts.push( `[Base message] ${ this.baseMessage }` ); - } - - if ( this.cliCommandResult ) { - const stderr = this.cliCommandResult.stderr.trim(); - const stdout = this.cliCommandResult.stdout.trim(); - if ( stderr ) { - messageParts.push( `[stderr] ${ stderr }` ); - } else if ( stdout ) { - messageParts.push( `[stdout] ${ stdout }` ); - } - } - - if ( this.signal !== null ) { - messageParts.push( `[Terminated by signal] ${ this.signal }` ); - } else if ( this.exitCode !== null ) { - messageParts.push( `[Exit code] ${ this.exitCode }` ); - } - - return messageParts - .map( ( part, index ) => ( index === 0 ? part : ` ${ part }` ) ) - .join( '\n' ); +// The spawn mechanics now live in `@studio/common/lib/cli-process` so the +// desktop app and the `studio ui` local server fork the CLI exactly the same +// way. This module is the desktop's thin binding: it injects how the desktop +// resolves the bundled CLI + Node binaries, routes errors to Sentry, and tears +// down forked children on app quit. Re-exports keep existing imports +// (`executeCliCommand`, `CliCommandError`, `CliCommandResult`) unchanged. +export { CliCommandError, type CliCommandResult } from '@studio/common/lib/cli-process'; + +let runner: CliRunner | undefined; + +function getRunner(): CliRunner { + if ( ! runner ) { + const cliRunner = createCliRunner( { + cliBinary: getCliPath(), + nodeBinary: getBundledNodeBinaryPath(), + onError: ( error ) => Sentry.captureException( error ), + } ); + // `child.kill()` only terminates the forked CLI; the runner's killAll walks + // the tree on Windows so php.exe descendants don't orphan. + app.on( 'will-quit', () => cliRunner.killAll() ); + runner = cliRunner; } + return runner; } -type CliCommandEventMap< CapturesOutput extends boolean > = { - started: void; - error: { error: Error }; - data: { data: unknown }; - success: { result: CapturesOutput extends true ? CliCommandResult : undefined }; - failure: CapturesOutput extends true - ? { error: CliCommandError; result: CliCommandResult } - : { error: CliCommandError }; -}; - -// Schema to detect error messages from CLI IPC regardless of the specific action type -const cliErrorMessageSchema = z.object( { - status: z.literal( 'fail' ), - message: z.string(), -} ); -type CliCommandEventEmitter< CapturesOutput extends boolean > = TypedEventEmitter< - CliCommandEventMap< CapturesOutput > ->; - -type ExecuteCliCommandOptionsIgnore = { - output: 'ignore'; - env?: NodeJS.ProcessEnv; -}; -type ExecuteCliCommandOptionsCapture = { - output: 'capture'; - logPrefix?: string; - env?: NodeJS.ProcessEnv; -}; -type ExecuteCliCommandOptions = ExecuteCliCommandOptionsIgnore | ExecuteCliCommandOptionsCapture; - -export function executeCliCommand( - args: string[], - options: { output: 'capture'; logPrefix?: string; env?: NodeJS.ProcessEnv } -): [ CliCommandEventEmitter< true >, ChildProcess ]; -export function executeCliCommand( - args: string[], - options: { output: 'ignore'; logPrefix?: string; env?: NodeJS.ProcessEnv } -): [ CliCommandEventEmitter< false >, ChildProcess ]; -export function executeCliCommand( +export const executeCliCommand: CliRunner[ 'executeCliCommand' ] = ( ( args: string[], - options?: ExecuteCliCommandOptions -): [ CliCommandEventEmitter< false >, ChildProcess ]; -export function executeCliCommand( - args: string[], - options: ExecuteCliCommandOptions = { output: 'ignore' } -): [ CliCommandEventEmitter< boolean >, ChildProcess ] { - const cliPath = getCliPath(); - - let stdio: StdioOptions | undefined; - /** - * If there's an IPC channel, the CLI `Logger` uses IPC to communicate all expected events. This - * means that for many CLI commands, the captured stdout/stderr will be empty, unless something - * unexpected was logged. - */ - if ( options.output === 'capture' ) { - stdio = [ 'ignore', 'pipe', 'pipe', 'ipc' ]; - } else if ( options.output === 'ignore' ) { - stdio = [ 'ignore', 'ignore', 'ignore', 'ipc' ]; - } - - const child = fork( cliPath, [ ...args, '--avoid-telemetry' ], { - stdio, - execPath: getBundledNodeBinaryPath(), - execArgv: [ '--experimental-wasm-jspi' ], - env: { ...process.env, ...options.env }, - } ); - const eventEmitter = new TypedEventEmitter< CliCommandEventMap< boolean > >(); - - 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 = ''; - let lastErrorMessage: string | undefined; - - if ( options.output === 'capture' ) { - // Only callers that opted-in with a `logPrefix` get stdout echoed to - // the main-process console. Commands like `preview list --format json` - // dump large structured payloads on stdout that would otherwise spam - // `npm start` output every time snapshots are fetched. - const logPrefix = options.logPrefix ? `[CLI - site ID ${ options.logPrefix }]` : null; - child.stdout?.on( 'data', ( data: Buffer ) => { - const text = data.toString(); - stdout += text; - if ( logPrefix ) { - const trimmed = text.trimEnd(); - if ( trimmed ) { - console.log( `${ logPrefix } ${ trimmed }` ); - } - } - } ); - child.stderr?.on( 'data', ( data: Buffer ) => { - stderr += data.toString(); - } ); - } - - child.on( 'message', ( message: unknown ) => { - const errorParsed = cliErrorMessageSchema.safeParse( message ); - if ( errorParsed.success ) { - lastErrorMessage = errorParsed.data.message; - } - - eventEmitter.emit( 'data', { data: message } ); - } ); - - function appQuitHandler() { - const pid = child.pid; - child.removeAllListeners(); - - // `child.kill()` only terminates the forked CLI process; on Windows its php.exe descendants - // would orphan and keep their DLLs locked. `taskkill /T` walks the whole tree instead. - if ( process.platform === 'win32' && pid ) { - spawnSync( 'taskkill', [ '/F', '/T', '/PID', String( pid ) ], { - windowsHide: true, - stdio: 'ignore', - } ); - return; - } - - const result = child.kill(); - if ( result ) { - console.log( `Successfully killed child process with pid ${ pid }. Args:`, args ); - } else { - console.error( - `Failed to kill child process with pid ${ pid }. This likely means the process is already terminated. CLI args:`, - args - ); - } - } - - child.on( 'close', ( exitCode, signal ) => { - child.removeAllListeners(); - app.off( 'will-quit', appQuitHandler ); - - let result: CliCommandResult | undefined; - - if ( options.output === 'capture' ) { - result = { stdout, stderr }; - } - - if ( exitCode === 0 ) { - eventEmitter.emit( 'success', { result } ); - } else { - const error = new CliCommandError( { - lastErrorMessage, - cliCommandResult: result, - exitCode, - signal, - } ); - if ( options.output === 'capture' ) { - eventEmitter.emit( 'failure', { error, result } ); - } else { - eventEmitter.emit( 'failure', { error } ); - } - } - } ); - - app.on( 'will-quit', appQuitHandler ); - - return [ eventEmitter, child ]; -} + options?: Parameters< CliRunner[ 'executeCliCommand' ] >[ 1 ] +) => getRunner().executeCliCommand( args, options ) ) as CliRunner[ 'executeCliCommand' ]; diff --git a/apps/studio/src/modules/cli/lib/execute-preview-command.ts b/apps/studio/src/modules/cli/lib/execute-preview-command.ts deleted file mode 100644 index e210f24cc3..0000000000 --- a/apps/studio/src/modules/cli/lib/execute-preview-command.ts +++ /dev/null @@ -1,74 +0,0 @@ -import crypto from 'crypto'; -import { PreviewCommandLoggerAction } from '@studio/common/logger-actions'; -import { z } from 'zod'; -import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; -import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; - -const snapshotEventSchema = z.discriminatedUnion( 'action', [ - z.object( { - action: z.enum( PreviewCommandLoggerAction ), - status: z.enum( [ 'inprogress', 'fail', 'success' ] ), - message: z.string(), - } ), - z.object( { - action: z.literal( 'keyValuePair' ), - key: z.string(), - value: z.string(), - } ), -] ); - -export async function executePreviewCliCommand( - args: string[], - parentWindow: Electron.BrowserWindow | null -): Promise< { operationId: crypto.UUID } > { - const operationId = crypto.randomUUID(); - const [ cliEventEmitter ] = executeCliCommand( args, { output: 'capture' } ); - - cliEventEmitter.on( 'data', ( { data } ) => { - const parsed = snapshotEventSchema.safeParse( data ); - - if ( ! parsed.success ) { - console.error( 'Invalid snapshot event:', parsed.error ); - return; - } - - if ( parsed.data.action === 'keyValuePair' ) { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-key-value', { - operationId, - data: parsed.data, - } ); - } else if ( parsed.data.status === 'fail' ) { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-error', { - operationId, - data: parsed.data, - } ); - } else { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-output', { - operationId, - data: parsed.data, - } ); - } - } ); - - cliEventEmitter.on( 'error', ( { error } ) => { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-fatal-error', { - operationId, - data: { message: error.message }, - } ); - } ); - - cliEventEmitter.on( 'failure', ( { error } ) => { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-fatal-error', { - operationId, - data: { message: error.message }, - } ); - } ); - - cliEventEmitter.on( 'success', () => { - sendIpcEventToRendererWithWindow( parentWindow, 'snapshot-success', { - operationId, - } ); - } ); - - return { operationId }; -} diff --git a/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts b/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts index c6b92147cb..0bedf75c58 100644 --- a/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts @@ -1,41 +1,58 @@ import { BrowserWindow, IpcMainInvokeEvent } from 'electron'; -import { snapshotSchema } from '@studio/common/types/snapshot'; -import { z } from 'zod'; +import { + createSnapshotManager, + fetchSnapshots as fetchSnapshotsFromCli, + type SnapshotManager, + type SnapshotOutput, +} from '@studio/common/sites/snapshots'; +import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; -import { executePreviewCliCommand } from 'src/modules/cli/lib/execute-preview-command'; import type { Snapshot } from '@studio/common/types/snapshot'; -const snapshotListKeyValueSchema = z.object( { - action: z.literal( 'keyValuePair' ), - key: z.literal( 'snapshots' ), - value: z - .string() - .transform( ( val ) => JSON.parse( val ) ) - .pipe( z.array( snapshotSchema ) ), -} ); +// Desktop binding for the shared snapshot manager: forwards the CLI's progress +// to the originating renderer over the existing `snapshot-*` IPC channels. The +// `studio ui` server wires the same manager to SSE instead. +function snapshotManagerForWindow( window: BrowserWindow | null ): SnapshotManager { + return createSnapshotManager( { + executeCliCommand, + emit: ( output: SnapshotOutput ) => { + switch ( output.kind ) { + case 'output': + sendIpcEventToRendererWithWindow( window, 'snapshot-output', { + operationId: output.operationId, + data: output.data, + } ); + break; + case 'key-value': + sendIpcEventToRendererWithWindow( window, 'snapshot-key-value', { + operationId: output.operationId, + data: output.data, + } ); + break; + case 'error': + sendIpcEventToRendererWithWindow( window, 'snapshot-error', { + operationId: output.operationId, + data: output.data, + } ); + break; + case 'fatal-error': + sendIpcEventToRendererWithWindow( window, 'snapshot-fatal-error', { + operationId: output.operationId, + data: output.data, + } ); + break; + case 'success': + sendIpcEventToRendererWithWindow( window, 'snapshot-success', { + operationId: output.operationId, + } ); + break; + } + }, + } ); +} export async function fetchSnapshots(): Promise< Snapshot[] > { - try { - return await new Promise< Snapshot[] >( ( resolve, reject ) => { - const [ emitter ] = executeCliCommand( [ 'preview', 'list', '--format', 'json' ], { - output: 'capture', - } ); - - emitter.on( 'data', ( { data } ) => { - const parsed = snapshotListKeyValueSchema.safeParse( data ); - if ( parsed.success ) { - resolve( parsed.data.value ); - } - } ); - - emitter.on( 'success', () => resolve( [] ) ); - emitter.on( 'failure', ( { error } ) => reject( error ) ); - emitter.on( 'error', ( { error } ) => reject( error ) ); - } ); - } catch ( error ) { - console.error( 'Failed to fetch snapshots from CLI:', error ); - return []; - } + return fetchSnapshotsFromCli( executeCliCommand ); } export async function createSnapshot( @@ -43,12 +60,8 @@ export async function createSnapshot( siteFolder: string, name?: string ) { - const parentWindow = BrowserWindow.fromWebContents( event.sender ); - const args = [ 'preview', 'create', '--path', siteFolder ]; - if ( name ) { - args.push( '--name', name ); - } - return executePreviewCliCommand( args, parentWindow ); + const window = BrowserWindow.fromWebContents( event.sender ); + return snapshotManagerForWindow( window ).createSnapshot( siteFolder, name ); } export async function updateSnapshot( @@ -56,16 +69,22 @@ export async function updateSnapshot( siteFolder: string, hostname: string ) { - const parentWindow = BrowserWindow.fromWebContents( event.sender ); - return executePreviewCliCommand( - [ 'preview', 'update', '--path', siteFolder, hostname ], - parentWindow - ); + const window = BrowserWindow.fromWebContents( event.sender ); + return snapshotManagerForWindow( window ).updateSnapshot( siteFolder, hostname ); } export async function deleteSnapshot( event: IpcMainInvokeEvent, hostname: string ) { - const parentWindow = BrowserWindow.fromWebContents( event.sender ); - return executePreviewCliCommand( [ 'preview', 'delete', hostname ], parentWindow ); + const window = BrowserWindow.fromWebContents( event.sender ); + return snapshotManagerForWindow( window ).deleteSnapshot( hostname ); +} + +export async function setSnapshot( + event: IpcMainInvokeEvent, + hostname: string, + options: { name?: string } +) { + const window = BrowserWindow.fromWebContents( event.sender ); + return snapshotManagerForWindow( window ).setSnapshot( hostname, options ); } export async function deleteAllSnapshots() { @@ -76,16 +95,3 @@ export async function deleteAllSnapshots() { cliEventEmitter.on( 'success', () => resolve() ); } ); } - -export async function setSnapshot( - event: IpcMainInvokeEvent, - hostname: string, - options: { name?: string } -) { - const parentWindow = BrowserWindow.fromWebContents( event.sender ); - const args = [ 'preview', 'set', hostname ]; - if ( options.name !== undefined ) { - args.push( '--name', options.name ); - } - return executePreviewCliCommand( args, parentWindow ); -} diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index f33ff10efa..bf0180a5e2 100644 --- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts @@ -15,6 +15,7 @@ import { getCurrentUserId } from '@studio/common/lib/shared-config'; import { fetchSyncableSites } from '@studio/common/lib/sync/sync-api'; import wpcomFactory from '@studio/common/lib/wpcom-factory'; import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; +import { pullSite, pushSite } from '@studio/common/sites/sync'; import { SyncSite } from '@studio/common/types/sync'; import { Upload } from 'tus-js-client'; import { z } from 'zod'; @@ -503,16 +504,54 @@ export async function pullSiteFromLive( siteFolder: string, remoteSiteId: number ): Promise< void > { - return new Promise< void >( ( resolve, reject ) => { - const [ emitter ] = executeCliCommand( - [ 'pull', '--path', siteFolder, '--remote-site', String( remoteSiteId ), '--options', 'all' ], - { output: 'capture' } - ); - - emitter.on( 'success', () => resolve() ); - emitter.on( 'failure', ( { error } ) => reject( error ) ); - emitter.on( 'error', ( { error } ) => reject( error ) ); - } ); + return pullSite( executeCliCommand, siteFolder, remoteSiteId ); +} + +// Push for the agentic UI (apps/ui): the same shared `pushSite` the `studio ui` +// server uses, so the agentic UI pushes identically in the desktop and the +// browser (export → TUS upload → import). Progress is forwarded over the +// existing `sync-upload-*` channels. The legacy renderer keeps its own +// `exportSiteForPush` + `pushArchive` (with manual pause/resume) untouched. +export async function pushSiteToLive( + _event: IpcMainInvokeEvent, + selectedSiteId: string, + remoteSiteId: number +): Promise< void > { + const site = SiteServer.get( selectedSiteId ); + if ( ! site ) { + throw new Error( 'Site not found.' ); + } + const token = await getAuthenticationToken(); + if ( ! token?.accessToken ) { + throw new Error( 'No token found' ); + } + await pushSite( + { + executeCliCommand, + accessToken: token.accessToken, + emit: ( output ) => { + if ( output.kind === 'upload-progress' ) { + void sendIpcEventToRenderer( 'sync-upload-progress', { + selectedSiteId, + remoteSiteId, + progress: output.progress, + } ); + } else if ( output.kind === 'network-paused' ) { + void sendIpcEventToRenderer( 'sync-upload-network-paused', { + selectedSiteId, + remoteSiteId, + error: output.error, + } ); + } else if ( output.kind === 'resumed' ) { + void sendIpcEventToRenderer( 'sync-upload-resumed', { + selectedSiteId, + remoteSiteId, + } ); + } + }, + }, + { sitePath: site.details.path, remoteSiteId } + ); } // Fetches every WordPress.com site the authenticated user can sync to. diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index a475b9d794..68d83bbbf9 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -38,6 +38,8 @@ const api: IpcApi = { optionsToSync, specificSelectionPaths ), + pushSiteToLive: ( selectedSiteId, remoteSiteId ) => + ipcRendererInvoke( 'pushSiteToLive', selectedSiteId, remoteSiteId ), deleteSite: ( id, deleteFiles ) => ipcRendererInvoke( 'deleteSite', id, deleteFiles ), copySite: ( sourceSiteId, newSiteId, siteName ) => ipcRendererInvoke( 'copySite', sourceSiteId, newSiteId, siteName ), diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 75a393530e..5f396bf883 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -2,9 +2,9 @@ import fs from 'fs'; import nodePath from 'path'; import * as Sentry from '@sentry/electron/main'; import { SQLITE_FILENAME } from '@studio/common/constants'; -import { siteListSchema, type SiteListItem } from '@studio/common/lib/cli-events'; import { parseJsonFromPhpOutput } from '@studio/common/lib/php-output-parser'; import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; +import { listSites } from '@studio/common/sites'; import fsExtra from 'fs-extra'; import { parse } from 'shell-quote'; import { z } from 'zod'; @@ -137,33 +137,11 @@ export class SiteServer { return deletedServers.includes( id ); } - private static siteListKeyValueSchema = z.object( { - action: z.literal( 'keyValuePair' ), - key: z.literal( 'sites' ), - value: z - .string() - .transform( ( val ) => JSON.parse( val ) ) - .pipe( siteListSchema ), - } ); - static async fetchAll(): Promise< void > { try { - const sites = await new Promise< SiteListItem[] >( ( resolve, reject ) => { - const [ emitter ] = executeCliCommand( [ 'site', 'list', '--format', 'json' ], { - output: 'capture', - } ); - - emitter.on( 'data', ( { data } ) => { - const parsed = SiteServer.siteListKeyValueSchema.safeParse( data ); - if ( parsed.success ) { - resolve( parsed.data.value ); - } - } ); - - emitter.on( 'success', () => resolve( [] ) ); - emitter.on( 'failure', ( { error } ) => reject( error ) ); - emitter.on( 'error', ( { error } ) => reject( error ) ); - } ); + // Same shared site-listing the `studio ui` server uses; it forks the CLI + // through the desktop's `executeCliCommand` so existing mocks still apply. + const sites = await listSites( executeCliCommand ); for ( const site of sites ) { if ( ! SiteServer.get( site.id ) ) { diff --git a/apps/ui/index.web.html b/apps/ui/index.hosted.html similarity index 78% rename from apps/ui/index.web.html rename to apps/ui/index.hosted.html index a5e1268de4..54e153e551 100644 --- a/apps/ui/index.web.html +++ b/apps/ui/index.hosted.html @@ -7,6 +7,6 @@
- + diff --git a/apps/ui/index.local.html b/apps/ui/index.local.html new file mode 100644 index 0000000000..64d385628c --- /dev/null +++ b/apps/ui/index.local.html @@ -0,0 +1,12 @@ + + + + + + Studio + + +
+ + + diff --git a/apps/ui/package.json b/apps/ui/package.json index 0310ac0313..ff85ccf850 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -5,13 +5,16 @@ "type": "module", "scripts": { "dev": "vite", - "dev:web": "STUDIO_TARGET=web vite", + "dev:hosted": "STUDIO_TARGET=hosted vite", + "dev:local": "STUDIO_TARGET=local vite", "build": "vite build", - "build:web": "STUDIO_TARGET=web vite build", + "build:hosted": "STUDIO_TARGET=hosted vite build", + "build:local": "STUDIO_TARGET=local vite build", "lint": "eslint src", "typecheck": "tsc -p tsconfig.json --noEmit", "preview": "vite preview", - "preview:web": "STUDIO_TARGET=web vite preview" + "preview:hosted": "STUDIO_TARGET=hosted vite preview", + "preview:local": "STUDIO_TARGET=local vite preview" }, "dependencies": { "@base-ui/react": "^1.3.0", diff --git a/apps/ui/src/app/app-providers.tsx b/apps/ui/src/app/app-providers.tsx index 95295a137b..4ff9cadb5e 100644 --- a/apps/ui/src/app/app-providers.tsx +++ b/apps/ui/src/app/app-providers.tsx @@ -7,7 +7,7 @@ import { ConnectorProvider, queryClient } from '@/data/core'; import { AgentRunProvider } from '@/data/queries/use-agent-run'; import { useSyncSessionsWithEvents } from '@/data/queries/use-sessions'; import { useSyncSitesWithEvents } from '@/data/queries/use-sites'; -import { usePrefersColorScheme } from '@/hooks/use-prefers-color-scheme'; +import { useColorScheme } from '@/hooks/use-color-scheme'; import { useSyncConnectSiteListener } from '@/hooks/use-sync-connect-site-listener'; import { unlock } from '@/lock-unlock'; import type { Connector } from '@/data/core'; @@ -26,19 +26,28 @@ function SiteEventsBridge() { return null; } -export function AppProviders( { children, connector }: AppProvidersProps ) { - const colorScheme = usePrefersColorScheme(); +// Themes the app from the resolved color scheme. Lives inside the connector + +// query providers so it can read the saved color-scheme preference (not just +// the OS setting), which is what makes the in-app dark/light toggle work in the +// browser, where there's no Electron `nativeTheme` to mirror it. +function ThemedApp( { children }: PropsWithChildren ) { + const colorScheme = useColorScheme(); const themeColor = colorScheme === 'dark' ? { bg: '#1e1e1e' } : undefined; + return ( + + { children } + + ); +} +export function AppProviders( { children, connector }: AppProvidersProps ) { return ( - - { children } - + { children } diff --git a/apps/ui/src/components/create-site-form/index.tsx b/apps/ui/src/components/create-site-form/index.tsx index 5335afadd7..0fb673753c 100644 --- a/apps/ui/src/components/create-site-form/index.tsx +++ b/apps/ui/src/components/create-site-form/index.tsx @@ -2,7 +2,7 @@ import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { generateCustomDomainFromSiteName } from '@studio/common/lib/domains'; import { generatePassword } from '@studio/common/lib/passwords'; import { RecommendedPHPVersion } from '@studio/common/types/php-versions'; -import { BaseControl, CheckboxControl } from '@wordpress/components'; +import { BaseControl, CheckboxControl, TextControl } from '@wordpress/components'; import { DataForm, useFormValidity } from '@wordpress/dataviews'; import { __, sprintf } from '@wordpress/i18n'; import { chevronDown, chevronRight } from '@wordpress/icons'; @@ -19,6 +19,7 @@ import { customDomainToggleField, wpVersionField, } from '@/components/site-fields'; +import { useConnector } from '@/data/core'; import { usePathValidator } from '@/data/queries/use-create-site-helpers'; import { useSites } from '@/data/queries/use-sites'; import styles from './style.module.css'; @@ -143,10 +144,13 @@ function usePathAutoGenerate( data: FormData, onChange: ( update: Partial< FormD }, [ data.name, data.hasCustomPath, data.path, data.pathError, generateProposedPath ] ); } -// Rendered as a button (not an input) because the value is always set by -// the name→path auto-gen or the native folder dialog — never typed. Also -// sidesteps the browser's refusal to expose `validationMessage` on readonly -// inputs, which was swallowing async errors like path collisions. +// On the desktop this is a button that opens the native folder dialog (the +// value is set by the name→path auto-gen or the dialog, never typed) — which +// also sidesteps the browser's refusal to expose `validationMessage` on +// readonly inputs. In the browser (`studio ui` / hosted) there's no native +// picker, so it falls back to an editable text field: the path is still +// prefilled from the site name, and the server validates the final path on +// create. function PathField( { data: item, field, @@ -154,6 +158,7 @@ function PathField( { onChange, validity, }: DataFormControlProps< FormData > ) { + const connector = useConnector(); const { data: sites } = useSites(); const { selectPath } = usePathValidator( sites ); @@ -169,6 +174,31 @@ function PathField( { }, [ item.hasCustomPath, item.name, item.path, onChange, selectPath ] ); const errorMessage = validity?.custom?.message; + const help = errorMessage ? ( + { errorMessage } + ) : ( + <> + { __( 'Select an empty directory or a directory with an existing WordPress site.' ) }{ ' ' } + + + ); + + // No native folder picker in the browser — edit the path as text. It's + // prefilled from the site name; the server validates it on create. + if ( ! connector.capabilities.nativeFolderPicker ) { + return ( + onChange( { path: value, hasCustomPath: true, pathError: '' } ) } + help={ help } + /> + ); + } + const triggerLabel = item.path ? sprintf( // translators: %s is the currently selected folder path. @@ -182,16 +212,7 @@ function PathField( { __nextHasNoMarginBottom label={ field.label } hideLabelFromVision={ hideLabelFromVision } - help={ - errorMessage ? ( - { errorMessage } - ) : ( - <> - { __( 'Select an empty directory or a directory with an existing WordPress site.' ) }{ ' ' } - - - ) - } + help={ help } > - ) : null } - + aria-pressed={ inspectorState.isPicking } + onClick={ () => sendInspectorCommand( 'toggle-picking' ) } + /> + { inspectorState.annotationCount > 0 ? ( + + ) : null } + + ) : null } ) : (