diff --git a/src/helpers/fast-copy.ts b/src/helpers/fast-copy.ts new file mode 100644 index 0000000..d3af339 --- /dev/null +++ b/src/helpers/fast-copy.ts @@ -0,0 +1,75 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs-extra'; +import * as path from 'path'; + +const execFileAsync = promisify(execFile); + +interface CopyTreeOptions { + exclude?: string[]; + intoExisting?: boolean; +} + +export async function copyTree( + src: string, + dest: string, + opts: CopyTreeOptions = {}, +): Promise<{ method: 'clonefile' | 'copy' }> { + const exclude = new Set(['.DS_Store', ...(opts.exclude || [])]); + + if (process.platform === 'darwin' && (!opts.exclude || opts.exclude.length === 0)) { + try { + const copySource = opts.intoExisting ? `${src}${path.sep}.` : src; + await execFileAsync('/bin/cp', ['-Rc', copySource, dest]); + return { method: 'clonefile' }; + } catch { + // Fall through when clonefile is unavailable, including cross-volume copies. + } + } + + await fs.copy(src, dest, { + filter: (source) => !exclude.has(path.basename(source)), + }); + + return { method: 'copy' }; +} + +/** + * Re-point absolute symlinks that still target the source tree a copy was made + * from (e.g. Query Monitor's wp-content/db.php) at the equivalent path inside + * the copy. Relative symlinks already resolve within the copy and are left alone. + */ +export async function retargetSymlinks(root: string, fromPrefix: string, toPrefix: string): Promise { + const skip = new Set(['node_modules', '.git']); + let retargeted = 0; + + async function walk(dir: string): Promise { + let entries; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isSymbolicLink()) { + try { + const target = await fs.readlink(fullPath); + if (target.startsWith(fromPrefix)) { + const newTarget = toPrefix + target.slice(fromPrefix.length); + await fs.remove(fullPath); + await fs.symlink(newTarget, fullPath); + retargeted += 1; + } + } catch { + // Leave unreadable/broken links alone + } + } else if (entry.isDirectory() && !skip.has(entry.name)) { + await walk(fullPath); + } + } + } + + await walk(root); + return retargeted; +} diff --git a/src/main.ts b/src/main.ts index 0a77c3c..4f99b8c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,8 +12,10 @@ import { } from './helpers/paths'; import { SiteConfig, SiteConfigRegistry } from './helpers/site-config'; import { findAvailablePort, savePort, removePortFile, removePortFileSync } from './helpers/port'; +import { copyTree, retargetSymlinks } from './helpers/fast-copy'; import { createMcpHttpServer, startMcpHttpServer, stopMcpHttpServer, closeSessionsForSite } from './mcp-server'; -import { LocalApi } from './tools'; +import { LocalApi, PreviewInfo } from './tools'; +import { execWpCli } from './tools/wpcli'; // --------------------------------------------------------------------------- // Types @@ -123,6 +125,10 @@ function isAgentToolsEnabled(site: Local.Site): boolean { return !!site.customOptions?.agentToolsEnabled; } +function isPreviewSite(site: Local.Site): boolean { + return site.customOptions?.agentToolsPreview === true; +} + function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -732,6 +738,334 @@ function createLocalApi(): LocalApi { status: statuses[site.id] || 'unknown', })); }, + + async createPreview(parentSiteId: string, label?: string): Promise { + const parent = LocalMain.SiteData.getSite(parentSiteId); + if (!parent) throw new Error(`Site not found: ${parentSiteId}`); + if (isPreviewSite(parent)) { + throw new Error('Create previews from the primary site, not from another preview.'); + } + + // Purpose-labeled names ("Amazing Facts - Polylang Fix") let a human scan + // Local's sidebar and know what each preview is for; the random suffix is + // only a fallback for label-less calls. + const suffix = Math.random().toString(36).slice(2, 8).padEnd(6, '0'); + const newSiteName = label?.trim() ? `${parent.name} - ${label.trim()}` : `${parent.name} Preview ${suffix}`; + const sites = LocalMain.SiteData.getSites(); + if ((Object.values(sites) as Local.Site[]).some((site) => site.name === newSiteName)) { + throw new Error( + `A site named "${newSiteName}" already exists. Pick a different label, or use preview_list to find ` + + 'the existing preview and reuse or destroy it.', + ); + } + + const serviceContainer = LocalMain.getServiceContainer(); + const { + siteProcessManager, + siteProvisioner, + siteDatabase, + changeSiteDomain, + lightningServices, + sitesOrganization, + localLogger, + } = serviceContainer.cradle; + // Route step logs through Local's logger so they land in + // local-lightning.log — the add-on's stdout is lost in normal launches. + // Every preview run benchmarks itself: one info line per step with ms. + const logger = localLogger.child({ thread: 'main', class: 'AgentToolsPreview' }); + // Local's renderer only learns about sites from IPC events, so mirror the + // status updates CloneSite sends (but never selectSite — a background + // preview must not steal the user's UI selection). + let previewIdForUi: string | null = null; + const runStep = async (step: string, action: () => Promise): Promise => { + const stepStart = Date.now(); + logger.info(`preview step started: ${step}`, { step, parentSiteId }); + try { + const result = await action(); + logger.info(`preview step finished: ${step}`, { step, parentSiteId, ms: Date.now() - stepStart }); + return result; + } catch (err: unknown) { + if (previewIdForUi) { + try { + LocalMain.sendIPCEvent('updateSiteStatus', previewIdForUi, 'halted'); + LocalMain.sendIPCEvent('updateSiteMessage', previewIdForUi, ''); + } catch { + // Best-effort UI update only + } + } + const message = err instanceof Error ? err.message : String(err); + const stepError = new Error(`preview provision failed at ${step}: ${message}`); + (stepError as Error & { cause?: unknown }).cause = err; + throw stepError; + } + }; + + const preview = await runStep('record', async () => { + const dupJson = JSON.parse(JSON.stringify(parent)) as Local.SiteJSON & { + localBackupRepoID?: string; + remoteBackups?: { resticRepoId?: string }; + }; + let id: string; + do { + id = ''; + while (id.length < 12) id += Math.random().toString(36).slice(2); + id = id.slice(0, 12); + } while (LocalMain.SiteData.getSite(id)); + + const niceName = newSiteName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const lastDot = parent.domain.lastIndexOf('.'); + const tld = + lastDot >= 0 && lastDot < parent.domain.length - 1 ? parent.domain.slice(lastDot) : '.local'; + let domainSuffix = 0; + let candidateDomain = `${niceName}${tld}`; + while (LocalMain.SiteData.getSiteByProperty('domain', candidateDomain)) { + domainSuffix += 1; + candidateDomain = `${niceName}-${domainSuffix}${tld}`; + } + + const parentPath = getSitePath(parent); + let pathSuffix = 0; + let candidatePath = path.join(path.dirname(parentPath), niceName); + while ( + LocalMain.SiteData.getSiteByProperty('path', candidatePath) || + (await fs.pathExists(candidatePath)) + ) { + pathSuffix += 1; + candidatePath = path.join(path.dirname(parentPath), `${niceName}-${pathSuffix}`); + } + + dupJson.id = id; + dupJson.name = newSiteName; + dupJson.domain = candidateDomain; + dupJson.path = candidatePath; + delete dupJson.liveLinkSettings; + delete dupJson.localBackupRepoID; + if (dupJson.remoteBackups) delete dupJson.remoteBackups.resticRepoId; + + const customOptions = { ...dupJson.customOptions }; + delete customOptions.agentToolsEnabled; + delete customOptions.agentToolsProjectDir; + delete customOptions.agentToolsAgents; + dupJson.customOptions = { + ...customOptions, + agentToolsPreview: true, + agentToolsPreviewOf: parentSiteId, + }; + + LocalMain.SiteData.addSite(id, dupJson); + const dupSite = LocalMain.SiteData.getSite(id); + if (!dupSite) throw new Error(`site record was not readable after addSite: ${id}`); + previewIdForUi = dupSite.id; + sitesOrganization.moveSitesToGroup([dupSite.id], 'default', true); + LocalMain.sendIPCEvent('updateSiteStatus', dupSite.id, 'provisioning'); + return dupSite; + }); + + await runStep('files', async () => { + LocalMain.sendIPCEvent('updateSiteMessage', preview.id, 'Copying site files'); + const src = getSitePath(parent); + const dest = getSitePath(preview); + let copied = false; + if (process.platform === 'darwin') { + const result = await copyTree(src, dest); + if (result.method === 'clonefile') { + logger.info('preview files copy method', { method: result.method }); + copied = true; + } else { + await fs.remove(dest); + } + } + if (!copied) { + const result = await copyTree(src, dest, { exclude: ['node_modules', '.git'] }); + logger.info('preview files copy method', { method: result.method }); + } + + // Absolute symlinks in the copy (e.g. Query Monitor's db.php drop-in) + // still point into the parent tree and double-load parent code. + const retargeted = await retargetSymlinks(dest, src, dest); + if (retargeted > 0) { + logger.info('preview retargeted parent-pointing symlinks', { retargeted }); + } + }); + + await runStep('provision', () => siteProvisioner.provision(preview)); + await runStep('db-stop', () => siteProcessManager.stop(preview, { dumpDatabase: false })); + await runStep('db-copy', async () => { + LocalMain.sendIPCEvent('updateSiteMessage', preview.id, 'Copying site database'); + const parentDatabaseService = lightningServices.getSiteServiceByRole( + parent, + Local.SiteServiceRole.DATABASE, + ); + const previewDatabaseService = lightningServices.getSiteServiceByRole( + preview, + Local.SiteServiceRole.DATABASE, + ); + if (!parentDatabaseService) throw new Error('parent database service was not found'); + if (!previewDatabaseService) throw new Error('preview database service was not found'); + + // Local's type omits dataPath even though database services expose it at runtime. + const parentDataPath = (parentDatabaseService as unknown as { dataPath?: string }).dataPath; + const previewDataPath = (previewDatabaseService as unknown as { dataPath?: string }).dataPath; + if (!parentDataPath) throw new Error('parent database service has no dataPath'); + if (!previewDataPath) throw new Error('preview database service has no dataPath'); + + await fs.emptyDir(previewDataPath); + // Local also copies the live parent data directory and relies on InnoDB crash recovery. + const result = await copyTree(parentDataPath, previewDataPath, { intoExisting: true }); + logger.info('preview db copy method', { method: result.method }); + }); + await runStep('start', () => siteProcessManager.start(preview)); + await runStep('db-wait', () => siteDatabase.waitForDB(preview)); + await runStep('domain', async () => { + const currentPreviewSite = LocalMain.SiteData.getSite(preview.id); + if (!currentPreviewSite) { + throw new Error(`preview site was not readable before domain rewrite: ${preview.id}`); + } + + const config = await buildSiteConfig(currentPreviewSite); + const oldDomain = parent.domain; + const newDomain = preview.domain; + if (!oldDomain || !newDomain) throw new Error('preview domain rewrite requires two non-empty domains'); + if (oldDomain === newDomain) throw new Error('preview domain must differ from the parent domain'); + + // A single bare-domain pass subsumes protocol-specific passes: the + // domain is a substring of every http(s) URL, and previews never + // change protocol. search-replace scans every table per pass, so + // one pass instead of three is the dominant cost saving on big DBs. + const replacements = [[oldDomain, newDomain]]; + for (const [oldValue, newValue] of replacements) { + const { stdout } = await execWpCli( + config, + [ + `--url=${oldDomain}`, + 'search-replace', + oldValue, + newValue, + '--all-tables-with-prefix', + '--report-changed-only', + '--format=count', + ], + { skipPlugins: true, skipThemes: true, neutralizeMuPlugins: true, timeoutMs: 10 * 60_000 }, + ); + logger.info('preview domain rewrite pass', { + from: oldValue, + to: newValue, + changed: stdout.trim(), + }); + } + + const wpConfigCandidates = [ + path.join(config.wpPath, 'wp-config.php'), + path.join(path.dirname(config.wpPath), 'wp-config.php'), + ]; + let wpConfigPath: string | undefined; + for (const candidate of wpConfigCandidates) { + if (await fs.pathExists(candidate)) { + wpConfigPath = candidate; + break; + } + } + if (!wpConfigPath) throw new Error('wp-config.php was not found in or above the WordPress path'); + + const wpConfig = await fs.readFile(wpConfigPath, 'utf8'); + let rewrittenWpConfig = wpConfig.split(oldDomain).join(newDomain); + + // Persistent object caches (Redis/Memcached drop-ins) are shared with + // the parent site, so the preview must namespace its cache keys or it + // reads the parent's cached data. Insert-or-replace both constants. + const cachePrefix = `preview-${preview.id}:`; + for (const constant of ['WP_REDIS_PREFIX', 'WP_CACHE_KEY_SALT']) { + const defineRegex = new RegExp( + `define\\(\\s*['"]${constant}['"]\\s*,\\s*(?:'[^']*'|"[^"]*")\\s*\\)`, + ); + const replacement = `define( '${constant}', '${cachePrefix}' )`; + if (defineRegex.test(rewrittenWpConfig)) { + rewrittenWpConfig = rewrittenWpConfig.replace(defineRegex, replacement); + } else { + rewrittenWpConfig = rewrittenWpConfig.replace( + /<\?php\s*\n/, + (match) => `${match}${replacement};\n`, + ); + } + } + + if (rewrittenWpConfig !== wpConfig) { + await fs.writeFile(wpConfigPath, rewrittenWpConfig, 'utf8'); + } + + try { + await changeSiteDomain.changeSiteDomainToHost(preview); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + logger.warn('preview changeSiteDomainToHost finisher failed (non-fatal)', { message }); + } + }); + + return runStep('register', async () => { + const currentPreview = LocalMain.SiteData.getSite(preview.id) || preview; + const config = await buildSiteConfig(currentPreview); + siteConfigRegistry.register(config); + + LocalMain.sendIPCEvent('updateSiteStatus', currentPreview.id, 'running'); + LocalMain.sendIPCEvent('updateSiteMessage', currentPreview.id, ''); + await LocalMain.HooksMain.doActions('siteAdded', currentPreview); + LocalMain.sendIPCEvent('siteAdded', currentPreview); + + return { + id: currentPreview.id, + name: currentPreview.name, + domain: config.siteDomain, + siteUrl: config.siteUrl, + sitePath: config.sitePath, + wpPath: config.wpPath, + status: siteProcessManager.getSiteStatus(currentPreview), + parentSiteId, + mcpUrl: `http://localhost:${mcpServerPort}/sites/${currentPreview.id}/mcp`, + }; + }); + }, + + async listPreviews(): Promise { + const serviceContainer = LocalMain.getServiceContainer(); + const siteProcessManager = serviceContainer.cradle.siteProcessManager; + const statuses = siteProcessManager.getSiteStatuses(); + const sites = LocalMain.SiteData.getSites(); + + return (Object.values(sites) as Local.Site[]).filter(isPreviewSite).map((site) => { + const sitePath = getSitePath(site); + + return { + id: site.id, + name: site.name, + domain: site.domain || '', + siteUrl: `https://${site.domain || ''}`, + sitePath, + wpPath: path.join(sitePath, 'app', 'public'), + status: statuses[site.id] || 'unknown', + parentSiteId: site.customOptions?.agentToolsPreviewOf || '', + mcpUrl: `http://localhost:${mcpServerPort}/sites/${site.id}/mcp`, + }; + }); + }, + + async destroyPreview(siteId: string) { + const site = LocalMain.SiteData.getSite(siteId); + if (!site) throw new Error(`Site not found: ${siteId}`); + if (site.customOptions?.agentToolsPreview !== true) { + throw new Error('Refusing to delete a non-preview site.'); + } + + closeSessionsForSite(siteId); + siteConfigRegistry.unregister(siteId); + + const serviceContainer = LocalMain.getServiceContainer(); + await serviceContainer.cradle.deleteSite.deleteSite({ site, trashFiles: true, updateHosts: true }); + + return { id: site.id, name: site.name, deleted: true }; + }, }; } @@ -769,14 +1103,15 @@ export default function (context: LocalMain.AddonMainContext): void { await savePort(mcpServerPort); - // Register configs for all sites with Agent Tools enabled (regardless of running status). + // Register configs for all sites with Agent Tools enabled and all preview sites + // (regardless of running status). // This ensures the MCP endpoint is always reachable — tools that need the site // running (WP-CLI, DB) will return appropriate errors; file-based tools still work. try { const sites = LocalMain.SiteData.getSites(); for (const site of Object.values(sites) as Local.Site[]) { - if (isAgentToolsEnabled(site)) { + if (isAgentToolsEnabled(site) || isPreviewSite(site)) { try { const siteConfig = await buildSiteConfig(site); siteConfigRegistry.register(siteConfig); diff --git a/src/mcp-server.ts b/src/mcp-server.ts index db6c98c..876e1f6 100644 --- a/src/mcp-server.ts +++ b/src/mcp-server.ts @@ -1,7 +1,7 @@ import * as http from 'http'; import { randomUUID } from 'crypto'; import { SiteConfigRegistry } from './helpers/site-config'; -import { allToolDefinitions, handleToolCall, LocalApi } from './tools'; +import { allToolDefinitions, crossSiteToolNames, handleToolCall, LocalApi } from './tools'; // --------------------------------------------------------------------------- // MCP SDK — loaded via require() for CJS compatibility. @@ -135,14 +135,25 @@ function createMcpServer(siteId: string, registry: SiteConfigRegistry, localApi: server.setRequestHandler(CallToolRequestSchema, async (request: McpRequest) => { const { name, arguments: args } = request.params; - console.log(`[Agent Tools] Tool called: ${name} (site: ${siteId})`); + + // Site-bound tools accept an optional siteId to target another registered + // site (e.g. a preview) without the client reconnecting to its endpoint. + const argSiteId = (args as Record | undefined)?.siteId; + const targetSiteId = + crossSiteToolNames.has(name) && typeof argSiteId === 'string' && argSiteId ? argSiteId : siteId; + console.log(`[Agent Tools] Tool called: ${name} (site: ${targetSiteId})`); // Look up config fresh on every call so we always use the latest // (e.g., after site start updates socket paths, PHP binary, etc.) - const config = registry.get(siteId); + const config = registry.get(targetSiteId); if (!config) { + const text = + targetSiteId === siteId + ? `Site ${siteId} is no longer registered.` + : `Site ${targetSiteId} is not registered with Agent Tools. Use preview_list or list_sites to find ` + + 'valid targets; previews register when preview_start completes.'; return { - content: [{ type: 'text', text: `Site ${siteId} is no longer registered.` }], + content: [{ type: 'text', text }], isError: true, }; } diff --git a/src/tools/environment.ts b/src/tools/environment.ts index 463a281..0a3641d 100644 --- a/src/tools/environment.ts +++ b/src/tools/environment.ts @@ -3,12 +3,27 @@ import { SiteConfig } from '../helpers/site-config'; // ── LocalApi interface ───────────────────────────────────────────────── // Implemented in main.ts, wrapping Local's SiteProcessManager APIs. +export interface PreviewInfo { + id: string; + name: string; + domain: string; + siteUrl: string; + sitePath: string; + wpPath: string; + status: string; + parentSiteId: string; + mcpUrl: string; +} + export interface LocalApi { startSite(siteId: string): Promise<{ id: string; name?: string; status: string; message?: string }>; stopSite(siteId: string): Promise<{ id: string; name?: string; status: string; message?: string }>; restartSite(siteId: string): Promise<{ id: string; name?: string; status: string; message?: string }>; getSiteStatus(siteId: string): Promise<{ id: string; name?: string; domain?: string; status: string }>; listSites(): Promise>; + createPreview(parentSiteId: string, label?: string): Promise; + listPreviews(): Promise; + destroyPreview(siteId: string): Promise<{ id: string; name: string; deleted: true }>; } // ── Tool Definitions ─────────────────────────────────────────────────── diff --git a/src/tools/index.ts b/src/tools/index.ts index ed9a54d..5b1ad75 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -4,13 +4,55 @@ import { toolDefinitions as logTools, handleTool as handleLogTool } from './logs import { toolDefinitions as configTools, handleTool as handleConfigTool } from './config'; import { toolDefinitions as siteTools, handleTool as handleSiteTool } from './site'; import { toolDefinitions as environmentTools, handleTool as handleEnvironmentTool, LocalApi } from './environment'; +import { toolDefinitions as previewTools, handleTool as handlePreviewTool } from './preview'; -export type { LocalApi } from './environment'; +export type { LocalApi, PreviewInfo } from './environment'; export type ToolResult = { content: Array<{ type: string; text: string }> }; +/** + * Site-bound tools (WP-CLI, logs, config, site info) resolve against the + * endpoint's site by default, but accept an optional siteId so a session + * connected to the primary site can operate on another REGISTERED site — + * the preview-environment flow: spin up a preview via preview_start, then + * run wp_cli / read logs against it without reconnecting the MCP client. + * Environment tools (site_start etc.) are excluded: they route their own + * siteId against ALL Local sites, registered or not. + */ +export const crossSiteToolNames = new Set( + [...wpcliTools, ...logTools, ...configTools, ...siteTools].map((t) => t.name), +); + +const SITE_ID_PARAM = { + type: 'string', + description: + 'Optional Local site ID to run this against a different registered site — for example a preview created ' + + "by preview_start. Defaults to this endpoint's site.", +}; + +function withSiteIdParam } }>( + tool: T, +): T { + if (!crossSiteToolNames.has(tool.name)) return tool; + if (tool.inputSchema.properties?.siteId) return tool; + return { + ...tool, + inputSchema: { + ...tool.inputSchema, + properties: { ...tool.inputSchema.properties, siteId: SITE_ID_PARAM }, + }, + }; +} + // All tool definitions aggregated -export const allToolDefinitions = [...wpcliTools, ...logTools, ...configTools, ...siteTools, ...environmentTools]; +export const allToolDefinitions = [ + ...wpcliTools, + ...logTools, + ...configTools, + ...siteTools, + ...environmentTools, + ...previewTools, +].map(withSiteIdParam); // Unified handler type: (name, args, config, localApi) => ToolResult type ToolHandler = ( @@ -38,6 +80,9 @@ for (const tool of siteTools) { for (const tool of environmentTools) { toolHandlerMap[tool.name] = (name, args, config, localApi) => handleEnvironmentTool(name, args, config, localApi); } +for (const tool of previewTools) { + toolHandlerMap[tool.name] = (name, args, config, localApi) => handlePreviewTool(name, args, config, localApi); +} /** * Handle a tool call, routing to the correct module based on tool name. diff --git a/src/tools/preview.ts b/src/tools/preview.ts new file mode 100644 index 0000000..de5eeb8 --- /dev/null +++ b/src/tools/preview.ts @@ -0,0 +1,92 @@ +import { SiteConfig } from '../helpers/site-config'; +import { LocalApi } from './environment'; + +// ── Tool Definitions ─────────────────────────────────────────────────── +export const toolDefinitions = [ + { + name: 'preview_start', + description: + 'Clone a Local site (default: the current site) into a disposable preview site with its own database, PHP/MySQL processes, domain, and logs. ' + + "Returns the preview's own MCP endpoint URL and is intended for isolated agent work (for example, a git worktree). " + + 'Cloning copies the full database and wp-content and may take a while on large sites.\n\n' + + 'Always pass a "label" describing the purpose of the preview (the task, branch, or ticket — e.g. "Polylang Fix", ' + + '"player-v3", "PROJ-123"). The site is named " -