Add guarded WordPress pull to local workflow
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user