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..4ecedc3fdb --- /dev/null +++ b/apps/cli/commands/ui.ts @@ -0,0 +1,72 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +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 }, { STUDIO_SITES_ROOT } ] = + await Promise.all( [ + import( '@studio/local' ), + import( 'cli/ai/sessions/paths' ), + 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, + } ); + + 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 8ea3c58292..b57676ced5 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 { registerCommand as registerUninstallCommand } from 'cli/commands/uninstall'; import { bumpAggregatedUniqueStat, @@ -216,6 +217,7 @@ async function main() { registerImportCommand( studioArgv ); registerExportCommand( studioArgv ); + registerUiCommand( studioArgv ); registerUninstallCommand( studioArgv ); studioArgv.command( 'preview', __( 'Manage preview sites' ), async ( previewYargs ) => { diff --git a/apps/cli/package.json b/apps/cli/package.json index 28e86ce1b0..35ef5e6fa0 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -76,6 +76,7 @@ }, "devDependencies": { "@studio/common": "file:../../packages/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 bbee5ef017..ab2890866f 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -5,7 +5,8 @@ "moduleResolution": "bundler", "paths": { "cli/*": [ "./*" ], - "@studio/common/*": [ "../../packages/common/*" ] + "@studio/common/*": [ "../../packages/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 6232afb3ff..85fadcf34c 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, '../../packages/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/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..de6af5a82f --- /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:../../packages/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..4a13a622b2 --- /dev/null +++ b/apps/local/src/index.ts @@ -0,0 +1,1254 @@ +import crypto from 'node:crypto'; +import { createWriteStream, existsSync, mkdtempSync, rm } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { isAiModelId } from '@studio/common/ai/models'; +import { createAgentRunManager } from '@studio/common/ai/run-manager'; +import { + createOrReuseAiSession, + hydrateAiSessionSummary, + listHydratedAiSessions, + loadHydratedAiSession, +} from '@studio/common/ai/sessions/manage'; +import { + deleteAiSessionPlacement, + readAiSessionPlacement, +} from '@studio/common/ai/sessions/placement'; +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 { 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 { + 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 { startSite, stopSite } from '@studio/common/sites/lifecycle'; +import { listSites } from '@studio/common/sites/list'; +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 { 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; +} + +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 } = 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` ); + } + } + + // Background watch for the WordPress.com site the user is about to create via + // the "Create new" checkout. A browser tab can't receive the wp-studio:// + // deep link the desktop uses, so we poll the account's syncable sites and, + // when a new one appears, report it on the `sync-connect` channel for the + // connector's onSyncConnectSite to auto-connect. Keyed by studio site id so a + // re-trigger supersedes the prior watch; all are cancelled on close(). + const publishWatches = new Map< string, { cancelled: boolean } >(); + function startPublishWatch( + studioSiteId: string, + expectedName: string, + accessToken: string, + knownIds: Set< number > + ): void { + const previous = publishWatches.get( studioSiteId ); + if ( previous ) { + previous.cancelled = true; + } + const watch = { cancelled: false }; + publishWatches.set( studioSiteId, watch ); + + const POLL_INTERVAL_MS = 5_000; + const slug = expectedName.toLowerCase(); + let attemptsLeft = 60; // ~5 minutes at a 5s interval. + + const poll = async (): Promise< void > => { + // A superseded/torn-down watch bails before any map mutation so it can't + // clobber the entry a newer watch now owns. + if ( watch.cancelled ) { + return; + } + let matchId: number | undefined; + try { + const sites = await fetchSyncableSites( accessToken ); + const fresh = sites.filter( ( candidate ) => ! knownIds.has( candidate.id ) ); + const match = + fresh.find( + ( candidate ) => + !! slug && + ( ( candidate.name ?? '' ).toLowerCase().includes( slug ) || + ( candidate.url ?? '' ).toLowerCase().includes( slug ) ) + ) ?? ( fresh.length === 1 ? fresh[ 0 ] : undefined ); + matchId = match?.id; + } catch { + // Transient (token refresh, network); keep polling until the deadline. + } + if ( watch.cancelled ) { + return; + } + if ( matchId !== undefined ) { + publishWatches.delete( studioSiteId ); + sseSend( { + channel: 'sync-connect', + payload: { remoteSiteId: matchId, studioSiteId }, + } ); + return; + } + if ( --attemptsLeft <= 0 ) { + publishWatches.delete( studioSiteId ); + return; + } + setTimeout( () => void poll(), POLL_INTERVAL_MS ); + }; + void poll(); + } + + const runManager = createAgentRunManager( { + cliBinary, + nodeBinary, + surface: 'cliui', + // 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(); + + // Restrict cross-origin access to the known `studio ui` origins. The server is + // loopback-only, but loopback is reachable from any page in the user's + // browser, so reflecting arbitrary origins would let any website call this + // unauthenticated API (read data, or trigger state changes). Requests with no + // Origin (same-origin navigations, the served SPA's same-origin fetches, curl, + // SSE) pass through; a present-but-disallowed Origin is rejected outright. + const allowedOrigins = new Set( [ + `http://localhost:${ port }`, + `http://127.0.0.1:${ port }`, + // Vite dev server for `npm run dev:local --workspace=apps/ui`. + 'http://localhost:5400', + 'http://127.0.0.1:5400', + // The studio.local custom-domain setup. + 'https://studio.local', + ] ); + app.use( ( req: Request, res: Response, next ) => { + const origin = req.headers.origin; + if ( origin ) { + if ( ! allowedOrigins.has( origin ) ) { + res.status( 403 ).json( { error: 'Origin not allowed' } ); + return; + } + res.setHeader( 'Access-Control-Allow-Origin', origin ); + res.setHeader( 'Vary', '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 the caller didn't supply a redirect URI, 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 ( ! redirectUri ) { + res.json( { url: null } ); + return; + } + res.json( { url: getAuthenticationUrl( 'en', redirectUri ) } ); + } ); + + // 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; + } + // The zip is always an upload under the OS temp dir (via /uploads); + // reject anything else so this can't be used to read arbitrary files. + const resolvedZip = path.resolve( zipFilePath ); + if ( ! ( resolvedZip + path.sep ).startsWith( path.resolve( os.tmpdir() ) + path.sep ) ) { + res.status( 400 ).json( { error: 'zipFilePath must be an uploaded file' } ); + return; + } + res.json( await extractBlueprintBundle( resolvedZip ) ); + } ) + ); + + 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 — but only a temp dir we created: a + // direct child of the OS temp dir. Resolve first so `..` in the + // supplied path can't escape the temp root and delete elsewhere. + const uploadDir = path.dirname( path.resolve( archivePath ) ); + if ( path.dirname( uploadDir ) === path.resolve( os.tmpdir() ) ) { + rm( uploadDir, { recursive: true, force: true }, () => undefined ); + } + } + const imported = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id ); + res.json( toSiteDetails( imported ?? site ) ); + } ) + ); + + // NOTE: there is intentionally no `/media/read` endpoint. Streaming an + // arbitrary local file by absolute path over HTTP is an arbitrary-read risk + // (the API is reachable cross-origin from the browser), and nothing in the UI + // consumes it yet. The connector's `readLocalMediaFile` throws until a real + // consumer and a path-containment policy (e.g. restricted to the sites root) + // exist. + + // 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 ); + } ) + ); + + // Start watching for the WordPress.com site the user is about to create in the + // "Create new" checkout tab (the local analog of the desktop's deep link). The + // new site, once it appears in the account, is reported on the `sync-connect` + // SSE channel; this responds immediately while the watch runs in the background. + api.post( + '/sites/:id/watch-published-site', + asyncHandler( async ( req: Request, res: Response ) => { + const token = await readAuthToken(); + if ( ! token?.accessToken ) { + res.status( 401 ).json( { error: 'Authentication required.' } ); + return; + } + const studioSiteId = req.params.id; + const site = ( await listSites( execute ) ).find( ( s ) => s.id === studioSiteId ); + const expectedName = site?.customDomain ?? site?.name ?? ''; + const knownIds = new Set( + ( await fetchSyncableSites( token.accessToken ) ).map( ( s ) => s.id ) + ); + startPublishWatch( studioSiteId, expectedName, token.accessToken, knownIds ); + res.sendStatus( 202 ); + } ) + ); + + // --- 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 watch of publishWatches.values() ) { + watch.cancelled = true; + } + publishWatches.clear(); + 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..7e709eb83e --- /dev/null +++ b/apps/local/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "es2022", + "moduleResolution": "bundler", + "paths": { + "@studio/common/*": [ "../../packages/common/*" ] + } + }, + "include": [ "**/*" ], + "exclude": [ "**/node_modules/**/*", "**/dist/**/*", "**/out/**/*" ] +} 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 4e034fdb46..f25a0faf7d 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -6,12 +6,15 @@ "scripts": { "dev": "vite", "dev:hosted": "STUDIO_TARGET=hosted vite", + "dev:local": "STUDIO_TARGET=local vite", "build": "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:hosted": "STUDIO_TARGET=hosted 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 } ) : (