From 8b23e5f59096dc01cf2df7703d1e0c574cea43a6 Mon Sep 17 00:00:00 2001 From: reaper Date: Sat, 15 Aug 2026 07:00:28 -0500 Subject: [PATCH] Add guarded WordPress pull to local workflow --- electron-builder.yml | 1 + src/main/index.ts | 2 + src/main/ipc/remote.ts | 143 ++++++++++++++++++ src/preload/index.ts | 13 ++ .../src/components/projects/ProjectDetail.tsx | 3 + .../components/projects/RemoteSiteSection.tsx | 59 ++++++++ src/renderer/src/hooks/useRemoteSite.ts | 32 ++++ src/shared/types.ts | 23 +++ 8 files changed, 276 insertions(+) create mode 100644 src/main/ipc/remote.ts create mode 100644 src/renderer/src/components/projects/RemoteSiteSection.tsx create mode 100644 src/renderer/src/hooks/useRemoteSite.ts diff --git a/electron-builder.yml b/electron-builder.yml index cf6fa88..075fdff 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -4,6 +4,7 @@ beforePack: scripts/package-modules.cjs directories: buildResources: build files: + - '!website/**' - '!**/.vscode/*' - '!src/*' - '!electron.vite.config.{js,ts,mjs,cjs}' diff --git a/src/main/index.ts b/src/main/index.ts index 05be5f0..ea1e1a0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc/remote.ts b/src/main/ipc/remote.ts new file mode 100644 index 0000000..dc0b3b1 --- /dev/null +++ b/src/main/ipc/remote.ts @@ -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 & { 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 { + try { return JSON.parse(await readFile(profilePath(root), 'utf8')) as StoredProfile } catch { return null } +} + +async function save(root: string, input: AuroraRemoteSiteProfile): Promise { + 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 { + 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 { + 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 { + 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 { + await runCommandStreamed(id, command, args, sender, { cwd, emitExit: false }) +} + +async function pullWordPress(sender: WebContents, operationId: string, name: string, root: string): Promise { + 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))) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index c0cc567..e41ff64 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,8 @@ import type { AuroraProjectSummary, AuroraSnapshot, AuroraSiteCredentials, + AuroraRemoteSiteProfile, + AuroraRemoteSiteStatus, AuroraStackOptions, EnvironmentUpdate, LogDataEvent, @@ -142,6 +144,17 @@ const api = { getSiteCredentials: (approot: string): Promise => ipcRenderer.invoke('secrets:getSiteCredentials', approot) }, + remote: { + getProfile: (name: string): Promise => + ipcRenderer.invoke('remote:getProfile', name), + saveProfile: (name: string, profile: AuroraRemoteSiteProfile): Promise => + ipcRenderer.invoke('remote:saveProfile', name, profile), + pickPrivateKey: (): Promise => ipcRenderer.invoke('remote:pickPrivateKey'), + test: (name: string, profile: AuroraRemoteSiteProfile): Promise => + ipcRenderer.invoke('remote:test', name, profile), + pull: (operationId: string, name: string): Promise => + ipcRenderer.invoke('remote:pull', operationId, name) + }, zoom: { in: (): Promise => ipcRenderer.invoke('window:zoomIn'), out: (): Promise => ipcRenderer.invoke('window:zoomOut'), diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index 47d48db..d861d4c 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -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 { )} + + {isRunning && }
diff --git a/src/renderer/src/components/projects/RemoteSiteSection.tsx b/src/renderer/src/components/projects/RemoteSiteSection.tsx new file mode 100644 index 0000000..cfca803 --- /dev/null +++ b/src/renderer/src/components/projects/RemoteSiteSection.tsx @@ -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(blank) + const [status, setStatus] = useState(null) + + useEffect(() => { if (data) setProfile({ ...data, applicationPassword: '' }) }, [data]) + if (projectType !== 'wordpress') return null + const set = (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 { + const path = await window.api.remote.pickPrivateKey() + if (path) set('privateKeyPath', path) + } + + async function testConnection(): Promise { + 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
+
+

Remote Site

Connect this local project to its production WordPress site.

+ {status && Connected · WordPress {status.wordpressVersion}} +
+
+ + + + +
+ + +
+ {status &&
{status.siteName}
{status.siteUrl}
PHP {status.phpVersion}
Database {status.databaseVersion}
Connector {status.connectorVersion}
SSH verified
} + {error &&

{error.message}

} +
+

Credentials are encrypted on this computer. Pull never uploads changes.

+
+
+
+} diff --git a/src/renderer/src/hooks/useRemoteSite.ts b/src/renderer/src/hooks/useRemoteSite.ts new file mode 100644 index 0000000..55d976b --- /dev/null +++ b/src/renderer/src/hooks/useRemoteSite.ts @@ -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 { + return useQuery({ queryKey: key(root), queryFn: () => window.api.remote.getProfile(name) }) +} + +export function useSaveRemoteSite(name: string, root: string): UseMutationResult { + 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 { + 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 { + 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) + } + }) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 77b78f9..3ea9c07 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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