Add guarded WordPress pull to local workflow

This commit is contained in:
reaper
2026-08-15 07:00:28 -05:00
parent 22dd00c4c0
commit 8b23e5f590
8 changed files with 276 additions and 0 deletions
+2
View File
@@ -10,6 +10,7 @@ import { registerLogsIpc } from './ipc/logs'
import { registerCreateIpc } from './ipc/create'
import { registerWindowIpc } from './ipc/window'
import { registerSecretsIpc } from './ipc/secrets'
import { registerRemoteIpc } from './ipc/remote'
import { killAllRunningCommands, powerOffAllProjects } from './commandRunner'
import { startAutomaticUpdates } from './updater'
@@ -68,6 +69,7 @@ app.whenReady().then(() => {
registerCreateIpc()
registerWindowIpc()
registerSecretsIpc()
registerRemoteIpc()
createWindow()
startAutomaticUpdates()
+143
View File
@@ -0,0 +1,143 @@
import { dialog, ipcMain, safeStorage, type WebContents } from 'electron'
import { execFile } from 'child_process'
import { copyFile, mkdir, readFile, writeFile } from 'fs/promises'
import { join } from 'path'
import { promisify } from 'util'
import type { AuroraRemoteSiteProfile, AuroraRemoteSiteStatus } from '../../shared/types'
import { getProjectConfig, getProjectRoot } from '../auroraEngine'
import { runCommandStreamed } from '../commandRunner'
const execFileAsync = promisify(execFile)
const profilePath = (root: string): string => join(root, '.aurora', 'remote-site.json')
type StoredProfile = Omit<AuroraRemoteSiteProfile, 'applicationPassword' | 'configured'> & { encryptedApplicationPassword: string }
function validate(profile: AuroraRemoteSiteProfile): AuroraRemoteSiteProfile {
let siteUrl: URL
try { siteUrl = new URL(profile.siteUrl) } catch { throw new Error('Enter a valid production site URL.') }
if (!['http:', 'https:'].includes(siteUrl.protocol)) throw new Error('The site URL must use HTTP or HTTPS.')
if (!/^[a-zA-Z0-9.-]+$/.test(profile.sshHost)) throw new Error('Enter a valid SSH hostname.')
if (!/^[a-zA-Z0-9._-]+$/.test(profile.sshUsername)) throw new Error('Enter a valid SSH username.')
if (!Number.isInteger(profile.sshPort) || profile.sshPort < 1 || profile.sshPort > 65535) throw new Error('Enter a valid SSH port.')
if (!/^\/[a-zA-Z0-9._/-]+$/.test(profile.remotePath)) throw new Error('The remote WordPress path must be absolute and contain only letters, numbers, dots, dashes, underscores, and slashes.')
if (!profile.wordpressUsername.trim()) throw new Error('Enter the WordPress username used by the Application Password.')
return { ...profile, siteUrl: siteUrl.origin, sshHost: profile.sshHost.trim(), sshUsername: profile.sshUsername.trim(), wordpressUsername: profile.wordpressUsername.trim(), remotePath: profile.remotePath.replace(/\/+$/, '') }
}
function encrypt(value: string): string {
if (!safeStorage.isEncryptionAvailable()) throw new Error('Secure credential storage is unavailable on this computer.')
return safeStorage.encryptString(value).toString('base64')
}
function decrypt(value: string): string {
return safeStorage.decryptString(Buffer.from(value, 'base64'))
}
async function loadStored(root: string): Promise<StoredProfile | null> {
try { return JSON.parse(await readFile(profilePath(root), 'utf8')) as StoredProfile } catch { return null }
}
async function save(root: string, input: AuroraRemoteSiteProfile): Promise<void> {
const profile = validate(input)
const existing = await loadStored(root)
const password = profile.applicationPassword?.trim()
const encryptedApplicationPassword = password ? encrypt(password) : existing?.encryptedApplicationPassword
if (!encryptedApplicationPassword) throw new Error('Enter a WordPress Application Password.')
const { applicationPassword: _removed, configured: _configured, ...publicProfile } = profile
await mkdir(join(root, '.aurora'), { recursive: true })
await writeFile(profilePath(root), JSON.stringify({ ...publicProfile, encryptedApplicationPassword }, null, 2) + '\n', { mode: 0o600 })
}
async function loadedProfile(root: string): Promise<AuroraRemoteSiteProfile> {
const stored = await loadStored(root)
if (!stored) throw new Error('Configure and save the remote site first.')
const { encryptedApplicationPassword, ...profile } = stored
return { ...profile, applicationPassword: decrypt(encryptedApplicationPassword), configured: true }
}
function sshArgs(profile: AuroraRemoteSiteProfile): string[] {
return ['-p', String(profile.sshPort), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=12', '-o', 'StrictHostKeyChecking=accept-new', ...(profile.privateKeyPath ? ['-i', profile.privateKeyPath] : []), `${profile.sshUsername}@${profile.sshHost}`]
}
async function testConnector(profile: AuroraRemoteSiteProfile): Promise<AuroraRemoteSiteStatus> {
const url = `${profile.siteUrl.replace(/\/$/, '')}/wp-json/aurora-dockside/v1/status`
const auth = Buffer.from(`${profile.wordpressUsername}:${profile.applicationPassword ?? ''}`).toString('base64')
const response = await fetch(url, { headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' }, signal: AbortSignal.timeout(15000) })
if (!response.ok) throw new Error(response.status === 401 ? 'WordPress rejected the username or Application Password.' : `Connector returned HTTP ${response.status}.`)
const data = await response.json() as any
if (data?.connector?.name !== 'Aurora Dockside Connector') throw new Error('The Aurora Dockside Connector did not return a valid response.')
return { connected: true, platform: 'wordpress', siteName: String(data.site?.name ?? ''), siteUrl: String(data.site?.url ?? profile.siteUrl), wordpressVersion: String(data.runtime?.wordpress ?? ''), phpVersion: String(data.runtime?.php ?? ''), databaseVersion: String(data.runtime?.database ?? ''), connectorVersion: String(data.connector?.version ?? '') }
}
async function testSsh(profile: AuroraRemoteSiteProfile): Promise<void> {
await execFileAsync('ssh', [...sshArgs(profile), 'test', '-d', profile.remotePath], { timeout: 15000, maxBuffer: 1024 * 1024 })
}
async function run(sender: WebContents, id: string, command: string, args: string[], cwd: string): Promise<void> {
await runCommandStreamed(id, command, args, sender, { cwd, emitExit: false })
}
async function pullWordPress(sender: WebContents, operationId: string, name: string, root: string): Promise<void> {
const profile = await loadedProfile(root)
await testConnector(profile)
await testSsh(profile)
const config = await getProjectConfig(root)
if (config.type !== 'wordpress') throw new Error('Remote pull currently supports WordPress projects only.')
if (!['mysql', 'mariadb'].includes(config.database)) throw new Error('WordPress remote pull requires MySQL or MariaDB locally.')
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
const backupDir = join(root, '.aurora', 'remote-backups', stamp)
const dumpName = `aurora-${name}-${Date.now()}.sql`
const remoteDump = `/tmp/${dumpName}`
const localDump = join(backupDir, dumpName)
await mkdir(backupDir, { recursive: true })
await copyFile(join(root, '.aurora', 'config.json'), join(backupDir, 'config.json'))
try {
const compose = join(root, '.aurora', 'compose.yaml')
await run(sender, operationId, 'tar', ['-czf', join(backupDir, 'site-files.tar.gz'), '--exclude=.aurora', '-C', root, '.'], root)
await run(sender, operationId, 'docker', ['compose', '-f', compose, 'up', '-d', '--build', '--remove-orphans'], root)
const dumpClient = config.database === 'mariadb' ? 'mariadb-dump' : 'mysqldump'
await run(sender, operationId, 'docker', ['compose', '-f', compose, 'exec', '-T', 'db', 'sh', '-lc', `${dumpClient} -udb -pdb db > /tmp/aurora-local-before-pull.sql`], root)
await run(sender, operationId, 'docker', ['compose', '-f', compose, 'cp', 'db:/tmp/aurora-local-before-pull.sql', join(backupDir, 'local-database.sql')], root)
await run(sender, operationId, 'ssh', [...sshArgs(profile), 'wp', `--path=${profile.remotePath}`, 'db', 'export', remoteDump, '--add-drop-table'], root)
const remote = `${profile.sshUsername}@${profile.sshHost}:${profile.remotePath}/`
await run(sender, operationId, 'rsync', ['-az', '--human-readable', '--info=progress2', '-e', ['ssh', '-p', String(profile.sshPort), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', ...(profile.privateKeyPath ? ['-i', profile.privateKeyPath] : [])].join(' '), '--exclude=.aurora/', '--exclude=wp-config.php', '--exclude=.htaccess', '--exclude=wp-content/cache/', '--exclude=wp-content/upgrade/', remote, `${root}/`], root)
await run(sender, operationId, 'scp', ['-P', String(profile.sshPort), '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', ...(profile.privateKeyPath ? ['-i', profile.privateKeyPath] : []), `${profile.sshUsername}@${profile.sshHost}:${remoteDump}`, localDump], root)
await run(sender, operationId, 'ssh', [...sshArgs(profile), 'rm', '-f', remoteDump], root)
await run(sender, operationId, 'docker', ['compose', '-f', compose, 'cp', localDump, 'db:/tmp/aurora-pull.sql'], root)
const databaseClient = config.database === 'mariadb' ? 'mariadb' : 'mysql'
await run(sender, operationId, 'docker', ['compose', '-f', compose, 'exec', '-T', 'db', databaseClient, '-udb', '-pdb', 'db', '-e', 'source /tmp/aurora-pull.sql'], root)
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
const gid = typeof process.getgid === 'function' ? process.getgid() : undefined
const wpBase = ['run', '--rm', ...(uid === undefined ? [] : ['--user', `${uid}:${gid}`]), '--network', `aurora-${name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-')}_default`, '-e', 'HOME=/tmp', '-v', `${root}:/app`, '-w', '/app', '--entrypoint', 'php', 'wordpress:cli', '/usr/local/bin/wp']
await run(sender, operationId, 'docker', [...wpBase, 'search-replace', profile.siteUrl, `https://${name}.aurora.localhost`, '--all-tables-with-prefix', '--skip-columns=guid', '--precise'], root)
await run(sender, operationId, 'docker', [...wpBase, 'cache', 'flush'], root)
if (!sender.isDestroyed()) sender.send('terminal:exit', { operationId, exitCode: 0, cancelled: false })
} catch (error) {
try { await execFileAsync('ssh', [...sshArgs(profile), 'rm', '-f', remoteDump], { timeout: 10000 }) } catch { /* best effort cleanup */ }
if (!sender.isDestroyed()) sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false })
throw error
}
}
export function registerRemoteIpc(): void {
ipcMain.handle('remote:getProfile', async (_event, name: string) => {
const root = await getProjectRoot(name)
const stored = await loadStored(root)
if (!stored) return null
const { encryptedApplicationPassword: _secret, ...profile } = stored
return { ...profile, applicationPassword: '', configured: true }
})
ipcMain.handle('remote:saveProfile', async (_event, name: string, profile: AuroraRemoteSiteProfile) => save(await getProjectRoot(name), profile))
ipcMain.handle('remote:pickPrivateKey', async () => {
const result = await dialog.showOpenDialog({ properties: ['openFile'], title: 'Select SSH private key' })
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('remote:test', async (_event, name: string, input: AuroraRemoteSiteProfile) => {
const root = await getProjectRoot(name)
await save(root, input)
const profile = await loadedProfile(root)
const status = await testConnector(profile)
await testSsh(profile)
return status
})
ipcMain.handle('remote:pull', async (event, operationId: string, name: string) => pullWordPress(event.sender, operationId, name, await getProjectRoot(name)))
}
+13
View File
@@ -9,6 +9,8 @@ import type {
AuroraProjectSummary,
AuroraSnapshot,
AuroraSiteCredentials,
AuroraRemoteSiteProfile,
AuroraRemoteSiteStatus,
AuroraStackOptions,
EnvironmentUpdate,
LogDataEvent,
@@ -142,6 +144,17 @@ const api = {
getSiteCredentials: (approot: string): Promise<AuroraSiteCredentials | null> =>
ipcRenderer.invoke('secrets:getSiteCredentials', approot)
},
remote: {
getProfile: (name: string): Promise<AuroraRemoteSiteProfile | null> =>
ipcRenderer.invoke('remote:getProfile', name),
saveProfile: (name: string, profile: AuroraRemoteSiteProfile): Promise<void> =>
ipcRenderer.invoke('remote:saveProfile', name, profile),
pickPrivateKey: (): Promise<string | null> => ipcRenderer.invoke('remote:pickPrivateKey'),
test: (name: string, profile: AuroraRemoteSiteProfile): Promise<AuroraRemoteSiteStatus> =>
ipcRenderer.invoke('remote:test', name, profile),
pull: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('remote:pull', operationId, name)
},
zoom: {
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
out: (): Promise<number> => ipcRenderer.invoke('window:zoomOut'),
@@ -36,6 +36,7 @@ import { DatabaseSection } from './DatabaseSection'
import { ModulesSection } from './ModulesSection'
import { DeveloperServices } from './DeveloperServices'
import { DeleteProjectModal } from './DeleteProjectModal'
import { RemoteSiteSection } from './RemoteSiteSection'
import { LogViewer } from '../logs/LogViewer'
import { useAppStore } from '../../stores/appStore'
import { useModuleRegistry, useRunModuleTool } from '../../hooks/useModules'
@@ -469,6 +470,8 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
</section>
)}
<RemoteSiteSection name={project.name} approot={project.approot} projectType={project.type} />
{isRunning && <DeveloperServices project={project} />}
<section className="rounded-xl border border-neutral-200/90 bg-white/90 p-4 shadow-[0_8px_24px_rgba(15,23,42,0.05)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react'
import { ArrowDownToLine, CheckCircle2, FolderKey, KeyRound, Link2, Server, ShieldCheck } from 'lucide-react'
import type { AuroraRemoteSiteProfile, AuroraRemoteSiteStatus } from '@shared/types'
import { usePullRemoteSite, useRemoteSiteProfile, useSaveRemoteSite, useTestRemoteSite } from '../../hooks/useRemoteSite'
const blank: AuroraRemoteSiteProfile = { siteUrl: '', wordpressUsername: '', applicationPassword: '', sshHost: '', sshPort: 22, sshUsername: '', privateKeyPath: '', remotePath: '' }
const input = 'w-full rounded-lg border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 shadow-sm outline-none transition focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950 dark:text-white'
export function RemoteSiteSection({ name, approot, projectType }: { name: string; approot: string; projectType: string }): React.JSX.Element | null {
const { data } = useRemoteSiteProfile(name, approot)
const save = useSaveRemoteSite(name, approot)
const test = useTestRemoteSite(name, approot)
const pull = usePullRemoteSite(name)
const [profile, setProfile] = useState<AuroraRemoteSiteProfile>(blank)
const [status, setStatus] = useState<AuroraRemoteSiteStatus | null>(null)
useEffect(() => { if (data) setProfile({ ...data, applicationPassword: '' }) }, [data])
if (projectType !== 'wordpress') return null
const set = <K extends keyof AuroraRemoteSiteProfile>(key: K, value: AuroraRemoteSiteProfile[K]): void => setProfile((current) => ({ ...current, [key]: value }))
const busy = save.isPending || test.isPending || pull.isPending
const error = save.error ?? test.error ?? pull.error
async function selectKey(): Promise<void> {
const path = await window.api.remote.pickPrivateKey()
if (path) set('privateKeyPath', path)
}
async function testConnection(): Promise<void> {
const result = await test.mutateAsync(profile)
setStatus(result)
}
function pullRemote(): void {
const confirmed = window.confirm(`Pull ${profile.siteUrl || 'the remote site'} into ${name}?\n\nAurora will preserve its local configuration, copy remote WordPress files, replace the local database, and rewrite production URLs for local use. A pre-pull recovery folder will be created.`)
if (confirmed) pull.mutate()
}
return <section className="rounded-xl border border-cyan-200/80 bg-gradient-to-br from-white to-cyan-50/60 p-4 shadow-[0_8px_24px_rgba(15,23,42,0.05)] dark:border-cyan-300/15 dark:from-neutral-950 dark:to-cyan-950/20">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-300"><Link2 size={14} className="text-cyan-600 dark:text-cyan-300"/> Remote Site</h3><p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">Connect this local project to its production WordPress site.</p></div>
{status && <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-300"><CheckCircle2 size={13}/> Connected · WordPress {status.wordpressVersion}</span>}
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<label className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">Production site URL<input className={`${input} mt-1.5`} type="url" placeholder="https://example.com" value={profile.siteUrl} onChange={(e) => set('siteUrl', e.target.value)}/></label>
<label className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">WordPress username<input className={`${input} mt-1.5`} autoComplete="username" value={profile.wordpressUsername} onChange={(e) => set('wordpressUsername', e.target.value)}/></label>
<label className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">Application Password<input className={`${input} mt-1.5`} type="password" autoComplete="new-password" placeholder={profile.configured ? 'Saved securely — leave blank to keep' : 'xxxx xxxx xxxx xxxx'} value={profile.applicationPassword ?? ''} onChange={(e) => set('applicationPassword', e.target.value)}/></label>
<label className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">SSH host<input className={`${input} mt-1.5`} placeholder="server.example.com" value={profile.sshHost} onChange={(e) => set('sshHost', e.target.value)}/></label>
<div className="grid grid-cols-[1fr_90px] gap-2"><label className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">SSH username<input className={`${input} mt-1.5`} value={profile.sshUsername} onChange={(e) => set('sshUsername', e.target.value)}/></label><label className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">Port<input className={`${input} mt-1.5`} type="number" min={1} max={65535} value={profile.sshPort} onChange={(e) => set('sshPort', Number(e.target.value))}/></label></div>
<label className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">WordPress path on server<input className={`${input} mt-1.5 font-mono`} placeholder="/home/user/public_html" value={profile.remotePath} onChange={(e) => set('remotePath', e.target.value)}/></label>
<label className="text-xs font-semibold text-neutral-600 dark:text-neutral-300 md:col-span-2 xl:col-span-3">SSH private key<div className="mt-1.5 flex gap-2"><input className={`${input} font-mono`} readOnly placeholder="Use SSH agent, or select a private key" value={profile.privateKeyPath}/><button type="button" onClick={() => void selectKey()} className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-neutral-200 bg-white px-3 text-xs font-semibold hover:border-cyan-300 dark:border-white/10 dark:bg-white/5"><FolderKey size={14}/> Choose key</button></div></label>
</div>
{status && <div className="mt-4 grid gap-2 rounded-lg border border-emerald-200 bg-emerald-50/70 p-3 text-xs text-emerald-900 sm:grid-cols-3 dark:border-emerald-300/15 dark:bg-emerald-400/5 dark:text-emerald-200"><span><b>{status.siteName}</b><br/>{status.siteUrl}</span><span>PHP {status.phpVersion}<br/>Database {status.databaseVersion}</span><span>Connector {status.connectorVersion}<br/>SSH verified</span></div>}
{error && <p role="alert" className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-400/10 dark:text-red-200">{error.message}</p>}
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200/70 pt-4 dark:border-white/10">
<p className="flex items-center gap-1.5 text-[11px] text-neutral-500"><ShieldCheck size={13}/> Credentials are encrypted on this computer. Pull never uploads changes.</p>
<div className="flex flex-wrap gap-2"><button type="button" disabled={busy} onClick={() => save.mutate(profile)} className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold hover:border-cyan-300 disabled:opacity-50 dark:border-white/10 dark:bg-white/5"><KeyRound size={13}/> Save</button><button type="button" disabled={busy} onClick={() => void testConnection()} className="inline-flex items-center gap-1.5 rounded-md border border-cyan-300/50 bg-cyan-50 px-3 py-2 text-xs font-semibold text-cyan-800 hover:bg-cyan-100 disabled:opacity-50 dark:bg-cyan-400/10 dark:text-cyan-200"><Server size={13}/> {test.isPending ? 'Testing…' : 'Test connection'}</button><button type="button" disabled={busy || !profile.configured} onClick={pullRemote} className="inline-flex items-center gap-1.5 rounded-md bg-cyan-600 px-3 py-2 text-xs font-semibold text-white shadow-sm hover:bg-cyan-500 disabled:opacity-40"><ArrowDownToLine size={13}/> {pull.isPending ? 'Pulling…' : 'Pull to Local'}</button></div>
</div>
</section>
}
+32
View File
@@ -0,0 +1,32 @@
import { useMutation, useQuery, useQueryClient, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query'
import type { AuroraRemoteSiteProfile, AuroraRemoteSiteStatus } from '@shared/types'
import { useStatusStore } from '../stores/statusStore'
import { useTerminalStore } from '../stores/terminalStore'
const key = (root: string): readonly [string, string] => ['remote-site', root] as const
export function useRemoteSiteProfile(name: string, root: string): UseQueryResult<AuroraRemoteSiteProfile | null, Error> {
return useQuery({ queryKey: key(root), queryFn: () => window.api.remote.getProfile(name) })
}
export function useSaveRemoteSite(name: string, root: string): UseMutationResult<void, Error, AuroraRemoteSiteProfile> {
const client = useQueryClient()
return useMutation({ mutationFn: (profile) => window.api.remote.saveProfile(name, profile), onSuccess: () => client.invalidateQueries({ queryKey: key(root) }) })
}
export function useTestRemoteSite(name: string, root: string): UseMutationResult<AuroraRemoteSiteStatus, Error, AuroraRemoteSiteProfile> {
const client = useQueryClient()
return useMutation({ mutationFn: (profile) => window.api.remote.test(name, profile), onSuccess: () => client.invalidateQueries({ queryKey: key(root) }) })
}
export function usePullRemoteSite(name: string): UseMutationResult<void, Error, void> {
return useMutation({
mutationFn: async () => {
const operationId = crypto.randomUUID()
const label = `Pull ${name} from remote`
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
await window.api.remote.pull(operationId, name)
}
})
}
+23
View File
@@ -83,6 +83,29 @@ export interface AuroraSiteCredentials {
email: string
}
export interface AuroraRemoteSiteProfile {
siteUrl: string
wordpressUsername: string
applicationPassword?: string
sshHost: string
sshPort: number
sshUsername: string
privateKeyPath: string
remotePath: string
configured?: boolean
}
export interface AuroraRemoteSiteStatus {
connected: boolean
platform: 'wordpress'
siteName: string
siteUrl: string
wordpressVersion: string
phpVersion: string
databaseVersion: string
connectorVersion: string
}
export interface AuroraStackOptions {
phpVersion: string
nodeVersion: string