Initial Aurora Dockside Alpha 23
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { mkdir, readFile, writeFile, access, rm, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { AuroraProjectDetail, AuroraProjectSummary, AuroraInstalledModule, AuroraModuleManifest, AuroraStackOptions } from '../shared/types'
|
||||
import { getModuleManifest, getModuleRegistry } from './moduleRegistry'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
|
||||
export const AURORA_ENV = { ...process.env, PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':') }
|
||||
|
||||
type AuroraConfig = {
|
||||
name: string
|
||||
type: string
|
||||
docroot: string
|
||||
php: string
|
||||
node: string
|
||||
webserver: 'nginx'
|
||||
database: 'mariadb' | 'postgres'
|
||||
databaseVersion: string
|
||||
modules: string[]
|
||||
moduleSettings?: Record<string, Record<string, string | number | boolean>>
|
||||
primaryProtocol?: 'http' | 'https'
|
||||
wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain'
|
||||
xdebug?: boolean
|
||||
}
|
||||
|
||||
type Registry = { projects: Record<string, string> }
|
||||
const configDir = (root: string): string => join(root, '.aurora')
|
||||
const configPath = (root: string): string => join(configDir(root), 'config.json')
|
||||
const composePath = (root: string): string => join(configDir(root), 'compose.yaml')
|
||||
const phpDockerfilePath = (root: string): string => join(configDir(root), 'Dockerfile.php')
|
||||
const registryPath = (): string => join(app.getPath('userData'), 'projects.json')
|
||||
const routerDir = (): string => join(app.getPath('userData'), 'router')
|
||||
const routerConfigPath = (): string => join(routerDir(), 'dynamic.yaml')
|
||||
const certDir = (): string => join(app.getPath('userData'), 'certificates')
|
||||
const caDir = (): string => join(certDir(), 'ca')
|
||||
const projectsCertDir = (): string => join(certDir(), 'projects')
|
||||
const caKeyPath = (): string => join(caDir(), 'aurora-root-ca.key')
|
||||
const caCertPath = (): string => join(caDir(), 'aurora-root-ca.crt')
|
||||
const projectCertPath = (name: string): string => join(projectsCertDir(), `${safeName(name)}.crt`)
|
||||
const projectKeyPath = (name: string): string => join(projectsCertDir(), `${safeName(name)}.key`)
|
||||
const safeName = (name: string): string => name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
|
||||
const projectHost = (name: string): string => `${safeName(name)}.aurora.localhost`
|
||||
export const projectUrls = (name: string) => ({ http: `http://${projectHost(name)}`, https: `https://${projectHost(name)}` })
|
||||
|
||||
async function loadRegistry(): Promise<Registry> {
|
||||
try { return JSON.parse(await readFile(registryPath(), 'utf8')) as Registry } catch { return { projects: {} } }
|
||||
}
|
||||
async function saveRegistry(registry: Registry): Promise<void> {
|
||||
await mkdir(app.getPath('userData'), { recursive: true })
|
||||
await writeFile(registryPath(), JSON.stringify(registry, null, 2))
|
||||
}
|
||||
async function readConfig(root: string): Promise<AuroraConfig> {
|
||||
return JSON.parse(await readFile(configPath(root), 'utf8')) as AuroraConfig
|
||||
}
|
||||
async function writeConfig(root: string, config: AuroraConfig): Promise<void> {
|
||||
await mkdir(configDir(root), { recursive: true })
|
||||
await writeFile(configPath(root), JSON.stringify(config, null, 2))
|
||||
await writeFile(composePath(root), await renderCompose(config))
|
||||
}
|
||||
|
||||
function indent(lines: string, spaces = 4): string {
|
||||
const pad = ' '.repeat(spaces)
|
||||
return lines.split('\n').map((line) => line ? pad + line : line).join('\n')
|
||||
}
|
||||
|
||||
async function renderModuleService(module: AuroraModuleManifest, config: AuroraConfig): Promise<string> {
|
||||
if (!module.compose) return ''
|
||||
const { service, image, ports = [], dependsOn = [] } = module.compose
|
||||
const settings = config.moduleSettings?.[module.id] ?? {}
|
||||
const lines = [`${service}:`, ` image: ${image}`]
|
||||
if (module.id === 'redis' && settings.persistence !== false) {
|
||||
lines.push(' volumes:', ' - redis_data:/data')
|
||||
}
|
||||
if (ports.length) {
|
||||
lines.push(' ports:')
|
||||
for (const port of ports) lines.push(` - "127.0.0.1::${port}"`)
|
||||
}
|
||||
if (dependsOn.length) lines.push(' depends_on:', ...dependsOn.map((dep) => ` - ${dep}`))
|
||||
if (module.id === 'mailpit') lines.push(' networks:', ' default:', ' aurora-router:', ' aliases:', ` - aurora-${safeName(config.name)}-mailpit`)
|
||||
return indent(lines.join('\n'), 2)
|
||||
}
|
||||
|
||||
async function renderCompose(c: AuroraConfig): Promise<string> {
|
||||
const db = c.database === 'postgres'
|
||||
? ` db:\n image: postgres:${c.databaseVersion}\n environment:\n POSTGRES_DB: db\n POSTGRES_USER: db\n POSTGRES_PASSWORD: db\n volumes:\n - db_data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U db -d db\"]\n interval: 3s\n timeout: 3s\n retries: 20`
|
||||
: ` db:\n image: mariadb:${c.databaseVersion}\n environment:\n MARIADB_DATABASE: db\n MARIADB_USER: db\n MARIADB_PASSWORD: db\n MARIADB_ROOT_PASSWORD: root\n volumes:\n - db_data:/var/lib/mysql\n healthcheck:\n test: [\"CMD\", \"healthcheck.sh\", \"--connect\", \"--innodb_initialized\"]\n interval: 3s\n timeout: 3s\n retries: 20`
|
||||
const manifests = await Promise.all(c.modules.filter((id) => id !== 'adminer').map((id) => getModuleManifest(id)))
|
||||
const extras = (await Promise.all(manifests.map((m) => renderModuleService(m, c)))).filter(Boolean)
|
||||
const volumes = [' db_data:']
|
||||
if (c.modules.includes('redis') && c.moduleSettings?.redis?.persistence !== false) volumes.push(' redis_data:')
|
||||
const adminer = c.modules.includes('adminer') ? ` adminer:\n image: adminer:5\n environment:\n ADMINER_DEFAULT_SERVER: db\n networks:\n default:\n aurora-router:\n aliases:\n - aurora-${safeName(c.name)}-adminer\n depends_on:\n db:\n condition: service_healthy` : ''
|
||||
return `name: aurora-${safeName(c.name)}\nservices:\n web:\n image: nginx:1.29-alpine\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro\n ports:\n - "127.0.0.1::80"\n networks:\n default:\n aurora-router:\n aliases:\n - aurora-${safeName(c.name)}-web\n depends_on:\n php:\n condition: service_started\n db:\n condition: service_healthy\n php:\n build:\n context: .\n dockerfile: Dockerfile.php\n args:\n PHP_VERSION: ${c.php}\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n node:\n image: node:${c.node}-alpine\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n command: ["sh", "-c", "sleep infinity"]\n${db}${adminer ? `\n${adminer}` : ''}${extras.length ? `\n${extras.join('\n')}` : ''}\nvolumes:\n${volumes.join('\n')}\nnetworks:\n aurora-router:\n external: true\n name: aurora-router\n`
|
||||
}
|
||||
async function writePhpDockerfile(root: string, xdebug = false): Promise<void> {
|
||||
await writeFile(phpDockerfilePath(root), `ARG PHP_VERSION=8.4
|
||||
FROM php:${'${PHP_VERSION}'}-fpm-alpine
|
||||
RUN apk add --no-cache icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev postgresql-dev \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install -j$(nproc) mysqli pdo_mysql pdo_pgsql intl zip gd mbstring opcache
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
${xdebug ? 'RUN apk add --no-cache $PHPIZE_DEPS linux-headers && pecl install xdebug && docker-php-ext-enable xdebug' : ''}
|
||||
`)
|
||||
}
|
||||
|
||||
async function writeNginx(root: string, docroot: string): Promise<void> {
|
||||
const webroot = docroot ? `/var/www/html/${docroot}` : '/var/www/html'
|
||||
await writeFile(join(configDir(root), 'nginx.conf'), `map $http_x_forwarded_proto $aurora_https {
|
||||
default off;
|
||||
https on;
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root ${webroot};
|
||||
index index.php index.html;
|
||||
location / { try_files $uri $uri/ /index.php?$query_string; }
|
||||
location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS $aurora_https; fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto; fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host; fastcgi_pass php:9000; }
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
export async function createProject(root: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>): Promise<void> {
|
||||
await mkdir(root, { recursive: true })
|
||||
const normalizedType = type || 'generic'
|
||||
const defaultDocroot = docroot || (normalizedType === 'laravel' ? 'public' : normalizedType === 'drupal' ? 'web' : '')
|
||||
const modules = normalizedType === 'generic' || normalizedType === 'php' ? [] : [normalizedType]
|
||||
if (stack?.adminer !== false) modules.push('adminer')
|
||||
if (stack?.redis) modules.push('redis')
|
||||
if (stack?.mailpit) modules.push('mailpit')
|
||||
const config: AuroraConfig = { name, type: normalizedType, docroot: defaultDocroot, php: stack?.phpVersion || '8.4', node: stack?.nodeVersion || '24', webserver: 'nginx', database: stack?.database || 'mariadb', databaseVersion: stack?.databaseVersion || '11.8', modules, moduleSettings: {}, primaryProtocol: 'https', xdebug: stack?.xdebug === true }
|
||||
await writeConfig(root, config); await writeNginx(root, defaultDocroot); await writePhpDockerfile(root, config.xdebug)
|
||||
const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg)
|
||||
if (normalizedType === 'generic' || normalizedType === 'php') await writeFile(join(root, 'index.php'), `<?php echo '<h1>${name}</h1><p>Aurora Dockside is running.</p>';`)
|
||||
}
|
||||
export async function unregisterProject(name: string, deleteFiles: boolean): Promise<void> {
|
||||
const reg = await loadRegistry(); const root = reg.projects[name]; delete reg.projects[name]; await saveRegistry(reg)
|
||||
if (deleteFiles && root) await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
async function composeJson(root: string): Promise<any[]> {
|
||||
try { const { stdout } = await execFileAsync('docker', ['compose', '-f', composePath(root), 'ps', '--format', 'json'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 }); return stdout.trim().split('\n').filter(Boolean).map(x => JSON.parse(x)) } catch { return [] }
|
||||
}
|
||||
export async function listProjects(): Promise<AuroraProjectSummary[]> {
|
||||
const reg = await loadRegistry(); const out: AuroraProjectSummary[] = []
|
||||
for (const [name, root] of Object.entries(reg.projects)) {
|
||||
try { await access(configPath(root)); const c = await readConfig(root); const ps = await composeJson(root); const running = ps.some(p => p.State === 'running'); const urls = projectUrls(c.name); const primary = c.primaryProtocol === 'http' ? urls.http : urls.https
|
||||
out.push({ name, status: running?'running':'stopped', status_desc: running?'Running':'Stopped', type:c.type, approot:root, shortroot:root, docroot:c.docroot, primary_url:primary, httpurl:urls.http, httpsurl:urls.https, mutagen_enabled:false })
|
||||
} catch { /* stale registry entry */ }
|
||||
} return out
|
||||
}
|
||||
export async function describeProject(name: string): Promise<AuroraProjectDetail> {
|
||||
const reg = await loadRegistry(); const root = reg.projects[name]; if (!root) throw new Error(`Aurora project '${name}' not found`)
|
||||
const c = await readConfig(root); const ps = await composeJson(root); const running = ps.some(p=>p.State==='running'); const currentRouterStatus = await routerStatus(); const urlSet=projectUrls(c.name); const primary=c.primaryProtocol === 'http' ? urlSet.http : urlSet.https
|
||||
const services: Record<string, any> = {}; for (const p of ps) services[p.Service]={short_name:p.Service,full_name:p.Name,status:p.State,image:p.Image,exposed_ports:'',host_ports:'',host_ports_mapping:[]}
|
||||
return { name,status:running?'running':'stopped',status_desc:running?'Running':'Stopped',type:c.type,approot:root,shortroot:root,docroot:c.docroot,primary_url:primary,httpurl:urlSet.http,httpsurl:urlSet.https,mutagen_enabled:false,database_type:c.database,database_version:c.databaseVersion,dbinfo:{database_type:c.database,database_version:c.databaseVersion,dbPort:c.database==='postgres'?'5432':'3306',dbname:'db',host:'db',password:'db',published_port:0,username:'db'},hostname:projectHost(c.name),hostnames:[projectHost(c.name)],httpURLs:[urlSet.http],httpsURLs:[urlSet.https],urls:[urlSet.http,urlSet.https],php_version:c.php,nodejs_version:c.node,webserver_type:'nginx',router:'file',router_status:currentRouterStatus,certificate_status:await certificateStatus(c.name),ca_trust_status:await caTrustStatus(),firefox_trust_status:await firefoxTrustStatus(),chromium_trust_status:await chromiumTrustStatus(),wordpress_multisite:c.type==='wordpress'?(c.wordpressMultisite??'none'):undefined,wordpress_network_admin_url:c.type==='wordpress'&&c.wordpressMultisite&&c.wordpressMultisite!=='none'?`${primary.replace(/\/$/,'')}/wp-admin/network/`:undefined,adminer_url:c.modules.includes('adminer')?`https://adminer.${projectHost(c.name)}`:undefined,services,xdebug_enabled:c.xdebug===true }
|
||||
}
|
||||
export async function updateEnvironment(root:string, updates:{phpVersion?:string;nodeVersion?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise<void>{
|
||||
const c=await readConfig(root)
|
||||
if(updates.phpVersion)c.php=updates.phpVersion
|
||||
if(updates.nodeVersion)c.node=updates.nodeVersion
|
||||
if(typeof updates.xdebugEnabled === 'boolean') c.xdebug=updates.xdebugEnabled
|
||||
if(updates.database){const [kind,version]=updates.database.split(':'); if(kind==='mariadb'||kind==='postgres'){c.database=kind;c.databaseVersion=version||c.databaseVersion}}
|
||||
if(updates.primaryProtocol === 'http' || updates.primaryProtocol === 'https') c.primaryProtocol = updates.primaryProtocol
|
||||
await writeConfig(root,c); await writeNginx(root,c.docroot); await writePhpDockerfile(root, c.xdebug === true)
|
||||
}
|
||||
|
||||
export async function getProjectConfig(root: string): Promise<AuroraConfig> { return readConfig(root) }
|
||||
|
||||
export async function setWordpressMultisite(root: string, mode: 'none' | 'subdirectory' | 'subdomain'): Promise<void> {
|
||||
const config = await readConfig(root)
|
||||
config.wordpressMultisite = mode
|
||||
await writeConfig(root, config)
|
||||
}
|
||||
|
||||
export async function routerStatus(): Promise<'running' | 'provider-error' | 'stopped'> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}}', 'aurora-router'], { env: AURORA_ENV })
|
||||
if (stdout.trim() !== 'true') return 'stopped'
|
||||
const { stdout: logs = '', stderr: logErrors = '' } = await execFileAsync('docker', ['logs', '--tail', '40', 'aurora-router'], { env: AURORA_ENV, maxBuffer: 2 * 1024 * 1024 })
|
||||
const recentLogs = `${logs}\n${logErrors}`
|
||||
if (recentLogs.includes('Error while building configuration') || recentLogs.includes('field not found, node:')) return 'provider-error'
|
||||
return 'running'
|
||||
} catch { return 'stopped' }
|
||||
}
|
||||
|
||||
export async function routerRunning(): Promise<boolean> {
|
||||
return (await routerStatus()) === 'running'
|
||||
}
|
||||
|
||||
export async function listModules(): Promise<AuroraModuleManifest[]> {
|
||||
return getModuleRegistry()
|
||||
}
|
||||
|
||||
export async function listInstalledModules(name: string): Promise<AuroraInstalledModule[]> {
|
||||
const reg = await loadRegistry()
|
||||
const root = reg.projects[name]
|
||||
if (!root) throw new Error(`Aurora project '${name}' not found`)
|
||||
const config = await readConfig(root)
|
||||
const manifests = await Promise.all(config.modules.map((id) => getModuleManifest(id)))
|
||||
return manifests.map((module) => ({ id: module.id, name: module.name, version: module.version, category: module.category }))
|
||||
}
|
||||
|
||||
export async function setModule(
|
||||
name: string,
|
||||
moduleId: string,
|
||||
install: boolean,
|
||||
settings: Record<string, string | number | boolean> = {}
|
||||
): Promise<void> {
|
||||
const reg = await loadRegistry()
|
||||
const root = reg.projects[name]
|
||||
if (!root) throw new Error(`Aurora project '${name}' not found`)
|
||||
const config = await readConfig(root)
|
||||
const module = await getModuleManifest(moduleId)
|
||||
config.moduleSettings ??= {}
|
||||
|
||||
if (install) {
|
||||
const conflict = module.conflicts.find((id) => config.modules.includes(id))
|
||||
if (conflict) throw new Error(`${module.name} conflicts with the installed '${conflict}' application module.`)
|
||||
for (const dependency of module.dependencies) {
|
||||
if (!config.modules.includes(dependency)) config.modules.push(dependency)
|
||||
}
|
||||
if (!config.modules.includes(moduleId)) config.modules.push(moduleId)
|
||||
config.moduleSettings[moduleId] = Object.fromEntries(module.settings.map((item) => [item.id, item.default]))
|
||||
Object.assign(config.moduleSettings[moduleId], settings)
|
||||
if (module.category === 'application') {
|
||||
config.type = moduleId
|
||||
if (module.defaults?.docroot !== undefined) config.docroot = module.defaults.docroot
|
||||
}
|
||||
} else {
|
||||
const dependents = (await getModuleRegistry()).filter((item) => item.dependencies.includes(moduleId) && config.modules.includes(item.id))
|
||||
if (dependents.length) throw new Error(`Cannot remove ${module.name}; required by ${dependents.map((item) => item.name).join(', ')}.`)
|
||||
config.modules = config.modules.filter((id) => id !== moduleId)
|
||||
delete config.moduleSettings[moduleId]
|
||||
if (config.type === moduleId) config.type = 'generic'
|
||||
}
|
||||
await writeConfig(root, config)
|
||||
await writeNginx(root, config.docroot)
|
||||
await writePhpDockerfile(root, config.xdebug === true)
|
||||
}
|
||||
|
||||
export async function scaffoldApplicationModule(name: string, moduleId: string): Promise<void> {
|
||||
const root = await getProjectRoot(name)
|
||||
const module = await getModuleManifest(moduleId)
|
||||
if (module.category !== 'application') return
|
||||
if (moduleId === 'wordpress') {
|
||||
await execFileAsync('docker', ['run', '--rm', '-v', `${root}:/app`, '-w', '/app', 'wordpress:cli', 'core', 'download', '--skip-content', '--force', '--allow-root'], { env: AURORA_ENV, maxBuffer: 16 * 1024 * 1024 })
|
||||
return
|
||||
}
|
||||
const packageName = moduleId === 'laravel' ? 'laravel/laravel' : moduleId === 'drupal' ? 'drupal/recommended-project' : null
|
||||
if (!packageName) return
|
||||
await execFileAsync('docker', ['run', '--rm', '-v', `${root}:/app`, 'composer:2', 'sh', '-lc', `rm -rf /tmp/aurora-app && composer create-project ${packageName} /tmp/aurora-app --no-interaction && cp -a /tmp/aurora-app/. /app/`], { env: AURORA_ENV, maxBuffer: 32 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
export async function getProjectRoot(name:string):Promise<string>{const r=await loadRegistry();if(!r.projects[name])throw new Error(`Project ${name} not found`);return r.projects[name]}
|
||||
|
||||
export async function getProjectConfigByRoot(root: string): Promise<{ database: 'mariadb' | 'postgres'; databaseVersion: string; name: string }> {
|
||||
const config = await readConfig(root)
|
||||
return { database: config.database, databaseVersion: config.databaseVersion, name: config.name }
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function ensureCertificateAuthority(): Promise<void> {
|
||||
await mkdir(caDir(), { recursive: true })
|
||||
try { await access(caKeyPath()); await access(caCertPath()); return } catch { /* create below */ }
|
||||
await execFileAsync('openssl', ['req','-x509','-newkey','rsa:3072','-sha256','-days','3650','-nodes','-keyout',caKeyPath(),'-out',caCertPath(),'-subj','/CN=Aurora Dockside Local Development CA','-addext','basicConstraints=critical,CA:TRUE','-addext','keyUsage=critical,keyCertSign,cRLSign'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
}
|
||||
|
||||
async function ensureProjectCertificate(name: string): Promise<void> {
|
||||
await ensureCertificateAuthority()
|
||||
await mkdir(projectsCertDir(), { recursive: true })
|
||||
const cert = projectCertPath(name); const key = projectKeyPath(name)
|
||||
try { await access(cert); await access(key); return } catch { /* create below */ }
|
||||
const host = projectHost(name)
|
||||
const csr = join(projectsCertDir(), `${safeName(name)}.csr`)
|
||||
await execFileAsync('openssl', ['req','-new','-newkey','rsa:2048','-nodes','-keyout',key,'-out',csr,'-subj',`/CN=${host}`,'-addext',`subjectAltName=DNS:${host},DNS:*.${host}`], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
await execFileAsync('openssl', ['x509','-req','-in',csr,'-CA',caCertPath(),'-CAkey',caKeyPath(),'-CAcreateserial','-out',cert,'-days','825','-sha256','-copy_extensions','copy'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
await rm(csr, { force: true })
|
||||
}
|
||||
|
||||
export async function certificateStatus(name: string): Promise<'generated' | 'missing'> {
|
||||
try { await access(projectCertPath(name)); await access(projectKeyPath(name)); return 'generated' } catch { return 'missing' }
|
||||
}
|
||||
|
||||
export async function caTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unknown'> {
|
||||
if (process.platform !== 'linux') return 'unknown'
|
||||
try { await access('/usr/local/share/ca-certificates/aurora-dockside-local-ca.crt'); return 'trusted' } catch { return 'not-trusted' }
|
||||
}
|
||||
|
||||
const firefoxRoots = (): string[] => {
|
||||
const home = process.env.HOME || app.getPath('home')
|
||||
return [join(home,'.mozilla','firefox'), join(home,'snap','firefox','common','.mozilla','firefox')]
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try { await access(path); return true } catch { return false }
|
||||
}
|
||||
|
||||
async function firefoxProfiles(): Promise<string[]> {
|
||||
const profiles: string[] = []
|
||||
for (const root of firefoxRoots()) {
|
||||
// Prefer the active/default profile declared by Firefox itself.
|
||||
try {
|
||||
const ini = await readFile(join(root, 'profiles.ini'), 'utf8')
|
||||
const sections = ini.split(/^\s*\[/m).map((section, index) => index === 0 ? section : '[' + section)
|
||||
for (const section of sections) {
|
||||
if (!/^\[Profile\d+\]/m.test(section) || !/^Default=1\s*$/m.test(section)) continue
|
||||
const match = section.match(/^Path=(.+)\s*$/m)
|
||||
if (!match) continue
|
||||
const profile = join(root, match[1].trim())
|
||||
if (await pathExists(join(profile, 'cert9.db'))) profiles.push(profile)
|
||||
}
|
||||
} catch { /* no profiles.ini */ }
|
||||
|
||||
// Fall back to every NSS profile, but never add duplicates.
|
||||
try {
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const dir = join(root, entry.name)
|
||||
if (!profiles.includes(dir) && await pathExists(join(dir,'cert9.db'))) profiles.push(dir)
|
||||
}
|
||||
} catch { /* Firefox root does not exist */ }
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
async function hasCertutil(): Promise<boolean> {
|
||||
try { await execFileAsync('certutil',['-L','-d','sql:/dev/null'],{env:AURORA_ENV,maxBuffer:1024*1024}); return true } catch (e:any) {
|
||||
return e?.code !== 'ENOENT'
|
||||
}
|
||||
}
|
||||
|
||||
async function nssHasTrustedAuroraCA(db: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('certutil',['-L','-d',`sql:${db}`],{env:AURORA_ENV,maxBuffer:1024*1024})
|
||||
const line = stdout.split(/\r?\n/).find((value) => value.includes('Aurora Dockside Local Development CA'))
|
||||
return Boolean(line && /\bC,,\s*$/.test(line.trim()))
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
export async function firefoxTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unavailable' | 'unknown'> {
|
||||
if (process.platform !== 'linux') return 'unknown'
|
||||
if (!(await hasCertutil())) return 'unavailable'
|
||||
const profiles = await firefoxProfiles()
|
||||
if (!profiles.length) return 'unknown'
|
||||
for (const profile of profiles) if (!(await nssHasTrustedAuroraCA(profile))) return 'not-trusted'
|
||||
return 'trusted'
|
||||
}
|
||||
|
||||
const chromiumNssDb = (): string => join(process.env.HOME || app.getPath('home'), '.pki', 'nssdb')
|
||||
|
||||
async function chromiumTrustTargetExists(): Promise<boolean> {
|
||||
const db = chromiumNssDb()
|
||||
if (await pathExists(join(db, 'cert9.db'))) return true
|
||||
for (const command of ['google-chrome','google-chrome-stable','chromium','chromium-browser','brave-browser','microsoft-edge','vivaldi']) {
|
||||
try { await execFileAsync('which',[command],{env:AURORA_ENV,maxBuffer:1024*1024}); return true } catch { /* try next */ }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function chromiumTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unavailable' | 'unknown'> {
|
||||
if (process.platform !== 'linux') return 'unknown'
|
||||
if (!(await chromiumTrustTargetExists())) return 'unknown'
|
||||
if (!(await hasCertutil())) return 'unavailable'
|
||||
return await nssHasTrustedAuroraCA(chromiumNssDb()) ? 'trusted' : 'not-trusted'
|
||||
}
|
||||
|
||||
async function installIntoNssDb(db: string): Promise<void> {
|
||||
await mkdir(db, { recursive: true })
|
||||
if (!(await pathExists(join(db,'cert9.db')))) {
|
||||
await execFileAsync('certutil',['-N','--empty-password','-d',`sql:${db}`],{env:AURORA_ENV,maxBuffer:1024*1024})
|
||||
}
|
||||
try { await execFileAsync('certutil',['-D','-d',`sql:${db}`,'-n','Aurora Dockside Local Development CA'],{env:AURORA_ENV,maxBuffer:1024*1024}) } catch { /* absent is fine */ }
|
||||
await execFileAsync('certutil',['-A','-d',`sql:${db}`,'-n','Aurora Dockside Local Development CA','-t','C,,','-i',caCertPath()],{env:AURORA_ENV,maxBuffer:1024*1024})
|
||||
if (!(await nssHasTrustedAuroraCA(db))) throw new Error(`Aurora CA import verification failed for NSS database: ${db}`)
|
||||
}
|
||||
|
||||
export async function trustAuroraCA(): Promise<void> {
|
||||
await ensureCertificateAuthority()
|
||||
if (process.platform !== 'linux') throw new Error('Automatic CA trust is currently implemented for Linux only.')
|
||||
|
||||
const script = `install -m 0644 "${caCertPath().replace(/"/g, '\\"')}" /usr/local/share/ca-certificates/aurora-dockside-local-ca.crt && update-ca-certificates`
|
||||
await execFileAsync('pkexec', ['sh','-c',script], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
|
||||
if (!(await hasCertutil())) {
|
||||
await execFileAsync('pkexec',['sh','-c','apt-get update && apt-get install -y libnss3-tools'],{env:AURORA_ENV,maxBuffer:32*1024*1024})
|
||||
}
|
||||
|
||||
// These are the exact stores verified on Ubuntu: Snap/native Firefox profiles
|
||||
// and the shared Chromium NSS database at ~/.pki/nssdb.
|
||||
const profiles = await firefoxProfiles()
|
||||
for (const profile of profiles) await installIntoNssDb(profile)
|
||||
|
||||
if (await chromiumTrustTargetExists()) await installIntoNssDb(chromiumNssDb())
|
||||
|
||||
const firefox = await firefoxTrustStatus()
|
||||
const chromium = await chromiumTrustStatus()
|
||||
if (profiles.length && firefox !== 'trusted') throw new Error('Aurora CA could not be verified in the active Firefox NSS trust store.')
|
||||
if (await chromiumTrustTargetExists() && chromium !== 'trusted') throw new Error('Aurora CA could not be verified in ~/.pki/nssdb for Chromium-family browsers.')
|
||||
}
|
||||
|
||||
async function writeRouterConfig(): Promise<void> {
|
||||
const registry = await loadRegistry()
|
||||
const routers: string[] = []
|
||||
const services: string[] = []
|
||||
for (const [name, root] of Object.entries(registry.projects)) {
|
||||
try {
|
||||
const config = await readConfig(root)
|
||||
await ensureProjectCertificate(config.name || name)
|
||||
const safe = safeName(config.name || name)
|
||||
const host = projectHost(config.name || name)
|
||||
const rule = config.type === 'wordpress' && config.wordpressMultisite === 'subdomain'
|
||||
? `Host(\`${host}\`) || HostRegexp(\`^[a-z0-9-]+\\.${host.replace(/\./g, '\\.')}$\`)`
|
||||
: `Host(\`${host}\`)`
|
||||
routers.push(
|
||||
` aurora-${safe}-http:\n rule: '${rule}'\n entryPoints: [web]\n service: aurora-${safe}`,
|
||||
` aurora-${safe}-https:\n rule: '${rule}'\n entryPoints: [websecure]\n service: aurora-${safe}\n tls: {}`
|
||||
)
|
||||
services.push(` aurora-${safe}:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-web:80`)
|
||||
if (config.modules.includes('adminer')) routers.push(
|
||||
` aurora-${safe}-adminer-http:\n rule: 'Host(\`adminer.${host}\`)'\n entryPoints: [web]\n service: aurora-${safe}-adminer`,
|
||||
` aurora-${safe}-adminer-https:\n rule: 'Host(\`adminer.${host}\`)'\n entryPoints: [websecure]\n service: aurora-${safe}-adminer\n tls: {}`
|
||||
)
|
||||
if (config.modules.includes('adminer')) services.push(` aurora-${safe}-adminer:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-adminer:8080`)
|
||||
if (config.modules.includes('mailpit')) {
|
||||
routers.push(
|
||||
` aurora-${safe}-mailpit-http:\n rule: 'Host(\`mail.${host}\`)'\n entryPoints: [web]\n service: aurora-${safe}-mailpit`,
|
||||
` aurora-${safe}-mailpit-https:\n rule: 'Host(\`mail.${host}\`)'\n entryPoints: [websecure]\n service: aurora-${safe}-mailpit\n tls: {}`
|
||||
)
|
||||
services.push(` aurora-${safe}-mailpit:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-mailpit:8025`)
|
||||
}
|
||||
} catch { /* ignore stale registry entries */ }
|
||||
}
|
||||
const tlsCerts: string[] = []
|
||||
for (const [name, root] of Object.entries(registry.projects)) {
|
||||
try { const config = await readConfig(root); const safe = safeName(config.name || name); tlsCerts.push(` - certFile: /etc/traefik/certs/${safe}.crt\n keyFile: /etc/traefik/certs/${safe}.key`) } catch { /* stale */ }
|
||||
}
|
||||
const dynamic = `http:\n routers:\n${routers.length ? routers.join('\n') : ' {}'}\n services:\n${services.length ? services.join('\n') : ' {}'}\n${tlsCerts.length ? `tls:\n certificates:\n${tlsCerts.join('\n')}\n` : ''}`
|
||||
await mkdir(routerDir(), { recursive: true })
|
||||
await writeFile(routerConfigPath(), dynamic)
|
||||
}
|
||||
|
||||
export async function ensureRouter(): Promise<void> {
|
||||
try { await execFileAsync('docker', ['network', 'inspect', 'aurora-router'], { env: AURORA_ENV }) }
|
||||
catch { await execFileAsync('docker', ['network', 'create', 'aurora-router'], { env: AURORA_ENV }) }
|
||||
|
||||
await writeRouterConfig()
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}} {{json .Config.Cmd}}', 'aurora-router'], { env: AURORA_ENV })
|
||||
const fileProvider = stdout.includes('--providers.file.directory=/etc/traefik/dynamic')
|
||||
if (stdout.trimStart().startsWith('true') && fileProvider) return
|
||||
await execFileAsync('docker', ['rm', '-f', 'aurora-router'], { env: AURORA_ENV }).catch(() => undefined)
|
||||
} catch { /* router does not exist yet */ }
|
||||
|
||||
await execFileAsync('docker', [
|
||||
'run', '-d', '--name', 'aurora-router', '--restart', 'unless-stopped',
|
||||
'--network', 'aurora-router',
|
||||
'-p', '127.0.0.1:80:80', '-p', '127.0.0.1:443:443',
|
||||
'-v', `${routerDir()}:/etc/traefik/dynamic:ro`,
|
||||
'-v', `${projectsCertDir()}:/etc/traefik/certs:ro`,
|
||||
'traefik:v3.5',
|
||||
'--providers.file.directory=/etc/traefik/dynamic', '--providers.file.watch=true',
|
||||
'--entrypoints.web.address=:80', '--entrypoints.websecure.address=:443',
|
||||
'--api.dashboard=false', '--log.level=INFO'
|
||||
], { env: AURORA_ENV, maxBuffer: 8 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
export async function powerOffProjects(): Promise<void> {
|
||||
const registry = await loadRegistry()
|
||||
await Promise.allSettled(Object.values(registry.projects).map(async (root) => {
|
||||
try {
|
||||
await access(composePath(root))
|
||||
await execFileAsync('docker', ['compose', '-f', composePath(root), 'down'], { env: AURORA_ENV, cwd: root })
|
||||
} catch { /* stale project or Docker unavailable */ }
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import type { WebContents } from 'electron'
|
||||
import { AURORA_ENV, powerOffProjects } from './auroraEngine'
|
||||
const running = new Map<string, ChildProcess>(); const cancelledIds = new Set<string>()
|
||||
const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g
|
||||
const stripAnsi=(s:string)=>s.replace(ANSI_PATTERN,'')
|
||||
export function runCommandStreamed(operationId:string, command:string, args:string[], sender:WebContents, options:{cwd?:string}={}):Promise<void>{return new Promise((resolve,reject)=>{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const tail:string[]=[];const forward=(stream:'stdout'|'stderr')=>(data:Buffer)=>{const chunk=stripAnsi(data.toString());tail.push(chunk);if(tail.length>20)tail.shift();if(!sender.isDestroyed())sender.send('terminal:data',{operationId,stream,chunk})};child.stdout.on('data',forward('stdout'));child.stderr.on('data',forward('stderr'));child.on('error',e=>{running.delete(operationId);sender.send('terminal:exit',{operationId,exitCode:null,cancelled:false});reject(e)});child.on('close',code=>{running.delete(operationId);const cancelled=cancelledIds.delete(operationId);if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:code,cancelled});if(cancelled)reject(new Error('Command cancelled'));else if(code===0)resolve();else reject(new Error(tail.join('').trim()||`${command} exited with code ${code}`))})})}
|
||||
export function cancelCommand(id:string):boolean{const c=running.get(id);if(!c)return false;cancelledIds.add(id);c.kill();return true}
|
||||
export function startLogStream(operationId:string, command:string,args:string[],sender:WebContents,options:{cwd?:string}={}):void{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const f=(stream:'stdout'|'stderr')=>(d:Buffer)=>{if(!sender.isDestroyed())sender.send('logs:data',{operationId,stream,chunk:stripAnsi(d.toString())})};child.stdout.on('data',f('stdout'));child.stderr.on('data',f('stderr'));const done=()=>{running.delete(operationId);if(!sender.isDestroyed())sender.send('logs:exit',{operationId})};child.on('close',done);child.on('error',done)}
|
||||
export function killAllRunningCommands():void{for(const c of running.values())c.kill();running.clear()}
|
||||
export async function powerOffAllProjects():Promise<void>{await powerOffProjects()}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { app, shell, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { registerProjectsIpc } from './ipc/projects'
|
||||
import { registerTerminalIpc } from './ipc/terminal'
|
||||
import { registerDatabaseIpc } from './ipc/database'
|
||||
import { registerModulesIpc } from './ipc/modules'
|
||||
import { registerLogsIpc } from './ipc/logs'
|
||||
import { registerCreateIpc } from './ipc/create'
|
||||
import { registerWindowIpc } from './ipc/window'
|
||||
import { registerSecretsIpc } from './ipc/secrets'
|
||||
import { registerWordpressIpc } from './ipc/wordpress'
|
||||
import { killAllRunningCommands, powerOffAllProjects } from './commandRunner'
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
title: 'Aurora Dockside',
|
||||
width: 1100,
|
||||
height: 720,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.aurora-dockside.app')
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
registerProjectsIpc()
|
||||
registerTerminalIpc()
|
||||
registerDatabaseIpc()
|
||||
registerModulesIpc()
|
||||
registerLogsIpc()
|
||||
registerCreateIpc()
|
||||
registerWindowIpc()
|
||||
registerSecretsIpc()
|
||||
registerWordpressIpc()
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// Kill any still-running Aurora processes (e.g. an open `Aurora logs -f` stream),
|
||||
// then power off every running project so sites don't keep running in the
|
||||
// background after the app exits. Quit is deferred until poweroff finishes
|
||||
// (or times out), so this intercepts the first before-quit and re-fires
|
||||
// app.quit() itself once cleanup is done — the isQuitting guard stops that
|
||||
// from looping back into this handler.
|
||||
let isQuitting = false
|
||||
app.on('before-quit', (event) => {
|
||||
if (isQuitting) return
|
||||
event.preventDefault()
|
||||
isQuitting = true
|
||||
killAllRunningCommands()
|
||||
void powerOffAllProjects().finally(() => app.quit())
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
@@ -0,0 +1,214 @@
|
||||
import { dialog, ipcMain } from 'electron'
|
||||
import { createProject, getProjectConfigByRoot } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { AURORA_ENV, ensureRouter, projectUrls, setWordpressMultisite } from '../auroraEngine'
|
||||
import { saveSiteCredentials } from './secrets'
|
||||
import type { AuroraStackOptions } from '../../shared/types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const safeName = (name: string): string =>
|
||||
name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
|
||||
|
||||
async function hostUserArgs(): Promise<string[]> {
|
||||
if (process.platform === 'win32') return []
|
||||
try {
|
||||
const [{ stdout: uid }, { stdout: gid }] = await Promise.all([
|
||||
execFileAsync('id', ['-u'], { env: AURORA_ENV }),
|
||||
execFileAsync('id', ['-g'], { env: AURORA_ENV })
|
||||
])
|
||||
return ['--user', `${uid.trim()}:${gid.trim()}`]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function wordpressBaseArgs(directory: string, network = false): Promise<string[]> {
|
||||
const userArgs = await hostUserArgs()
|
||||
const config = await getProjectConfigByRoot(directory)
|
||||
return [
|
||||
'run', '--rm',
|
||||
...userArgs,
|
||||
'-e', 'HOME=/tmp',
|
||||
'-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
|
||||
...(network ? ['--network', `aurora-${safeName(config.name)}_default`] : []),
|
||||
'-v', `${directory}:/app`,
|
||||
'-w', '/app',
|
||||
'--entrypoint', 'php',
|
||||
'wordpress:cli',
|
||||
'-d', 'memory_limit=512M',
|
||||
'/usr/local/bin/wp'
|
||||
]
|
||||
}
|
||||
|
||||
async function currentProjectUrl(directory: string): Promise<string> {
|
||||
const config = await getProjectConfigByRoot(directory)
|
||||
await ensureRouter()
|
||||
// Aurora provisions WordPress with HTTPS as its canonical URL. WP-CLI writes
|
||||
// the URL directly and does not need to make a browser-trusted TLS request.
|
||||
return projectUrls(config.name).https
|
||||
}
|
||||
|
||||
|
||||
export function registerCreateIpc(): void {
|
||||
ipcMain.handle('create:pickDirectory', async () => {
|
||||
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
|
||||
return r.canceled ? null : r.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('create:configure', async (_e, _id: string, directory: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>) =>
|
||||
createProject(directory, name, type, docroot, stack)
|
||||
)
|
||||
|
||||
ipcMain.handle('create:downloadWordpress', async (e, id: string, directory: string, locale: string) => {
|
||||
const args = await wordpressBaseArgs(directory)
|
||||
return runCommandStreamed(
|
||||
id,
|
||||
'docker',
|
||||
[...args, 'core', 'download', `--locale=${locale || 'en_US'}`, '--force'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'create:setupWordpress',
|
||||
async (
|
||||
e,
|
||||
id: string,
|
||||
directory: string,
|
||||
_siteUrl: string,
|
||||
title: string,
|
||||
user: string,
|
||||
password: string,
|
||||
email: string,
|
||||
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||
) => {
|
||||
const base = await wordpressBaseArgs(directory, true)
|
||||
const siteUrl = await currentProjectUrl(directory)
|
||||
|
||||
// wp-config.php is generated against Docker's internal database hostname.
|
||||
await runCommandStreamed(
|
||||
`${id}-config`,
|
||||
'docker',
|
||||
[...base, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Always establish a known-good single-site installation first. Multisite
|
||||
// is a conversion step, not an alternate bootstrap path. This gives us a
|
||||
// populated database to verify before attempting network conversion.
|
||||
await runCommandStreamed(
|
||||
`${id}-install`,
|
||||
'docker',
|
||||
[
|
||||
...base,
|
||||
'core', 'install',
|
||||
`--url=${siteUrl}`,
|
||||
`--title=${title}`,
|
||||
`--admin_user=${user}`,
|
||||
`--admin_password=${password}`,
|
||||
`--admin_email=${email}`,
|
||||
'--skip-email'
|
||||
],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Hard gate #1: never continue from an empty/partial WordPress database.
|
||||
await runCommandStreamed(
|
||||
`${id}-verify-install`,
|
||||
'docker',
|
||||
[...base, 'core', 'is-installed'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
if (multisite !== 'none') {
|
||||
const multisiteArgs = multisite === 'subdomain' ? ['--subdomains'] : []
|
||||
await runCommandStreamed(
|
||||
`${id}-multisite-convert`,
|
||||
'docker',
|
||||
[
|
||||
...base,
|
||||
'core', 'multisite-convert',
|
||||
`--title=${title}`,
|
||||
...multisiteArgs
|
||||
],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Hard gate #2: a multisite project is not ready unless WP-CLI can see
|
||||
// the network installation. Any non-zero exit propagates to Dockside.
|
||||
await runCommandStreamed(
|
||||
`${id}-verify-network`,
|
||||
'docker',
|
||||
[...base, 'core', 'is-installed', '--network'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Verify the network table itself as a second independent sanity check.
|
||||
await runCommandStreamed(
|
||||
`${id}-verify-network-db`,
|
||||
'docker',
|
||||
[...base, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
}
|
||||
|
||||
await setWordpressMultisite(directory, multisite)
|
||||
await ensureRouter()
|
||||
|
||||
// Development-friendly defaults. Failure here should fail creation so the
|
||||
// user never gets a project Aurora claims is fully provisioned when it is not.
|
||||
await runCommandStreamed(
|
||||
`${id}-defaults`,
|
||||
'docker',
|
||||
[
|
||||
...base,
|
||||
'rewrite', 'structure', '/%postname%/', '--hard'
|
||||
],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
await runCommandStreamed(
|
||||
`${id}-debug`,
|
||||
'docker',
|
||||
[...base, 'config', 'set', 'WP_DEBUG', 'true', '--raw'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
await runCommandStreamed(
|
||||
`${id}-env`,
|
||||
'docker',
|
||||
[...base, 'config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Store the original development login locally after every provisioning
|
||||
// gate has succeeded. WordPress stores only a password hash, so Aurora
|
||||
// must retain the original password if it is to display it later.
|
||||
await saveSiteCredentials(directory, {
|
||||
platform: 'wordpress',
|
||||
adminUrl: `${siteUrl.replace(/\/$/, '')}/wp-admin/`,
|
||||
username: user,
|
||||
password,
|
||||
email
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('create:downloadDrupal', async (e, id: string, directory: string) =>
|
||||
runCommandStreamed(id, 'docker', ['run', '--rm', '-v', `${directory}:/app`, '-w', '/app', 'composer:2', 'create-project', 'drupal/recommended-project', '.', '--no-interaction'], e.sender, { cwd: directory })
|
||||
)
|
||||
ipcMain.handle('create:requireDrush', async (e, id: string, directory: string) =>
|
||||
runCommandStreamed(id, 'docker', ['run', '--rm', '-v', `${directory}:/app`, '-w', '/app', 'composer:2', 'require', 'drush/drush', '--no-interaction'], e.sender, { cwd: directory })
|
||||
)
|
||||
ipcMain.handle('create:setupDrupal', async () => {})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { dialog, ipcMain } from 'electron'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import { mkdir, readdir, rm, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { spawn } from 'child_process'
|
||||
import { createGunzip, createGzip } from 'zlib'
|
||||
import { AURORA_ENV, getProjectConfigByRoot } from '../auroraEngine'
|
||||
import type { AuroraSnapshot } from '../../shared/types'
|
||||
|
||||
const snapshotsDir = (root: string): string => join(root, '.aurora', 'snapshots')
|
||||
const composeFile = (root: string): string => join(root, '.aurora', 'compose.yaml')
|
||||
|
||||
function safeSnapshotName(value?: string): string {
|
||||
const fallback = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const safe = (value?.trim() || fallback).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return safe || fallback
|
||||
}
|
||||
|
||||
function dbCommand(type: 'mariadb' | 'postgres', mode: 'dump' | 'restore'): { command: string; args: string[] } {
|
||||
if (type === 'postgres') {
|
||||
return mode === 'dump'
|
||||
? { command: 'pg_dump', args: ['-U', 'db', '-d', 'db', '--clean', '--if-exists'] }
|
||||
: { command: 'psql', args: ['-U', 'db', '-d', 'db', '-v', 'ON_ERROR_STOP=1'] }
|
||||
}
|
||||
return mode === 'dump'
|
||||
? { command: 'mariadb-dump', args: ['-udb', '-pdb', '--single-transaction', '--routines', '--triggers', 'db'] }
|
||||
: { command: 'mariadb', args: ['-udb', '-pdb', 'db'] }
|
||||
}
|
||||
|
||||
function runDatabasePipe(root: string, type: 'mariadb' | 'postgres', mode: 'dump' | 'restore', filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = dbCommand(type, mode)
|
||||
const child = spawn('docker', ['compose', '-f', composeFile(root), 'exec', '-T', 'db', db.command, ...db.args], {
|
||||
cwd: root, env: AURORA_ENV, stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk.toString() })
|
||||
const gz = filePath.endsWith('.gz')
|
||||
let outputFinished = Promise.resolve()
|
||||
if (mode === 'dump') {
|
||||
const output = createWriteStream(filePath)
|
||||
const source = gz ? child.stdout.pipe(createGzip()) : child.stdout
|
||||
source.pipe(output)
|
||||
outputFinished = new Promise<void>((done, fail) => { output.on('finish', done); output.on('error', fail) })
|
||||
} else {
|
||||
const input = createReadStream(filePath)
|
||||
const source = gz ? input.pipe(createGunzip()) : input
|
||||
source.pipe(child.stdin)
|
||||
input.on('error', reject)
|
||||
}
|
||||
child.on('error', reject)
|
||||
child.on('close', async (code) => {
|
||||
if (code !== 0) return reject(new Error(stderr.trim() || `Database command exited with code ${code}`))
|
||||
try { await outputFinished; resolve() } catch (error) { reject(error) }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function listSnapshots(root: string): Promise<AuroraSnapshot[]> {
|
||||
const dir = snapshotsDir(root)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql.gz'))
|
||||
const rows = await Promise.all(files.map(async (name) => ({ Name: name.replace(/\.sql\.gz$/, ''), Created: (await stat(join(dir, name))).mtime.toISOString() })))
|
||||
return rows.sort((a, b) => b.Created.localeCompare(a.Created))
|
||||
}
|
||||
|
||||
export function registerDatabaseIpc():void {
|
||||
ipcMain.handle('database:listSnapshots', (_event, _name: string, root: string) => listSnapshots(root))
|
||||
ipcMain.handle('database:createSnapshot', async (_event, _operationId: string, root: string, name?: string) => {
|
||||
const config = await getProjectConfigByRoot(root); await mkdir(snapshotsDir(root), { recursive: true })
|
||||
await runDatabasePipe(root, config.database, 'dump', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`))
|
||||
})
|
||||
ipcMain.handle('database:restoreSnapshot', async (_event, _operationId: string, root: string, name: string) => {
|
||||
const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`))
|
||||
})
|
||||
ipcMain.handle('database:deleteSnapshot', async (_event, _operationId: string, root: string, name: string) => rm(join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`), { force: true }))
|
||||
ipcMain.handle('database:importFile', async (_event, _operationId: string, root: string, filePath: string) => { const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', filePath) })
|
||||
ipcMain.handle('database:exportFile', async (_event, _operationId: string, root: string, filePath: string) => { const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'dump', filePath) })
|
||||
ipcMain.handle('database:pickImportFile', async () => { const r=await dialog.showOpenDialog({properties:['openFile'],filters:[{name:'SQL dumps',extensions:['sql','gz']}]}); return r.canceled?null:r.filePaths[0] })
|
||||
ipcMain.handle('database:pickExportPath', async (_e,name:string) => { const r=await dialog.showSaveDialog({defaultPath: basename(name).endsWith('.gz') ? name : `${name}.sql.gz`, filters:[{name:'Compressed SQL dump',extensions:['gz']},{name:'SQL dump',extensions:['sql']}]}); return r.canceled?null:r.filePath })
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { getProjectRoot } from '../auroraEngine'
|
||||
import { startLogStream } from '../commandRunner'
|
||||
export function registerLogsIpc():void{ipcMain.handle('logs:start',async(event,id:string,name:string,service:string)=>{const root=await getProjectRoot(name);startLogStream(id,'docker',['compose','-f',`${root}/.aurora/compose.yaml`,'logs','-f','--tail','200',service],event.sender,{cwd:root})})}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
|
||||
export function registerModulesIpc(): void {
|
||||
ipcMain.handle('modules:listRegistry', () => listModules())
|
||||
ipcMain.handle('modules:listInstalled', (_event, name: string) => listInstalledModules(name))
|
||||
ipcMain.handle(
|
||||
'modules:install',
|
||||
async (
|
||||
event,
|
||||
operationId: string,
|
||||
name: string,
|
||||
moduleId: string,
|
||||
settings: Record<string, string | number | boolean>
|
||||
) => {
|
||||
await setModule(name, moduleId, true, settings)
|
||||
await scaffoldApplicationModule(name, moduleId)
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(
|
||||
operationId,
|
||||
'docker',
|
||||
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
|
||||
event.sender,
|
||||
{ cwd: root }
|
||||
)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'modules:remove',
|
||||
async (event, operationId: string, name: string, moduleId: string) => {
|
||||
await setModule(name, moduleId, false)
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(
|
||||
operationId,
|
||||
'docker',
|
||||
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
|
||||
event.sender,
|
||||
{ cwd: root }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, getProjectConfig, projectUrls, AURORA_ENV, trustAuroraCA } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
const execFileAsync = promisify(execFile)
|
||||
const composeArgs=(root:string,...args:string[])=>['compose','-f',`${root}/.aurora/compose.yaml`,...args]
|
||||
const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit'])
|
||||
export function registerProjectsIpc():void{
|
||||
ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name))
|
||||
ipcMain.handle('projects:start',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);await updateEnvironment(root,{});await ensureRouter();return runCommandStreamed(id,'docker',composeArgs(root,'up','-d','--build','--remove-orphans'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:stop',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'down'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:restart',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);await updateEnvironment(root,{});await ensureRouter();return runCommandStreamed(id,'docker',composeArgs(root,'up','-d','--build','--force-recreate','--remove-orphans'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:restartService',async(e,id:string,name:string,service:string)=>{if(!allowedServices.has(service))throw new Error('Invalid service');const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'restart',service),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:phpInfo',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'exec','-T','php','php','-i'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:openTerminal',async(_e,name:string)=>{const root=await getProjectRoot(name);const candidates: Array<[string,string[]]>=process.platform==='linux'?[['x-terminal-emulator',['--working-directory',root]],['gnome-terminal',['--working-directory',root]],['konsole',['--workdir',root]]]:[];for(const [cmd,args] of candidates){try{const child=spawn(cmd,args,{detached:true,stdio:'ignore'});child.unref();return}catch{}}throw new Error('No supported terminal application was found.')})
|
||||
ipcMain.handle('projects:delete',async(e,id:string,name:string,approot:string,deleteFiles:boolean)=>{
|
||||
let root=approot
|
||||
try { root=await getProjectRoot(name) } catch { /* use renderer-provided root for stale entries */ }
|
||||
try {
|
||||
await runCommandStreamed(id,'docker',composeArgs(root,'down','-v','--remove-orphans'),e.sender,{cwd:root})
|
||||
} catch (error) {
|
||||
// A malformed/missing compose file must never make a project undeletable.
|
||||
console.warn(`Aurora cleanup for '${name}' skipped:`, error)
|
||||
}
|
||||
await unregisterProject(name,deleteFiles)
|
||||
})
|
||||
ipcMain.handle('projects:trustCA',async()=>{ await trustAuroraCA(); await ensureRouter() })
|
||||
ipcMain.handle('projects:updateEnvironment',async(_e,_id:string,_name:string,root:string,updates:any)=>{
|
||||
await updateEnvironment(root,updates)
|
||||
if (updates.primaryProtocol) {
|
||||
const config = await getProjectConfig(root)
|
||||
if (config.type === 'wordpress') {
|
||||
const url = projectUrls(config.name)[updates.primaryProtocol === 'http' ? 'http' : 'https']
|
||||
const network = `aurora-${config.name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'}_default`
|
||||
// Keep WordPress' canonical home/siteurl aligned with Dockside's primary protocol.
|
||||
// WP-CLI talks to MariaDB over the project network; it does not need to trust local TLS.
|
||||
await execFileAsync('docker', ['run','--rm','--network',network,'-e','HOME=/tmp','-v',`${root}:/app`,'-w','/app','wordpress:cli','option','update','home',url,'--allow-root'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
await execFileAsync('docker', ['run','--rm','--network',network,'-e','HOME=/tmp','-v',`${root}:/app`,'-w','/app','wordpress:cli','option','update','siteurl',url,'--allow-root'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { chmod, readFile, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { AuroraSiteCredentials } from '../../shared/types'
|
||||
|
||||
const secretsPath = (root: string): string => join(root, '.aurora', 'secrets.json')
|
||||
|
||||
export async function saveSiteCredentials(root: string, credentials: AuroraSiteCredentials): Promise<void> {
|
||||
const path = secretsPath(root)
|
||||
await writeFile(path, JSON.stringify({ site: credentials }, null, 2) + '\n', { mode: 0o600 })
|
||||
if (process.platform !== 'win32') await chmod(path, 0o600)
|
||||
}
|
||||
|
||||
async function readSiteCredentials(root: string): Promise<AuroraSiteCredentials | null> {
|
||||
try {
|
||||
const data = JSON.parse(await readFile(secretsPath(root), 'utf8')) as { site?: AuroraSiteCredentials }
|
||||
return data.site ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSecretsIpc(): void {
|
||||
ipcMain.handle('secrets:getSiteCredentials', async (_event, root: string) => readSiteCredentials(root))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { cancelCommand } from '../commandRunner'
|
||||
|
||||
export function registerTerminalIpc(): void {
|
||||
ipcMain.handle('terminal:cancel', (_event, operationId: string) => cancelCommand(operationId))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
const MIN_ZOOM = -3
|
||||
const MAX_ZOOM = 5
|
||||
|
||||
export function registerWindowIpc(): void {
|
||||
ipcMain.handle('window:zoomIn', (event) => {
|
||||
const level = Math.min(MAX_ZOOM, event.sender.getZoomLevel() + 0.5)
|
||||
event.sender.setZoomLevel(level)
|
||||
return level
|
||||
})
|
||||
|
||||
ipcMain.handle('window:zoomOut', (event) => {
|
||||
const level = Math.max(MIN_ZOOM, event.sender.getZoomLevel() - 0.5)
|
||||
event.sender.setZoomLevel(level)
|
||||
return level
|
||||
})
|
||||
|
||||
ipcMain.handle('window:zoomReset', (event) => {
|
||||
event.sender.setZoomLevel(0)
|
||||
return 0
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { getProjectConfig, getProjectRoot } from '../auroraEngine'
|
||||
|
||||
const safeName = (name: string): string =>
|
||||
name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
|
||||
|
||||
function wpDockerArgs(name: string, root: string, wpArgs: string[]): string[] {
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
||||
const gid = typeof process.getgid === 'function' ? process.getgid() : undefined
|
||||
const userArgs = uid !== undefined && gid !== undefined ? ['--user', `${uid}:${gid}`] : []
|
||||
|
||||
return [
|
||||
'run', '--rm',
|
||||
...userArgs,
|
||||
'--network', `aurora-${safeName(name)}_default`,
|
||||
'-e', 'HOME=/tmp',
|
||||
'-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
|
||||
'-v', `${root}:/app`,
|
||||
'-w', '/app',
|
||||
'wordpress:cli',
|
||||
...wpArgs
|
||||
]
|
||||
}
|
||||
|
||||
export function registerWordpressIpc(): void {
|
||||
ipcMain.handle('wordpress:run', async (event, operationId: string, name: string, action: string, payload?: string) => {
|
||||
const root = await getProjectRoot(name)
|
||||
const config = await getProjectConfig(root)
|
||||
if (config.type !== 'wordpress') throw new Error(`Project '${name}' is not a WordPress project`)
|
||||
|
||||
let args: string[]
|
||||
switch (action) {
|
||||
case 'cache-flush': args = ['cache', 'flush']; break
|
||||
case 'rewrite-flush': args = ['rewrite', 'flush']; break
|
||||
case 'maintenance-on': args = ['maintenance-mode', 'activate']; break
|
||||
case 'maintenance-off': args = ['maintenance-mode', 'deactivate']; break
|
||||
case 'debug-on': args = ['config', 'set', 'WP_DEBUG', 'true', '--raw']; break
|
||||
case 'debug-off': args = ['config', 'set', 'WP_DEBUG', 'false', '--raw']; break
|
||||
case 'core-version': args = ['core', 'version']; break
|
||||
case 'plugin-list': args = ['plugin', 'list']; break
|
||||
case 'theme-list': args = ['theme', 'list']; break
|
||||
case 'search-replace': {
|
||||
const [from, to] = (payload ?? '').split('\n')
|
||||
if (!from || !to) throw new Error('Search and replacement values are required')
|
||||
args = ['search-replace', from, to, '--all-tables-with-prefix', '--precise', '--report-changed-only']
|
||||
if (config.wordpressMultisite && config.wordpressMultisite !== 'none') args.push('--network')
|
||||
break
|
||||
}
|
||||
default: throw new Error(`Unknown WordPress action '${action}'`)
|
||||
}
|
||||
|
||||
return runCommandStreamed(operationId, 'docker', wpDockerArgs(name, root, args), event.sender, { cwd: root })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { app } from 'electron'
|
||||
import { readFile, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { AuroraModuleManifest } from '../shared/types'
|
||||
|
||||
let cache: AuroraModuleManifest[] | null = null
|
||||
|
||||
function moduleDirectory(): string {
|
||||
return join(app.getAppPath(), 'resources', 'modules')
|
||||
}
|
||||
|
||||
export async function getModuleRegistry(): Promise<AuroraModuleManifest[]> {
|
||||
if (cache) return cache
|
||||
const dir = moduleDirectory()
|
||||
const files = (await readdir(dir)).filter((file) => file.endsWith('.json')).sort()
|
||||
cache = await Promise.all(files.map(async (file) => JSON.parse(await readFile(join(dir, file), 'utf8')) as AuroraModuleManifest))
|
||||
return cache
|
||||
}
|
||||
|
||||
export async function getModuleManifest(id: string): Promise<AuroraModuleManifest> {
|
||||
const module = (await getModuleRegistry()).find((item) => item.id === id)
|
||||
if (!module) throw new Error(`Unknown Aurora module '${id}'`)
|
||||
return module
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { Api } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: Api
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type {
|
||||
AuroraInstalledModule,
|
||||
AuroraModuleManifest,
|
||||
AuroraProjectDetail,
|
||||
AuroraProjectSummary,
|
||||
AuroraSnapshot,
|
||||
AuroraSiteCredentials,
|
||||
AuroraStackOptions,
|
||||
EnvironmentUpdate,
|
||||
LogDataEvent,
|
||||
LogExitEvent,
|
||||
TerminalDataEvent,
|
||||
TerminalExitEvent
|
||||
} from '../shared/types'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
projects: {
|
||||
list: (): Promise<AuroraProjectSummary[]> => ipcRenderer.invoke('projects:list'),
|
||||
describe: (name: string): Promise<AuroraProjectDetail> =>
|
||||
ipcRenderer.invoke('projects:describe', name),
|
||||
start: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:start', operationId, name),
|
||||
stop: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:stop', operationId, name),
|
||||
restart: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:restart', operationId, name),
|
||||
delete: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
approot: string,
|
||||
deleteFiles: boolean
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:delete', operationId, name, approot, deleteFiles),
|
||||
updateEnvironment: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
approot: string,
|
||||
updates: EnvironmentUpdate
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:updateEnvironment', operationId, name, approot, updates),
|
||||
trustCA: (): Promise<void> => ipcRenderer.invoke('projects:trustCA'),
|
||||
restartService: (operationId: string, name: string, service: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:restartService', operationId, name, service),
|
||||
phpInfo: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:phpInfo', operationId, name),
|
||||
openTerminal: (name: string): Promise<void> => ipcRenderer.invoke('projects:openTerminal', name)
|
||||
},
|
||||
wordpress: {
|
||||
run: (operationId: string, name: string, action: string, payload?: string): Promise<void> =>
|
||||
ipcRenderer.invoke('wordpress:run', operationId, name, action, payload)
|
||||
},
|
||||
terminal: {
|
||||
cancel: (operationId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('terminal:cancel', operationId),
|
||||
onData: (callback: (event: TerminalDataEvent) => void): (() => void) => {
|
||||
const listener = (_event: IpcRendererEvent, data: TerminalDataEvent): void => callback(data)
|
||||
ipcRenderer.on('terminal:data', listener)
|
||||
return () => ipcRenderer.removeListener('terminal:data', listener)
|
||||
},
|
||||
onExit: (callback: (event: TerminalExitEvent) => void): (() => void) => {
|
||||
const listener = (_event: IpcRendererEvent, data: TerminalExitEvent): void => callback(data)
|
||||
ipcRenderer.on('terminal:exit', listener)
|
||||
return () => ipcRenderer.removeListener('terminal:exit', listener)
|
||||
}
|
||||
},
|
||||
database: {
|
||||
listSnapshots: (name: string, approot: string): Promise<AuroraSnapshot[]> =>
|
||||
ipcRenderer.invoke('database:listSnapshots', name, approot),
|
||||
createSnapshot: (operationId: string, approot: string, snapshotName?: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:createSnapshot', operationId, approot, snapshotName),
|
||||
restoreSnapshot: (operationId: string, approot: string, snapshotName: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:restoreSnapshot', operationId, approot, snapshotName),
|
||||
deleteSnapshot: (operationId: string, approot: string, snapshotName: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:deleteSnapshot', operationId, approot, snapshotName),
|
||||
importFile: (operationId: string, approot: string, filePath: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:importFile', operationId, approot, filePath),
|
||||
exportFile: (operationId: string, approot: string, filePath: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:exportFile', operationId, approot, filePath),
|
||||
pickImportFile: (): Promise<string | null> => ipcRenderer.invoke('database:pickImportFile'),
|
||||
pickExportPath: (defaultFileName: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke('database:pickExportPath', defaultFileName)
|
||||
},
|
||||
modules: {
|
||||
listRegistry: (): Promise<AuroraModuleManifest[]> => ipcRenderer.invoke('modules:listRegistry'),
|
||||
listInstalled: (name: string): Promise<AuroraInstalledModule[]> =>
|
||||
ipcRenderer.invoke('modules:listInstalled', name),
|
||||
install: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
moduleId: string,
|
||||
settings: Record<string, string | number | boolean>
|
||||
): Promise<void> => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings),
|
||||
remove: (operationId: string, name: string, moduleId: string): Promise<void> =>
|
||||
ipcRenderer.invoke('modules:remove', operationId, name, moduleId)
|
||||
},
|
||||
logs: {
|
||||
start: (operationId: string, name: string, service: string): Promise<void> =>
|
||||
ipcRenderer.invoke('logs:start', operationId, name, service),
|
||||
onData: (callback: (event: LogDataEvent) => void): (() => void) => {
|
||||
const listener = (_event: IpcRendererEvent, data: LogDataEvent): void => callback(data)
|
||||
ipcRenderer.on('logs:data', listener)
|
||||
return () => ipcRenderer.removeListener('logs:data', listener)
|
||||
},
|
||||
onExit: (callback: (event: LogExitEvent) => void): (() => void) => {
|
||||
const listener = (_event: IpcRendererEvent, data: LogExitEvent): void => callback(data)
|
||||
ipcRenderer.on('logs:exit', listener)
|
||||
return () => ipcRenderer.removeListener('logs:exit', listener)
|
||||
}
|
||||
},
|
||||
create: {
|
||||
pickDirectory: (): Promise<string | null> => ipcRenderer.invoke('create:pickDirectory'),
|
||||
configure: (
|
||||
operationId: string,
|
||||
directory: string,
|
||||
projectName: string,
|
||||
projectType: string,
|
||||
docroot: string,
|
||||
stack?: Partial<AuroraStackOptions>
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke(
|
||||
'create:configure',
|
||||
operationId,
|
||||
directory,
|
||||
projectName,
|
||||
projectType,
|
||||
docroot,
|
||||
stack
|
||||
),
|
||||
downloadWordpress: (operationId: string, directory: string, locale: string): Promise<void> =>
|
||||
ipcRenderer.invoke('create:downloadWordpress', operationId, directory, locale),
|
||||
setupWordpress: (
|
||||
operationId: string,
|
||||
directory: string,
|
||||
siteUrl: string,
|
||||
title: string,
|
||||
adminUser: string,
|
||||
adminPassword: string,
|
||||
adminEmail: string,
|
||||
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke(
|
||||
'create:setupWordpress',
|
||||
operationId,
|
||||
directory,
|
||||
siteUrl,
|
||||
title,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
multisite
|
||||
),
|
||||
downloadDrupal: (operationId: string, directory: string): Promise<void> =>
|
||||
ipcRenderer.invoke('create:downloadDrupal', operationId, directory),
|
||||
requireDrush: (operationId: string, directory: string): Promise<void> =>
|
||||
ipcRenderer.invoke('create:requireDrush', operationId, directory),
|
||||
setupDrupal: (
|
||||
operationId: string,
|
||||
directory: string,
|
||||
siteName: string,
|
||||
adminUser: string,
|
||||
adminPassword: string,
|
||||
adminEmail: string,
|
||||
profile: string
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke(
|
||||
'create:setupDrupal',
|
||||
operationId,
|
||||
directory,
|
||||
siteName,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
profile
|
||||
)
|
||||
},
|
||||
secrets: {
|
||||
getSiteCredentials: (approot: string): Promise<AuroraSiteCredentials | null> =>
|
||||
ipcRenderer.invoke('secrets:getSiteCredentials', approot)
|
||||
},
|
||||
zoom: {
|
||||
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
|
||||
out: (): Promise<number> => ipcRenderer.invoke('window:zoomOut'),
|
||||
reset: (): Promise<number> => ipcRenderer.invoke('window:zoomReset')
|
||||
}
|
||||
}
|
||||
|
||||
export type Api = typeof api
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Aurora Dockside</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
|
||||
vi.stubGlobal('api', {
|
||||
projects: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
describe: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
restart: vi.fn()
|
||||
},
|
||||
terminal: {
|
||||
cancel: vi.fn(),
|
||||
onData: vi.fn().mockReturnValue(() => {}),
|
||||
onExit: vi.fn().mockReturnValue(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
function renderApp(): ReturnType<typeof render> {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the app title', () => {
|
||||
renderApp()
|
||||
expect(screen.getByText('Aurora Dockside')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty state when there are no Aurora projects', async () => {
|
||||
renderApp()
|
||||
await waitFor(() => expect(screen.getByText(/No Aurora projects found/)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Anchor, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react'
|
||||
import { ProjectDetail } from './components/projects/ProjectDetail'
|
||||
import { ProjectList } from './components/projects/ProjectList'
|
||||
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
||||
import { StatusBar } from './components/layout/StatusBar'
|
||||
import { Toaster } from './components/ui/Toaster'
|
||||
import { CreateProjectModal } from './components/create/CreateProjectModal'
|
||||
import { SettingsModal } from './components/settings/SettingsModal'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
||||
import { useAppliedTheme } from './hooks/useAppliedTheme'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import docksideIcon from './assets/dockside-icon.png'
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
useTerminalEvents()
|
||||
useAppliedTheme()
|
||||
useKeyboardShortcuts({
|
||||
onNewProject: useCallback(() => setIsCreateOpen(true), []),
|
||||
onOpenSettings: useCallback(() => setIsSettingsOpen(true), [])
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col bg-[linear-gradient(135deg,rgba(8,145,178,0.12)_0%,transparent_36%),linear-gradient(180deg,#f8fafc_0%,#eef6f5_52%,#e7eef5_100%)] text-neutral-900 dark:bg-[linear-gradient(135deg,rgba(45,212,191,0.10)_0%,transparent_36%),linear-gradient(180deg,#070a0f_0%,#0f172a_54%,#092f34_100%)] dark:text-neutral-100">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<aside className="flex w-80 flex-shrink-0 flex-col border-r border-white/70 bg-white/[0.78] shadow-[8px_0_30px_rgba(15,23,42,0.06)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.72] dark:shadow-black/25">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200/70 px-4 py-3 dark:border-white/10">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
src={docksideIcon}
|
||||
alt=""
|
||||
className="size-10 rounded-xl shadow-sm shadow-cyan-900/20"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold tracking-wide">Aurora Dockside</h1>
|
||||
<p className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Aurora modular dev platform
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
title="New Project"
|
||||
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-cyan-50 hover:text-cyan-700 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-300"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
title="Settings"
|
||||
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ProjectList />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{selectedProjectName ? (
|
||||
<ProjectDetail name={selectedProjectName} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="relative grid w-full max-w-3xl overflow-hidden rounded-2xl border border-white/70 bg-white/[0.84] shadow-[0_24px_80px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.70] dark:shadow-black/30">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(8,145,178,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(8,145,178,0.08)_1px,transparent_1px)] bg-[size:34px_34px] dark:bg-[linear-gradient(rgba(45,212,191,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.08)_1px,transparent_1px)]" />
|
||||
<div className="relative grid gap-7 p-8">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-cyan-200 bg-cyan-50 px-3 py-1 text-xs font-medium text-cyan-800 dark:border-cyan-400/20 dark:bg-cyan-400/10 dark:text-cyan-200">
|
||||
<Sparkles size={13} />
|
||||
Local environments, neatly handled
|
||||
</div>
|
||||
<h2 className="max-w-xl text-3xl font-semibold leading-tight text-neutral-950 dark:text-white">
|
||||
Build local development stacks your way.
|
||||
</h2>
|
||||
<p className="mt-3 max-w-xl text-sm leading-6 text-neutral-600 dark:text-neutral-300">
|
||||
Create Docker-powered PHP and Node.js projects, then add applications, services, and tools as modules.
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src={docksideIcon}
|
||||
alt=""
|
||||
className="hidden size-24 flex-shrink-0 rounded-3xl shadow-lg shadow-cyan-900/20 sm:block"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||
<FolderOpen size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||
<p className="text-sm font-semibold">Project overview</p>
|
||||
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
Status, stack details, paths, and services.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||
<TerminalSquare size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||
<p className="text-sm font-semibold">Live operations</p>
|
||||
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
Start, stop, restart, and inspect logs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||
<Anchor size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||
<p className="text-sm font-semibold">Database control</p>
|
||||
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
Snapshots, imports, exports, and add-ons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<TerminalPanel />
|
||||
<StatusBar />
|
||||
<Toaster />
|
||||
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
|
||||
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
@@ -0,0 +1,61 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
background: #f8fafc;
|
||||
font-feature-settings:
|
||||
'liga' 1,
|
||||
'calt' 1;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Menlo,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monospace;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid #06b6d4;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.35);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(100, 116, 139, 0.55);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Boxes,
|
||||
Check,
|
||||
FileCode2,
|
||||
FolderOpen,
|
||||
Globe2,
|
||||
Layers3,
|
||||
Package,
|
||||
Sparkles,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCreateProject } from '../../hooks/useCreateProject'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
import { getTypeLabel, PROJECT_TYPES } from './types/registry'
|
||||
import { DrupalSetup } from './types/DrupalSetup'
|
||||
import { GenericSetup } from './types/GenericSetup'
|
||||
import { WordpressSetup } from './types/WordpressSetup'
|
||||
import type { TypeSetupHandle } from './types/shared'
|
||||
import { isValidProjectName, slugifyProjectName } from './projectName'
|
||||
import docksideIcon from '../../assets/dockside-icon.png'
|
||||
|
||||
type Step = 'site' | 'setup'
|
||||
|
||||
const fieldClass =
|
||||
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||
|
||||
const labelClass =
|
||||
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||
|
||||
const TYPE_ICONS: Record<string, typeof Globe2> = {
|
||||
'': Sparkles,
|
||||
php: FileCode2,
|
||||
wordpress: Globe2,
|
||||
drupal: Layers3,
|
||||
laravel: FileCode2,
|
||||
backdrop: Layers3,
|
||||
craftcms: Package,
|
||||
magento2: Package,
|
||||
shopware6: Package,
|
||||
symfony: Boxes,
|
||||
typo3: Layers3
|
||||
}
|
||||
|
||||
export function CreateProjectModal({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||
const [step, setStep] = useState<Step>('site')
|
||||
const [directory, setDirectory] = useState<string | null>(null)
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [projectType, setProjectType] = useState('')
|
||||
const [docroot, setDocroot] = useState('')
|
||||
const [setupValid, setSetupValid] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [phpVersion, setPhpVersion] = useState('8.4')
|
||||
const [nodeVersion, setNodeVersion] = useState('24')
|
||||
const [database, setDatabase] = useState<'mariadb' | 'postgres'>('mariadb')
|
||||
const [databaseVersion, setDatabaseVersion] = useState('11.8')
|
||||
const [adminer, setAdminer] = useState(true)
|
||||
const [redis, setRedis] = useState(false)
|
||||
const [mailpit, setMailpit] = useState(false)
|
||||
const [xdebug, setXdebug] = useState(false)
|
||||
|
||||
const createProject = useCreateProject()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
const setupRef = useRef<TypeSetupHandle>(null)
|
||||
|
||||
const trimmedName = projectName.trim()
|
||||
const nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName)
|
||||
const canContinue = directory !== null && nameValid
|
||||
const canSubmit = canContinue && setupValid && !isSubmitting
|
||||
|
||||
async function handlePickDirectory(): Promise<void> {
|
||||
const picked = await window.api.create.pickDirectory()
|
||||
if (!picked) return
|
||||
setDirectory(picked)
|
||||
if (!projectName) {
|
||||
const name = picked.split('/').filter(Boolean).pop() ?? ''
|
||||
setProjectName(slugifyProjectName(name))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!directory || !canSubmit) return
|
||||
const name = projectName.trim()
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot, stack: { phpVersion, nodeVersion, database, databaseVersion, adminer, redis, mailpit, xdebug } })
|
||||
} catch {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Post-create (starting the project, downloading/installing WordPress,
|
||||
// etc.) can run long. Close the modal as soon as the quick `Aurora
|
||||
// config` step succeeds instead of blocking the whole wizard on it —
|
||||
// the terminal panel already tracks and surfaces this operation's
|
||||
// progress and any failure independently of the modal.
|
||||
const runPostCreate = setupRef.current?.runPostCreate
|
||||
selectProject(name)
|
||||
onClose()
|
||||
runPostCreate?.({ directory, projectName: name }).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-3 backdrop-blur-sm sm:p-4">
|
||||
<div
|
||||
className="grid w-full max-w-4xl overflow-hidden rounded-2xl border border-white/70 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950 md:grid-cols-[0.82fr_1.18fr]"
|
||||
style={{ height: 'min(760px, calc(100vh - 24px))', maxHeight: 'calc(100vh - 24px)' }}
|
||||
>
|
||||
<aside className="relative hidden overflow-hidden bg-neutral-950 p-6 text-white md:block">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.11)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.11)_1px,transparent_1px)] bg-[size:34px_34px]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-cyan-500/20 to-transparent" />
|
||||
<div className="relative flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<img
|
||||
src={docksideIcon}
|
||||
alt=""
|
||||
className="mb-5 size-16 rounded-2xl shadow-lg shadow-cyan-950/30"
|
||||
/>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-200">
|
||||
Project launch
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold leading-tight">
|
||||
Create a local site that feels ready to work.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Destination
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold">
|
||||
{directory ? directory.split('/').filter(Boolean).pop() : 'Choose a folder'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Project
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold">
|
||||
{projectName.trim() || 'Name pending'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-h-0 min-w-0 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-neutral-200/80 bg-white/95 px-5 py-4 backdrop-blur dark:border-white/10 dark:bg-neutral-950/95">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">
|
||||
{step === 'site' ? 'New Project' : `Set up ${getTypeLabel(projectType)}`}
|
||||
</h2>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{(['site', 'setup'] as const).map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||
step === item
|
||||
? 'border-cyan-200 bg-cyan-50 text-cyan-800 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-cyan-200'
|
||||
: 'border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-neutral-400'
|
||||
)}
|
||||
>
|
||||
<span className="grid size-4 place-items-center rounded-full bg-current text-[10px]">
|
||||
<span className="text-white dark:text-neutral-950">{index + 1}</span>
|
||||
</span>
|
||||
{item === 'site' ? 'Project' : 'Setup'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-white/10 dark:hover:text-neutral-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
{step === 'site' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Project folder</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickDirectory}
|
||||
className={clsx(
|
||||
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
|
||||
directory
|
||||
? 'border-cyan-200 bg-cyan-50/60 text-neutral-900 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-neutral-100'
|
||||
: 'border-neutral-300 bg-neutral-50/70 text-neutral-500 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
|
||||
)}
|
||||
>
|
||||
<span className="grid size-9 flex-shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-sm dark:bg-neutral-950/70 dark:text-cyan-300">
|
||||
<FolderOpen size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{directory ?? 'Choose a folder…'}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
This becomes the project root.
|
||||
</span>
|
||||
</span>
|
||||
{directory && <Check size={16} className="text-cyan-700 dark:text-cyan-300" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Project name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="my-project"
|
||||
className={fieldClass}
|
||||
/>
|
||||
{trimmedName.length > 0 && !nameValid && (
|
||||
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
Use only letters, numbers, and hyphens — no spaces (e.g. "
|
||||
{slugifyProjectName(trimmedName) || 'my-project'}").
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Project type</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{PROJECT_TYPES.map((t) => {
|
||||
const Icon = TYPE_ICONS[t.value] ?? Boxes
|
||||
const isSelected = projectType === t.value
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProjectType(t.value)
|
||||
// drupal/recommended-project's installer-paths
|
||||
// expect a `web` docroot — default it in so a
|
||||
// fresh Drupal project doesn't end up serving
|
||||
// from an unexpected root (only if the user
|
||||
// hasn't already typed a docroot themselves).
|
||||
if (t.value === 'drupal' && !docroot.trim()) setDocroot('web')
|
||||
if (t.value === 'wordpress' && database === 'postgres') { setDatabase('mariadb'); setDatabaseVersion('11.8') }
|
||||
}}
|
||||
className={clsx(
|
||||
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
|
||||
isSelected
|
||||
? 'border-cyan-300 bg-cyan-50 text-cyan-950 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
|
||||
: 'border-neutral-200 bg-white/70 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'grid size-9 flex-shrink-0 place-items-center rounded-lg',
|
||||
isSelected
|
||||
? 'bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950'
|
||||
: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-950/70 dark:text-neutral-400'
|
||||
)}
|
||||
>
|
||||
<Icon size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{t.label}</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t.value ? 'Use Aurora type preset' : 'Let Aurora inspect it'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-semibold">Development stack</p>
|
||||
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">Aurora core owns the runtime; the application is a module layered on top.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={labelClass}>PHP</label><select className={fieldClass} value={phpVersion} onChange={(e)=>setPhpVersion(e.target.value)}>{['8.2','8.3','8.4','8.5'].map(v=><option key={v}>{v}</option>)}</select></div>
|
||||
<div><label className={labelClass}>Node.js</label><select className={fieldClass} value={nodeVersion} onChange={(e)=>setNodeVersion(e.target.value)}>{['20','22','24'].map(v=><option key={v}>{v}</option>)}</select></div>
|
||||
<div><label className={labelClass}>Database</label><select className={fieldClass} value={`${database}:${databaseVersion}`} onChange={(e)=>{const [kind,version]=e.target.value.split(':');setDatabase(kind as 'mariadb'|'postgres');setDatabaseVersion(version)}}><option value="mariadb:11.8">MariaDB 11.8</option><option value="mariadb:10.11">MariaDB 10.11</option>{projectType !== 'wordpress' && <><option value="postgres:17">PostgreSQL 17</option><option value="postgres:16">PostgreSQL 16</option></>}</select></div>
|
||||
<div className="grid grid-cols-2 gap-2 pt-5">
|
||||
{[['Adminer',adminer,setAdminer],['Redis',redis,setRedis],['Mailpit',mailpit,setMailpit],['Xdebug',xdebug,setXdebug]].map(([label,value,setter])=><label key={label as string} className="flex items-center gap-2 text-xs font-medium"><input type="checkbox" checked={value as boolean} onChange={(e)=>(setter as (v:boolean)=>void)(e.target.checked)} />{label as string}</label>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Docroot (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docroot}
|
||||
onChange={(e) => setDocroot(e.target.value)}
|
||||
placeholder="e.g. web, public — leave blank for project root"
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : projectType === 'wordpress' ? (
|
||||
<WordpressSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : projectType === 'drupal' ? (
|
||||
<DrupalSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : (
|
||||
<GenericSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-20 flex items-center justify-between gap-3 border-t border-neutral-200/80 bg-neutral-50/95 p-4 backdrop-blur dark:border-white/10 dark:bg-neutral-950/95">
|
||||
<button
|
||||
type="button"
|
||||
onClick={step === 'site' ? onClose : () => setStep('site')}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-white hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
{step === 'site' ? null : <ArrowLeft size={14} />}
|
||||
{step === 'site' ? 'Cancel' : 'Go back'}
|
||||
</button>
|
||||
|
||||
{step === 'site' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canContinue}
|
||||
onClick={() => setStep('setup')}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{isSubmitting ? 'Creating…' : 'Add Site'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isValidProjectName, slugifyProjectName } from './projectName'
|
||||
|
||||
describe('isValidProjectName', () => {
|
||||
it('accepts hostname-safe names', () => {
|
||||
expect(isValidProjectName('my-project')).toBe(true)
|
||||
expect(isValidProjectName('project1')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects names with spaces', () => {
|
||||
expect(isValidProjectName('Aurora Admin')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects names starting or ending with a hyphen', () => {
|
||||
expect(isValidProjectName('-project')).toBe(false)
|
||||
expect(isValidProjectName('project-')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty names', () => {
|
||||
expect(isValidProjectName('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slugifyProjectName', () => {
|
||||
it('lowercases and replaces spaces with hyphens', () => {
|
||||
expect(slugifyProjectName('Aurora Admin')).toBe('aurora-admin')
|
||||
})
|
||||
|
||||
it('collapses runs of non-alphanumeric characters', () => {
|
||||
expect(slugifyProjectName('My Cool!! Project')).toBe('my-cool-project')
|
||||
})
|
||||
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(slugifyProjectName(' -Weird Name- ')).toBe('weird-name')
|
||||
})
|
||||
|
||||
it('produces a name that passes validation', () => {
|
||||
const slug = slugifyProjectName('Aurora Admin')
|
||||
expect(isValidProjectName(slug)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
// Aurora project names must be valid hostname labels: alphanumeric and
|
||||
// hyphens only, can't start/end with a hyphen.
|
||||
const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/
|
||||
|
||||
export function isValidProjectName(name: string): boolean {
|
||||
return PROJECT_NAME_PATTERN.test(name)
|
||||
}
|
||||
|
||||
export function slugifyProjectName(raw: string): string {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, KeyRound, Layers3, Mail, Type, UserRound } from 'lucide-react'
|
||||
import { useDownloadDrupal, useRequireDrush, useSetupDrupal } from '../../../hooks/useCreateProject'
|
||||
import { useStartProject } from '../../../hooks/useAurora'
|
||||
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||
|
||||
const PROFILES = [
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'demo_umami', label: 'Umami demo' }
|
||||
]
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||
const labelClass =
|
||||
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||
|
||||
export const DrupalSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function DrupalSetup(
|
||||
{ projectName, onValidityChange },
|
||||
ref
|
||||
) {
|
||||
const [siteName, setSiteName] = useState('')
|
||||
const [adminUser, setAdminUser] = useState('admin')
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [adminEmail, setAdminEmail] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [profile, setProfile] = useState('standard')
|
||||
|
||||
const startProject = useStartProject()
|
||||
const downloadDrupal = useDownloadDrupal()
|
||||
const requireDrush = useRequireDrush()
|
||||
const setupDrupal = useSetupDrupal()
|
||||
|
||||
const isValid =
|
||||
adminUser.trim().length > 0 && adminPassword.trim().length > 0 && adminEmail.trim().length > 0
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange(isValid)
|
||||
}, [isValid, onValidityChange])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
runPostCreate: async ({ directory, projectName: name }: TypeSetupContext) => {
|
||||
// drush needs the containers running, so this ignores any "start
|
||||
// after creating" preference — an unstarted Drupal project would just
|
||||
// be the same half-built state this whole flow exists to avoid.
|
||||
await startProject.mutateAsync(name)
|
||||
await downloadDrupal.mutateAsync({ directory })
|
||||
await requireDrush.mutateAsync({ directory })
|
||||
await setupDrupal.mutateAsync({
|
||||
directory,
|
||||
siteName: siteName.trim() || name,
|
||||
adminUser: adminUser.trim(),
|
||||
adminPassword: adminPassword.trim(),
|
||||
adminEmail: adminEmail.trim(),
|
||||
profile
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||
<Layers3 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">Drupal install</p>
|
||||
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||
Core downloads via Composer and the project starts for setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Site name</label>
|
||||
<div className="relative">
|
||||
<Type
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={siteName}
|
||||
onChange={(e) => setSiteName(e.target.value)}
|
||||
placeholder={projectName || 'My Drupal Site'}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin username</label>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={adminUser}
|
||||
onChange={(e) => setAdminUser(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin password</label>
|
||||
<div className="relative">
|
||||
<KeyRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Admin email</label>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="flex items-center justify-between rounded-xl border border-neutral-200 bg-white/70 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 transition hover:border-cyan-200 hover:bg-cyan-50/50 hover:text-cyan-800 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-200"
|
||||
>
|
||||
<span>Advanced options</span>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="grid gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div>
|
||||
<label className={labelClass}>Install profile</label>
|
||||
<select
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{PROFILES.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { PlayCircle } from 'lucide-react'
|
||||
import { useStartProject } from '../../../hooks/useAurora'
|
||||
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||
|
||||
// Fallback setup panel for any project type without a dedicated one yet
|
||||
// (see registry.ts). Just scaffolds via `Aurora config` and optionally starts
|
||||
// the project — no type-specific installer.
|
||||
export const GenericSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function GenericSetup(
|
||||
{ onValidityChange },
|
||||
ref
|
||||
) {
|
||||
const [startAfterCreate, setStartAfterCreate] = useState(true)
|
||||
const startProject = useStartProject()
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange(true)
|
||||
}, [onValidityChange])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
runPostCreate: async ({ projectName }: TypeSetupContext) => {
|
||||
if (startAfterCreate) {
|
||||
await startProject.mutateAsync(projectName)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||
<PlayCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
|
||||
Ready after Aurora config
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||
This type uses Aurora defaults, then you can finish the app-specific install in the
|
||||
project.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white/70 p-4 text-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<span>
|
||||
<span className="block font-semibold">Start after creating</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Launch the environment as soon as config is written.
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={startAfterCreate}
|
||||
onChange={(e) => setStartAfterCreate(e.target.checked)}
|
||||
className="size-4 accent-cyan-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Globe2, KeyRound, Mail, Type, UserRound } from 'lucide-react'
|
||||
import { useDownloadWordpress, useSetupWordpress } from '../../../hooks/useCreateProject'
|
||||
import { useStartProject } from '../../../hooks/useAurora'
|
||||
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ value: 'en_US', label: 'English (United States)' },
|
||||
{ value: 'en_GB', label: 'English (UK)' },
|
||||
{ value: 'de_DE', label: 'German' },
|
||||
{ value: 'es_ES', label: 'Spanish (Spain)' },
|
||||
{ value: 'fr_FR', label: 'French (France)' },
|
||||
{ value: 'it_IT', label: 'Italian' },
|
||||
{ value: 'pt_BR', label: 'Portuguese (Brazil)' },
|
||||
{ value: 'nl_NL', label: 'Dutch' },
|
||||
{ value: 'ja', label: 'Japanese' }
|
||||
]
|
||||
|
||||
type Multisite = 'none' | 'subdirectory' | 'subdomain'
|
||||
|
||||
const MULTISITE_OPTIONS: { value: Multisite; label: string }[] = [
|
||||
{ value: 'none', label: 'No' },
|
||||
{ value: 'subdirectory', label: 'Yes – Subdirectory' },
|
||||
{ value: 'subdomain', label: 'Yes – Subdomain' }
|
||||
]
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||
const labelClass =
|
||||
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||
|
||||
export const WordpressSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function WordpressSetup(
|
||||
{ projectName, onValidityChange },
|
||||
ref
|
||||
) {
|
||||
const [siteTitle, setSiteTitle] = useState('')
|
||||
const [adminUser, setAdminUser] = useState('admin')
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [adminEmail, setAdminEmail] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [language, setLanguage] = useState('en_US')
|
||||
const [multisite, setMultisite] = useState<Multisite>('none')
|
||||
|
||||
const startProject = useStartProject()
|
||||
const downloadWordpress = useDownloadWordpress()
|
||||
const setupWordpress = useSetupWordpress()
|
||||
|
||||
const isValid =
|
||||
adminUser.trim().length > 0 && adminPassword.trim().length > 0 && adminEmail.trim().length > 0
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange(isValid)
|
||||
}, [isValid, onValidityChange])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
runPostCreate: async ({ directory, projectName: name }: TypeSetupContext) => {
|
||||
// wp-cli needs the containers running, so this ignores any "start
|
||||
// after creating" preference — an unstarted WordPress project would
|
||||
// just be the same half-built state this whole flow exists to avoid.
|
||||
await startProject.mutateAsync(name)
|
||||
await downloadWordpress.mutateAsync({ directory, locale: language })
|
||||
await setupWordpress.mutateAsync({
|
||||
directory,
|
||||
siteUrl: `https://${name}.Aurora.site`,
|
||||
title: siteTitle.trim() || name,
|
||||
adminUser: adminUser.trim(),
|
||||
adminPassword: adminPassword.trim(),
|
||||
adminEmail: adminEmail.trim(),
|
||||
multisite
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||
<Globe2 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
|
||||
WordPress install
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||
Core downloads automatically and the project starts for setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Site title</label>
|
||||
<div className="relative">
|
||||
<Type
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={siteTitle}
|
||||
onChange={(e) => setSiteTitle(e.target.value)}
|
||||
placeholder={projectName || 'My WordPress Site'}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin username</label>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={adminUser}
|
||||
onChange={(e) => setAdminUser(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin password</label>
|
||||
<div className="relative">
|
||||
<KeyRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Admin email</label>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="flex items-center justify-between rounded-xl border border-neutral-200 bg-white/70 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 transition hover:border-cyan-200 hover:bg-cyan-50/50 hover:text-cyan-800 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-200"
|
||||
>
|
||||
<span>Advanced options</span>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="grid gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div>
|
||||
<label className={labelClass}>Select language</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Is this a WordPress Multisite?</label>
|
||||
<select
|
||||
value={multisite}
|
||||
onChange={(e) => setMultisite(e.target.value as Multisite)}
|
||||
className={inputClass}
|
||||
>
|
||||
{MULTISITE_OPTIONS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
export const PROJECT_TYPES = [
|
||||
{ value: '', label: 'Blank PHP + Node' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
{ value: 'laravel', label: 'Laravel' },
|
||||
{ value: 'drupal', label: 'Drupal' }
|
||||
]
|
||||
|
||||
export function getTypeLabel(projectType: string): string {
|
||||
return PROJECT_TYPES.find((type) => type.value === projectType)?.label ?? 'project'
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
|
||||
|
||||
export interface TypeSetupContext {
|
||||
directory: string
|
||||
projectName: string
|
||||
}
|
||||
|
||||
// Each project type's setup panel exposes this so the wizard can trigger
|
||||
// whatever post-`Aurora config` work that type needs (downloading app core,
|
||||
// running an installer, seeding a database, ...) without knowing the details.
|
||||
export interface TypeSetupHandle {
|
||||
runPostCreate: (ctx: TypeSetupContext) => Promise<void>
|
||||
}
|
||||
|
||||
export interface TypeSetupProps {
|
||||
projectName: string
|
||||
onValidityChange: (valid: boolean) => void
|
||||
}
|
||||
|
||||
export type TypeSetupComponent = ForwardRefExoticComponent<
|
||||
TypeSetupProps & RefAttributes<TypeSetupHandle>
|
||||
>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
import { useStatusStore } from '../../stores/statusStore'
|
||||
import { useTerminalStore } from '../../stores/terminalStore'
|
||||
|
||||
export function StatusBar(): React.JSX.Element {
|
||||
const operationId = useStatusStore((s) => s.operationId)
|
||||
const label = useStatusStore((s) => s.label)
|
||||
const setActiveOperation = useTerminalStore((s) => s.setActiveOperation)
|
||||
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
|
||||
|
||||
return (
|
||||
<footer className="flex h-8 flex-shrink-0 items-center justify-between border-t border-white/70 bg-white/75 px-3 text-xs text-neutral-500 backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/75 dark:text-neutral-400">
|
||||
{operationId && label ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveOperation(operationId)
|
||||
setPanelOpen(true)
|
||||
}}
|
||||
className="flex items-center gap-1.5 transition hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{label}…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.terminal.cancel(operationId)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-400/10"
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>Ready</span>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useLogStream } from '../../hooks/useLogStream'
|
||||
|
||||
function LogPane({
|
||||
name,
|
||||
service,
|
||||
filter
|
||||
}: {
|
||||
name: string
|
||||
service: string
|
||||
filter: string
|
||||
}): React.JSX.Element {
|
||||
const { lines, isStreaming } = useLogStream(name, service)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredLines = useMemo(() => {
|
||||
if (!filter.trim()) return lines
|
||||
const q = filter.toLowerCase()
|
||||
return lines.filter((line) => line.text.toLowerCase().includes(q))
|
||||
}, [lines, filter])
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
||||
}, [filteredLines.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center gap-1 text-xs',
|
||||
isStreaming ? 'text-emerald-600 dark:text-emerald-400' : 'text-neutral-400'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
isStreaming ? 'bg-emerald-500' : 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
{isStreaming ? 'streaming' : 'stopped'}
|
||||
</span>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto bg-neutral-950 px-4 py-3 font-mono text-xs text-neutral-200"
|
||||
>
|
||||
{filteredLines.length === 0 ? (
|
||||
<p className="text-neutral-500">
|
||||
{lines.length === 0 ? 'Waiting for log output…' : 'No lines match the filter.'}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{filteredLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx('whitespace-pre-wrap', line.stream === 'stderr' && 'text-red-400')}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogViewer({
|
||||
name,
|
||||
services,
|
||||
onClose
|
||||
}: {
|
||||
name: string
|
||||
services: string[]
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const [service, setService] = useState(services[0] ?? 'web')
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
|
||||
<div className="flex h-full w-full max-w-3xl flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-sm font-semibold">Logs — {name}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<select
|
||||
value={service}
|
||||
onChange={(e) => setService(e.target.value)}
|
||||
className="rounded-md border border-neutral-300 px-2 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
||||
>
|
||||
{services.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter logs…"
|
||||
className="flex-1 rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LogPane key={service} name={name} service={service} filter={filter} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Boxes, ChevronLeft, X } from 'lucide-react'
|
||||
import type { AuroraModuleManifest } from '@shared/types'
|
||||
import { useInstalledModules, useInstallModule, useModuleRegistry } from '../../hooks/useModules'
|
||||
|
||||
const CATEGORY_LABELS = { application: 'Applications', service: 'Services', tool: 'Developer Tools' } as const
|
||||
|
||||
function ModuleSettings({ module, values, onChange }: { module: AuroraModuleManifest; values: Record<string, string | number | boolean>; onChange: (values: Record<string, string | number | boolean>) => void }): React.JSX.Element {
|
||||
return <div className="grid gap-4">
|
||||
{module.settings.map((setting) => <label key={setting.id} className="grid gap-1.5 text-sm">
|
||||
<span className="font-medium">{setting.label}</span>
|
||||
{setting.type === 'boolean' ? (
|
||||
<input type="checkbox" checked={Boolean(values[setting.id])} onChange={(e) => onChange({ ...values, [setting.id]: e.target.checked })} className="size-4" />
|
||||
) : setting.type === 'select' ? (
|
||||
<select value={String(values[setting.id] ?? '')} onChange={(e) => onChange({ ...values, [setting.id]: e.target.value })} className="rounded-md border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-950">
|
||||
{setting.options?.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={String(values[setting.id] ?? '')} onChange={(e) => onChange({ ...values, [setting.id]: setting.type === 'number' ? Number(e.target.value) : e.target.value })} className="rounded-md border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-950" />
|
||||
)}
|
||||
</label>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ModuleBrowserModal({ name, onClose }: { name: string; onClose: () => void }): React.JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selected, setSelected] = useState<AuroraModuleManifest | null>(null)
|
||||
const [settings, setSettings] = useState<Record<string, string | number | boolean>>({})
|
||||
const { data: registry = [], isLoading } = useModuleRegistry()
|
||||
const { data: installed = [] } = useInstalledModules(name)
|
||||
const install = useInstallModule(name)
|
||||
const installedIds = useMemo(() => new Set(installed.map((item) => item.id)), [installed])
|
||||
const filtered = useMemo(() => registry.filter((item) => !query.trim() || `${item.name} ${item.description}`.toLowerCase().includes(query.toLowerCase())), [registry, query])
|
||||
|
||||
function choose(module: AuroraModuleManifest): void {
|
||||
setSelected(module)
|
||||
setSettings(Object.fromEntries(module.settings.map((setting) => [setting.id, setting.default])))
|
||||
}
|
||||
|
||||
async function installSelected(): Promise<void> {
|
||||
if (!selected) return
|
||||
await install.mutateAsync({ moduleId: selected.id, settings })
|
||||
setSelected(null)
|
||||
}
|
||||
|
||||
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8 backdrop-blur-sm">
|
||||
<div className="flex h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<header className="flex items-center justify-between border-b border-neutral-200 px-5 py-4 dark:border-neutral-800">
|
||||
<div className="flex items-center gap-3">
|
||||
{selected && <button onClick={() => setSelected(null)} className="rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800"><ChevronLeft size={18}/></button>}
|
||||
<div><h2 className="font-semibold">{selected ? selected.name : 'Aurora Module Library'}</h2><p className="text-xs text-neutral-500">{selected ? selected.description : 'Extend this project without rebuilding Dockside.'}</p></div>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800"><X size={18}/></button>
|
||||
</header>
|
||||
{selected ? <div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-neutral-200 p-5 dark:border-neutral-800">
|
||||
<div className="mb-5 flex items-center gap-3"><span className="grid size-11 place-items-center rounded-xl bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300"><Boxes size={20}/></span><div><div className="font-semibold">{selected.name}</div><div className="text-xs uppercase tracking-wide text-neutral-500">{selected.category} · v{selected.version}</div></div></div>
|
||||
{selected.settings.length ? <ModuleSettings module={selected} values={settings} onChange={setSettings}/> : <p className="text-sm text-neutral-500">This module has no configuration options.</p>}
|
||||
{selected.conflicts.length > 0 && <p className="mt-4 rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-400/10 dark:text-amber-200">Application conflict protection: {selected.conflicts.join(', ')}</p>}
|
||||
<button disabled={install.isPending || installedIds.has(selected.id)} onClick={() => void installSelected()} className="mt-6 w-full rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50">{installedIds.has(selected.id) ? 'Already installed' : install.isPending ? 'Installing…' : `Install ${selected.name}`}</button>
|
||||
</div>
|
||||
</div> : <>
|
||||
<div className="border-b border-neutral-200 p-4 dark:border-neutral-800"><input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search modules…" className="w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"/></div>
|
||||
<div className="flex-1 overflow-y-auto p-5">{isLoading ? <p className="text-sm text-neutral-500">Loading module registry…</p> : (['application','service','tool'] as const).map((category) => {
|
||||
const items = filtered.filter((item) => item.category === category); if (!items.length) return null
|
||||
return <section key={category} className="mb-6"><h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">{CATEGORY_LABELS[category]}</h3><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{items.map((module) => <button key={module.id} onClick={() => choose(module)} className="rounded-xl border border-neutral-200 p-4 text-left transition hover:border-cyan-300 hover:bg-cyan-50/50 dark:border-neutral-800 dark:hover:border-cyan-400/30 dark:hover:bg-cyan-400/5"><div className="flex items-center justify-between"><span className="font-semibold">{module.name}</span><span className="text-[10px] uppercase text-neutral-400">{installedIds.has(module.id) ? 'Installed' : `v${module.version}`}</span></div><p className="mt-2 text-xs leading-relaxed text-neutral-500">{module.description}</p></button>)}</div></section>
|
||||
})}</div>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { Camera, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
|
||||
import {
|
||||
useCreateSnapshot,
|
||||
useDeleteSnapshot,
|
||||
useExportDatabase,
|
||||
useImportDatabase,
|
||||
useRestoreSnapshot,
|
||||
useSnapshots
|
||||
} from '../../hooks/useDatabase'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
export function DatabaseSection({
|
||||
name,
|
||||
approot
|
||||
}: {
|
||||
name: string
|
||||
approot: string
|
||||
}): React.JSX.Element {
|
||||
const { data: snapshots, isLoading } = useSnapshots(name, approot)
|
||||
const createSnapshot = useCreateSnapshot(name, approot)
|
||||
const restoreSnapshot = useRestoreSnapshot(name, approot)
|
||||
const deleteSnapshot = useDeleteSnapshot(name, approot)
|
||||
const importDatabase = useImportDatabase(name, approot)
|
||||
const exportDatabase = useExportDatabase(name, approot)
|
||||
|
||||
// Electron doesn't support window.prompt() (it silently returns null with
|
||||
// no dialog), so snapshot naming needs an inline input instead.
|
||||
const [isNaming, setIsNaming] = useState(false)
|
||||
const [snapshotNameDraft, setSnapshotNameDraft] = useState('')
|
||||
|
||||
const isBusy =
|
||||
createSnapshot.isPending ||
|
||||
restoreSnapshot.isPending ||
|
||||
deleteSnapshot.isPending ||
|
||||
importDatabase.isPending ||
|
||||
exportDatabase.isPending
|
||||
|
||||
function submitSnapshotName(): void {
|
||||
const trimmed = snapshotNameDraft.trim()
|
||||
createSnapshot.mutate(trimmed || undefined)
|
||||
setIsNaming(false)
|
||||
setSnapshotNameDraft('')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
Database
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isNaming ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Snapshot name (optional)"
|
||||
value={snapshotNameDraft}
|
||||
onChange={(e) => setSnapshotNameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submitSnapshotName()
|
||||
if (e.key === 'Escape') setIsNaming(false)
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs dark:border-white/10 dark:bg-neutral-950"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitSnapshotName}
|
||||
className="rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white transition hover:bg-cyan-500"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNaming(false)}
|
||||
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 transition hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => importDatabase.mutate()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
|
||||
>
|
||||
<Upload size={12} /> Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => exportDatabase.mutate()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
|
||||
>
|
||||
<Download size={12} /> Export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsNaming(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white shadow-sm shadow-cyan-900/[0.15] transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Camera size={12} /> Snapshot
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||
Loading snapshots…
|
||||
</p>
|
||||
) : !snapshots || snapshots.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||
No snapshots yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200/80 bg-white dark:border-white/10 dark:bg-neutral-950/70">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-white/[0.04] dark:text-neutral-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Created</th>
|
||||
<th className="px-3 py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr
|
||||
key={snapshot.Name}
|
||||
className="border-t border-neutral-200/80 transition hover:bg-cyan-50/40 dark:border-white/10 dark:hover:bg-cyan-400/5"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{snapshot.Name}</td>
|
||||
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
||||
{formatDate(snapshot.Created)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => restoreSnapshot.mutate(snapshot.Name)}
|
||||
title="Restore"
|
||||
className="rounded p-1 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete snapshot "${snapshot.Name}"?`)) {
|
||||
deleteSnapshot.mutate(snapshot.Name)
|
||||
}
|
||||
}}
|
||||
title="Delete"
|
||||
className="rounded p-1 text-red-500 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
export function DeleteProjectModal({
|
||||
projectName,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
projectName: string
|
||||
onCancel: () => void
|
||||
onConfirm: (deleteFiles: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const [deleteFiles, setDeleteFiles] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-8 backdrop-blur-sm">
|
||||
<div className="grid w-full max-w-md gap-4 rounded-2xl border border-white/70 bg-white p-5 shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Delete "{projectName}"?
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Aurora will take a database snapshot first, then remove the project's containers
|
||||
and Aurora registration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-3 text-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
onChange={(e) => setDeleteFiles(e.target.checked)}
|
||||
className="mt-0.5 size-4 accent-red-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Also delete project files from disk
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Permanently removes the project folder. This cannot be undone by the database
|
||||
snapshot.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(deleteFiles)}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-red-900/20 transition hover:bg-red-500"
|
||||
>
|
||||
{deleteFiles ? 'Delete project + files' : 'Delete project'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Database, ExternalLink, Info, RotateCw, TerminalSquare, Wrench } from 'lucide-react'
|
||||
import type { AuroraProjectDetail } from '@shared/types'
|
||||
import { useTerminalStore } from '../../stores/terminalStore'
|
||||
import { useStatusStore } from '../../stores/statusStore'
|
||||
|
||||
export function DeveloperServices({ project }: { project: AuroraProjectDetail }): React.JSX.Element {
|
||||
const button = 'inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-700 shadow-sm transition hover:border-cyan-300 hover:text-cyan-700 dark:border-white/10 dark:bg-white/[0.05] dark:text-neutral-200 dark:hover:border-cyan-400/40 dark:hover:text-cyan-300'
|
||||
async function streamed(label: string, fn: (id: string) => Promise<void>): Promise<void> {
|
||||
const id=crypto.randomUUID(); useTerminalStore.getState().startOperation(id,label); useStatusStore.getState().begin(id,label); await fn(id)
|
||||
}
|
||||
return <section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-4"><h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Wrench size={14} className="text-cyan-600 dark:text-cyan-300"/> Project developer services</h3><p className="mt-1 text-xs text-neutral-500">Database administration, PHP diagnostics, terminal access and service controls.</p></div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.adminer_url && <a className={button} href={project.adminer_url} target="_blank" rel="noreferrer"><Database size={13}/> Adminer <ExternalLink size={11}/></a>}
|
||||
<button className={button} onClick={() => void streamed(`PHP info · ${project.name}`,(id)=>window.api.projects.phpInfo(id,project.name))}><Info size={13}/> PHP info</button>
|
||||
<button className={button} onClick={() => void window.api.projects.openTerminal(project.name)}><TerminalSquare size={13}/> Project terminal</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{Object.values(project.services).map(service => <div key={service.short_name} className="flex items-center justify-between rounded-lg border border-neutral-200/80 bg-white/70 px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]"><div className="min-w-0"><div className="truncate text-xs font-semibold">{service.short_name}</div><div className="truncate text-[10px] text-neutral-500">{service.status}</div></div><button title={`Restart ${service.short_name}`} className="rounded p-1.5 hover:bg-black/5 dark:hover:bg-white/10" onClick={() => void streamed(`Restart ${service.short_name} · ${project.name}`,(id)=>window.api.projects.restartService(id,project.name,service.short_name))}><RotateCw size={13}/></button></div>)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { Boxes, Trash2 } from 'lucide-react'
|
||||
import { useInstalledModules, useRemoveModule } from '../../hooks/useModules'
|
||||
import { ModuleBrowserModal } from '../modules/ModuleBrowserModal'
|
||||
|
||||
export function ModulesSection({ name }: { name: string }): React.JSX.Element {
|
||||
const { data: installed = [], isLoading } = useInstalledModules(name)
|
||||
const remove = useRemoveModule(name)
|
||||
const [open, setOpen] = useState(false)
|
||||
return <section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-3 flex items-center justify-between"><div><h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Modules</h3><p className="mt-1 text-xs text-neutral-400">Applications, services and developer tools.</p></div><button onClick={() => setOpen(true)} className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium dark:border-white/10 dark:bg-white/[0.05]"><Boxes size={12}/> Module Library</button></div>
|
||||
{isLoading ? <p className="text-sm text-neutral-500">Loading modules…</p> : installed.length === 0 ? <p className="rounded-lg border border-dashed border-neutral-300 p-4 text-sm text-neutral-500 dark:border-white/10">No modules installed. The base PHP + Node environment is ready.</p> : <div className="grid gap-2 sm:grid-cols-2">{installed.map((module) => <div key={module.id} className="flex items-center justify-between rounded-lg border border-neutral-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{module.name}</div><div className="text-xs capitalize text-neutral-500">{module.category} · v{module.version}</div></div><button disabled={remove.isPending} onClick={() => { if (window.confirm(`Remove module "${module.name}"?`)) remove.mutate(module.id) }} className="rounded p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-400/10"><Trash2 size={14}/></button></div>)}</div>}
|
||||
{open && <ModuleBrowserModal name={name} onClose={() => setOpen(false)}/>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import {
|
||||
Boxes,
|
||||
Code2,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Gauge,
|
||||
KeyRound,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Play,
|
||||
RotateCw,
|
||||
Server,
|
||||
Square,
|
||||
Trash2,
|
||||
Zap,
|
||||
Globe2,
|
||||
LockKeyhole,
|
||||
Radio
|
||||
} from 'lucide-react'
|
||||
import type { AuroraSiteCredentials, EnvironmentUpdate } from '@shared/types'
|
||||
import {
|
||||
useDeleteProject,
|
||||
useProjectDetail,
|
||||
useRestartProject,
|
||||
useStartProject,
|
||||
useStopProject,
|
||||
useUpdateEnvironment
|
||||
} from '../../hooks/useAurora'
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
import { DatabaseSection } from './DatabaseSection'
|
||||
import { ModulesSection } from './ModulesSection'
|
||||
import { WordpressTools } from './WordpressTools'
|
||||
import { DeveloperServices } from './DeveloperServices'
|
||||
import { DeleteProjectModal } from './DeleteProjectModal'
|
||||
import { LogViewer } from '../logs/LogViewer'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
|
||||
const NODE_VERSIONS = ['20', '22', '24']
|
||||
|
||||
const PHP_VERSIONS = [
|
||||
'5.6',
|
||||
'7.0',
|
||||
'7.1',
|
||||
'7.2',
|
||||
'7.3',
|
||||
'7.4',
|
||||
'8.0',
|
||||
'8.1',
|
||||
'8.2',
|
||||
'8.3',
|
||||
'8.4',
|
||||
'8.5'
|
||||
]
|
||||
|
||||
const WEBSERVER_TYPES = [
|
||||
{ value: 'nginx-fpm', label: 'nginx' },
|
||||
{ value: 'apache-fpm', label: 'Apache' },
|
||||
{ value: 'generic', label: 'Generic' }
|
||||
]
|
||||
|
||||
const DATABASE_OPTIONS = [
|
||||
{ value: 'mariadb:11.8', label: 'MariaDB 11.8' },
|
||||
{ value: 'mariadb:10.11', label: 'MariaDB 10.11' },
|
||||
{ value: 'mariadb:10.6', label: 'MariaDB 10.6' },
|
||||
{ value: 'mysql:8.4', label: 'MySQL 8.4' },
|
||||
{ value: 'mysql:8.0', label: 'MySQL 8.0' },
|
||||
{ value: 'mysql:5.7', label: 'MySQL 5.7' },
|
||||
{ value: 'postgres:17', label: 'PostgreSQL 17' },
|
||||
{ value: 'postgres:16', label: 'PostgreSQL 16' },
|
||||
{ value: 'postgres:15', label: 'PostgreSQL 15' }
|
||||
]
|
||||
|
||||
const heroFieldClass =
|
||||
'w-full rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-sm font-semibold text-white transition hover:border-cyan-300/40 hover:bg-white/10 focus:border-cyan-300/60 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
||||
const startProject = useStartProject()
|
||||
const stopProject = useStopProject()
|
||||
const restartProject = useRestartProject()
|
||||
const deleteProject = useDeleteProject()
|
||||
const updateEnvironment = useUpdateEnvironment()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
const [isLogsOpen, setIsLogsOpen] = useState(false)
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
|
||||
const [siteCredentials, setSiteCredentials] = useState<AuroraSiteCredentials | null>(null)
|
||||
const [showSitePassword, setShowSitePassword] = useState(false)
|
||||
const [isTrustingCA, setIsTrustingCA] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!project || project.type !== 'wordpress') {
|
||||
setSiteCredentials(null)
|
||||
return () => { active = false }
|
||||
}
|
||||
void window.api.secrets.getSiteCredentials(project.approot).then((credentials) => {
|
||||
if (active) setSiteCredentials(credentials)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [project?.approot, project?.type])
|
||||
|
||||
function copyCredential(value: string): void {
|
||||
void navigator.clipboard.writeText(value)
|
||||
}
|
||||
|
||||
const isBusy =
|
||||
startProject.isPending ||
|
||||
stopProject.isPending ||
|
||||
restartProject.isPending ||
|
||||
deleteProject.isPending
|
||||
|
||||
const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending
|
||||
|
||||
function handleDeleteConfirm(deleteFiles: boolean): void {
|
||||
if (!project) return
|
||||
setIsDeleteOpen(false)
|
||||
deleteProject.mutate(
|
||||
{ name: project.name, approot: project.approot, deleteFiles },
|
||||
{ onSuccess: () => selectProject(null) }
|
||||
)
|
||||
}
|
||||
|
||||
async function applyEnvironmentChange(updates: EnvironmentUpdate): Promise<void> {
|
||||
if (!project) return
|
||||
await updateEnvironment.mutateAsync({ name: project.name, approot: project.approot, updates })
|
||||
if (project.status === 'running') {
|
||||
await restartProject.mutateAsync(project.name)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDatabaseChange(value: string): void {
|
||||
const confirmed = window.confirm(
|
||||
'Changing the database type restarts the project and may require Aurora to migrate or ' +
|
||||
'recreate the database. Consider taking a snapshot first if this project has data you ' +
|
||||
'want to keep. Continue?'
|
||||
)
|
||||
if (!confirmed) return
|
||||
void applyEnvironmentChange({ database: value })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6 text-sm text-neutral-500 dark:text-neutral-400">Loading {name}…</div>
|
||||
}
|
||||
|
||||
if (isError || !project) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-600 dark:text-red-400">
|
||||
{error instanceof Error ? error.message : `Failed to load ${name}.`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isRunning = project.status === 'running'
|
||||
const services = Object.values(project.services)
|
||||
const runningServices = services.filter((service) => service.status === 'running').length
|
||||
|
||||
const phpVersions =
|
||||
project.php_version && !PHP_VERSIONS.includes(project.php_version)
|
||||
? [project.php_version, ...PHP_VERSIONS]
|
||||
: PHP_VERSIONS
|
||||
|
||||
const currentDatabase = `${project.dbinfo.database_type}:${project.dbinfo.database_version}`
|
||||
const allowedDatabaseOptions = project.type === 'wordpress' ? DATABASE_OPTIONS.filter((o) => o.value.startsWith('mariadb:')) : DATABASE_OPTIONS
|
||||
const databaseOptions = allowedDatabaseOptions.some((o) => o.value === currentDatabase)
|
||||
? DATABASE_OPTIONS
|
||||
: [
|
||||
{
|
||||
value: currentDatabase,
|
||||
label: `${project.dbinfo.database_type} ${project.dbinfo.database_version}`
|
||||
},
|
||||
...allowedDatabaseOptions
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-6">
|
||||
<header className="overflow-hidden rounded-2xl border border-white/70 bg-neutral-950 text-white shadow-[0_24px_70px_rgba(15,23,42,0.18)] dark:border-white/10">
|
||||
<div className="relative p-5">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.09)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.09)_1px,transparent_1px)] bg-[size:36px_36px]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-cyan-500/10 to-transparent" />
|
||||
<div className="relative flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-medium text-cyan-100">
|
||||
<Gauge size={13} />
|
||||
{runningServices} of {services.length} services running
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-3xl font-semibold">{project.name}</h2>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<p className="mt-2 max-w-3xl truncate text-sm text-neutral-300">{project.approot}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning || isBusy}
|
||||
onClick={() => startProject.mutate(project.name)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-500 px-3 py-1.5 text-sm font-semibold text-white shadow-sm shadow-emerald-950/20 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning || isBusy}
|
||||
onClick={() => stopProject.mutate(project.name)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning || isBusy}
|
||||
onClick={() => restartProject.mutate(project.name)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-400 px-3 py-1.5 text-sm font-semibold text-neutral-950 shadow-sm shadow-cyan-950/20 transition hover:bg-cyan-300 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RotateCw size={14} /> Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning}
|
||||
onClick={() => setIsLogsOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FileText size={14} /> Logs
|
||||
</button>
|
||||
{project.type === 'wordpress' && isRunning && (
|
||||
<a
|
||||
href={`${project.primary_url}/wp-admin/`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15]"
|
||||
>
|
||||
<KeyRound size={14} /> WP Admin
|
||||
</a>
|
||||
)}
|
||||
{project.type === 'wordpress' && isRunning && project.wordpress_network_admin_url && (
|
||||
<a href={project.wordpress_network_admin_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15]">
|
||||
<Globe2 size={14} /> Network Admin
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-red-300/20 bg-red-400/10 px-3 py-1.5 text-sm font-medium text-red-100 transition hover:bg-red-400/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<Boxes size={17} className="mb-3 text-cyan-200" />
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Project type
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-white">{project.type}</p>{project.type === 'wordpress' && <p className="mt-1 text-[11px] text-neutral-400">{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}</p>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<Code2 size={17} className="mb-3 text-cyan-200" />
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
PHP
|
||||
</p>
|
||||
<select
|
||||
value={project.php_version ?? ''}
|
||||
disabled={isEnvUpdating}
|
||||
onChange={(e) => void applyEnvironmentChange({ phpVersion: e.target.value })}
|
||||
className={heroFieldClass}
|
||||
>
|
||||
{phpVersions.map((v) => (
|
||||
<option key={v} value={v} className="text-neutral-900">
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<Code2 size={17} className="mb-3 text-cyan-200" />
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">Node.js</p>
|
||||
<select value={project.nodejs_version ?? '24'} disabled={isEnvUpdating} onChange={(e) => void applyEnvironmentChange({ nodeVersion: e.target.value })} className={heroFieldClass}>
|
||||
{NODE_VERSIONS.map((v) => <option key={v} value={v} className="text-neutral-900">{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<Server size={17} className="mb-3 text-cyan-200" />
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Web server
|
||||
</p>
|
||||
<select
|
||||
value={project.webserver_type ?? 'nginx-fpm'}
|
||||
disabled={isEnvUpdating}
|
||||
onChange={(e) => void applyEnvironmentChange({ webserverType: e.target.value })}
|
||||
className={heroFieldClass}
|
||||
>
|
||||
{WEBSERVER_TYPES.map((w) => (
|
||||
<option key={w.value} value={w.value} className="text-neutral-900">
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Zap size={17} className="text-cyan-200" />
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={project.xdebug_enabled}
|
||||
aria-label="Toggle Xdebug"
|
||||
disabled={isEnvUpdating}
|
||||
onClick={() =>
|
||||
void applyEnvironmentChange({ xdebugEnabled: !project.xdebug_enabled })
|
||||
}
|
||||
className={clsx(
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition disabled:cursor-not-allowed disabled:opacity-50',
|
||||
project.xdebug_enabled ? 'bg-cyan-400' : 'bg-white/15'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block size-3.5 transform rounded-full bg-white shadow transition',
|
||||
project.xdebug_enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">Xdebug</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{project.xdebug_enabled ? 'Enabled' : 'Off'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Globe2 size={14} className="text-cyan-600 dark:text-cyan-300" /> Site URLs</h3>
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-semibold',
|
||||
project.router_status === 'running'
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
: project.router_status === 'provider-error'
|
||||
? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-neutral-500/10 text-neutral-500'
|
||||
)}>
|
||||
<Radio size={11} /> Router {project.router_status === 'running' ? 'online' : project.router_status === 'provider-error' ? 'Docker provider error' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{[{label:'HTTP',url:project.httpurl,icon:<Globe2 size={14}/>},{label:'HTTPS',url:project.httpsurl,icon:<LockKeyhole size={14}/>}].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2 rounded-lg border border-neutral-200/70 bg-neutral-50/70 p-2 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<span className="flex w-16 items-center gap-1.5 text-xs font-semibold text-neutral-500">{item.icon}{item.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{item.url}</span>
|
||||
<a href={item.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md bg-cyan-600 px-2.5 py-1.5 text-xs font-semibold text-white hover:bg-cyan-500">Open <ExternalLink size={11}/></a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200/70 pt-3 dark:border-white/10">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-neutral-700 dark:text-neutral-200">Primary protocol</p>
|
||||
<p className="text-[11px] text-neutral-500">Both remain available. WordPress canonical URLs follow this setting.</p>
|
||||
</div>
|
||||
<div className="inline-flex rounded-lg border border-neutral-200 bg-neutral-100 p-1 dark:border-white/10 dark:bg-white/5">
|
||||
{(['http','https'] as const).map((protocol) => { const active = project.primary_url.startsWith(`${protocol}:`); return <button key={protocol} type="button" disabled={isEnvUpdating || !isRunning} onClick={() => void applyEnvironmentChange({ primaryProtocol: protocol })} className={clsx('rounded-md px-3 py-1.5 text-xs font-semibold uppercase transition disabled:cursor-not-allowed disabled:opacity-50', active ? 'bg-white text-cyan-700 shadow-sm dark:bg-neutral-800 dark:text-cyan-300' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white')}>{protocol}</button> })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-neutral-200/70 bg-neutral-50/70 p-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="text-[11px]">
|
||||
<p className="font-semibold text-neutral-700 dark:text-neutral-200">Aurora HTTPS certificate</p>
|
||||
<p className="mt-0.5 text-neutral-500">Certificate: {project.certificate_status === 'generated' ? 'Generated' : 'Missing'} · System: {project.ca_trust_status === 'trusted' ? 'Trusted' : project.ca_trust_status === 'not-trusted' ? 'Not trusted' : 'Unknown'} · Firefox: {project.firefox_trust_status === 'trusted' ? 'Trusted' : project.firefox_trust_status === 'not-trusted' ? 'Not trusted' : project.firefox_trust_status === 'unavailable' ? 'Needs NSS tools' : 'Unknown'} · Chromium: {project.chromium_trust_status === 'trusted' ? 'Trusted' : project.chromium_trust_status === 'not-trusted' ? 'Not trusted' : project.chromium_trust_status === 'unavailable' ? 'Needs NSS tools' : 'Not detected'}</p>
|
||||
</div>
|
||||
{(project.ca_trust_status !== 'trusted' || project.firefox_trust_status === 'not-trusted' || project.firefox_trust_status === 'unavailable' || project.chromium_trust_status === 'not-trusted' || project.chromium_trust_status === 'unavailable') && (
|
||||
<button type="button" disabled={isTrustingCA} onClick={async () => { try { setIsTrustingCA(true); await window.api.projects.trustCA(); window.location.reload() } finally { setIsTrustingCA(false) } }} className="rounded-md bg-cyan-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-cyan-500 disabled:opacity-50">
|
||||
{isTrustingCA ? 'Installing…' : 'Install / Repair Browser Trust'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
<Database size={14} className="text-cyan-600 dark:text-cyan-300" />
|
||||
Database credentials
|
||||
</h3>
|
||||
<dl className="grid grid-cols-[minmax(110px,0.45fr)_1fr] items-center gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Type</dt>
|
||||
<dd className="font-medium">
|
||||
<select
|
||||
value={currentDatabase}
|
||||
disabled={isEnvUpdating}
|
||||
onChange={(e) => handleDatabaseChange(e.target.value)}
|
||||
className="w-full max-w-[220px] rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm transition hover:border-cyan-300 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-950"
|
||||
>
|
||||
{databaseOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Database</dt>
|
||||
<dd className="font-medium">{project.dbinfo.dbname}</dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
|
||||
<dd className="font-medium">{project.dbinfo.username}</dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
|
||||
<dd className="font-mono text-xs">{project.dbinfo.password}</dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Port</dt>
|
||||
<dd className="font-medium">{project.dbinfo.published_port}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{project.type === 'wordpress' && siteCredentials && (
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
<KeyRound size={14} className="text-cyan-600 dark:text-cyan-300" />
|
||||
Site credentials
|
||||
</h3>
|
||||
<a href={siteCredentials.adminUrl} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 rounded-md bg-cyan-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-cyan-500">
|
||||
Open WP Admin <ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[minmax(110px,0.3fr)_1fr] items-center gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Admin URL</dt>
|
||||
<dd className="flex min-w-0 items-center gap-2"><span className="truncate font-mono text-xs">{siteCredentials.adminUrl}</span><button title="Copy admin URL" onClick={() => copyCredential(siteCredentials.adminUrl)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
|
||||
<dd className="flex items-center gap-2"><span className="font-medium">{siteCredentials.username}</span><button title="Copy username" onClick={() => copyCredential(siteCredentials.username)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
|
||||
<dd className="flex items-center gap-2"><span className="min-w-[150px] font-mono text-xs">{showSitePassword ? siteCredentials.password : '••••••••••••'}</span><button title={showSitePassword ? 'Hide password' : 'Show password'} onClick={() => setShowSitePassword((v) => !v)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10">{showSitePassword ? <EyeOff size={14} /> : <Eye size={14} />}</button><button title="Copy password" onClick={() => copyCredential(siteCredentials.password)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Email</dt>
|
||||
<dd className="flex items-center gap-2"><span className="truncate font-medium">{siteCredentials.email}</span><button title="Copy email" onClick={() => copyCredential(siteCredentials.email)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{project.type === 'wordpress' && isRunning && <WordpressTools project={project} />}
|
||||
{isRunning && <DeveloperServices project={project} />}
|
||||
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
Services
|
||||
</h3>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.short_name}
|
||||
className="rounded-xl border border-neutral-200/80 bg-white/80 p-4 transition hover:border-cyan-200 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/30"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{service.short_name}</p>
|
||||
<p className="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{service.full_name}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={service.status} />
|
||||
</div>
|
||||
<p className="mt-4 truncate rounded-lg bg-neutral-100 px-3 py-2 font-mono text-xs text-neutral-600 dark:bg-neutral-950/70 dark:text-neutral-300">
|
||||
{service.image}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<DatabaseSection name={project.name} approot={project.approot} />
|
||||
<ModulesSection name={project.name} />
|
||||
</div>
|
||||
|
||||
{isLogsOpen && (
|
||||
<LogViewer
|
||||
name={project.name}
|
||||
services={Object.keys(project.services)}
|
||||
onClose={() => setIsLogsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDeleteOpen && (
|
||||
<DeleteProjectModal
|
||||
projectName={project.name}
|
||||
onCancel={() => setIsDeleteOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { FolderKanban, Loader2 } from 'lucide-react'
|
||||
import { useProjects } from '../../hooks/useAurora'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
|
||||
export function ProjectList(): React.JSX.Element {
|
||||
const { data: projects, isLoading, isError, error } = useProjects()
|
||||
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<Loader2 size={15} className="animate-spin text-cyan-600 dark:text-cyan-300" />
|
||||
Loading projects…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-red-600 dark:text-red-400">
|
||||
{error instanceof Error ? error.message : 'Failed to load Aurora projects.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projects || projects.length === 0) {
|
||||
return (
|
||||
<div className="m-3 rounded-lg border border-dashed border-neutral-300 bg-white/60 p-4 text-sm text-neutral-500 dark:border-neutral-700 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||
<div className="mb-3 grid size-10 place-items-center rounded-lg bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300">
|
||||
<FolderKanban size={18} />
|
||||
</div>
|
||||
<p className="font-medium text-neutral-800 dark:text-neutral-200">No Aurora projects yet</p>
|
||||
<p className="mt-1 leading-5">
|
||||
Create a project with the + button.{' '}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1.5 p-2.5">
|
||||
{projects.map((project) => (
|
||||
<li key={project.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectProject(project.name)}
|
||||
className={clsx(
|
||||
'group relative flex w-full flex-col gap-1 overflow-hidden rounded-lg border px-3 py-2.5 text-left transition',
|
||||
selectedProjectName === project.name
|
||||
? 'border-cyan-200 bg-cyan-50/90 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/25 dark:bg-cyan-400/10'
|
||||
: 'border-transparent hover:border-neutral-200 hover:bg-white/70 hover:shadow-sm dark:hover:border-white/10 dark:hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute inset-y-2 left-0 w-1 rounded-r-full transition-opacity',
|
||||
selectedProjectName === project.name
|
||||
? 'bg-cyan-500 opacity-100'
|
||||
: 'bg-neutral-300 opacity-0 group-hover:opacity-100 dark:bg-neutral-600'
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-semibold">{project.name}</span>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{project.type} · {project.shortroot}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
running:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300',
|
||||
stopped:
|
||||
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400',
|
||||
paused:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-300'
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE =
|
||||
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400'
|
||||
|
||||
export function StatusBadge({ status }: { status: string }): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
|
||||
STATUS_STYLES[status] ?? DEFAULT_STYLE
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'running'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'paused'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { Bug, Hammer, List, RefreshCw, Search, ShieldAlert, TerminalSquare, Wrench } from 'lucide-react'
|
||||
import type { AuroraProjectDetail } from '@shared/types'
|
||||
import { useTerminalStore } from '../../stores/terminalStore'
|
||||
import { useStatusStore } from '../../stores/statusStore'
|
||||
|
||||
export function WordpressTools({ project }: { project: AuroraProjectDetail }): React.JSX.Element {
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [debug, setDebug] = useState<boolean | null>(null)
|
||||
const [maintenance, setMaintenance] = useState(false)
|
||||
|
||||
async function run(action: string, label: string, payload?: string): Promise<void> {
|
||||
const id = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(id, label)
|
||||
useStatusStore.getState().begin(id, label)
|
||||
setBusy(action)
|
||||
try { await window.api.wordpress.run(id, project.name, action, payload) }
|
||||
finally { setBusy(null) }
|
||||
}
|
||||
|
||||
function searchReplace(): void {
|
||||
const from = window.prompt('Search for (old URL or text):')
|
||||
if (!from) return
|
||||
const to = window.prompt('Replace with:')
|
||||
if (!to || from === to) return
|
||||
if (!window.confirm(`Replace all occurrences of:\n\n${from}\n\nwith:\n\n${to}\n\nA database snapshot is recommended first. Continue?`)) return
|
||||
void run('search-replace', `WP search-replace · ${project.name}`, `${from}\n${to}`)
|
||||
}
|
||||
|
||||
const button = 'inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-700 shadow-sm transition hover:border-cyan-300 hover:text-cyan-700 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:text-neutral-200 dark:hover:border-cyan-400/40 dark:hover:text-cyan-300'
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Wrench size={14} className="text-cyan-600 dark:text-cyan-300" /> WordPress developer tools</h3>
|
||||
<p className="mt-1 text-xs text-neutral-500">WP-CLI commands run inside the project network and stream output to Dockside's terminal.</p>
|
||||
</div>
|
||||
<div className="text-right text-[11px] text-neutral-500">
|
||||
<div>PHP {project.php_version ?? '—'} · Node {project.nodejs_version ?? '—'}</div>
|
||||
<div>{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('cache-flush', `WP cache flush · ${project.name}`)}><RefreshCw size={13}/> Flush cache</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('rewrite-flush', `WP rewrite flush · ${project.name}`)}><Hammer size={13}/> Flush rewrites</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => { const next=!maintenance; setMaintenance(next); void run(next?'maintenance-on':'maintenance-off', `${next?'Enable':'Disable'} maintenance · ${project.name}`) }}><ShieldAlert size={13}/> {maintenance ? 'Disable' : 'Enable'} maintenance</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => { const next=debug === null ? true : !debug; setDebug(next); void run(next?'debug-on':'debug-off', `${next?'Enable':'Disable'} WP_DEBUG · ${project.name}`) }}><Bug size={13}/> {debug ? 'Disable' : 'Enable'} WP_DEBUG</button>
|
||||
<button className={button} disabled={!!busy} onClick={searchReplace}><Search size={13}/> Search & replace</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('plugin-list', `WP plugins · ${project.name}`)}><List size={13}/> Plugins</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('theme-list', `WP themes · ${project.name}`)}><List size={13}/> Themes</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('core-version', `WP-CLI · ${project.name}`)}><TerminalSquare size={13}/> WP version</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Minus, Plus, RotateCcw, X } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useThemeStore, type Theme } from '../../stores/themeStore'
|
||||
|
||||
const THEMES: { value: Theme; label: string }[] = [
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'system', label: 'System' }
|
||||
]
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ keys: 'Cmd/Ctrl + N', action: 'New project' },
|
||||
{ keys: 'Cmd/Ctrl + ,', action: 'Open settings' },
|
||||
{ keys: 'Cmd/Ctrl + =', action: 'Zoom in' },
|
||||
{ keys: 'Cmd/Ctrl + -', action: 'Zoom out' },
|
||||
{ keys: 'Cmd/Ctrl + 0', action: 'Reset zoom' }
|
||||
]
|
||||
|
||||
export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
|
||||
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 className="text-sm font-semibold">Settings</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Theme
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(t.value)}
|
||||
className={clsx(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-sm font-medium',
|
||||
theme === t.value
|
||||
? 'border-neutral-900 bg-neutral-900 text-white dark:border-neutral-100 dark:bg-neutral-100 dark:text-neutral-900'
|
||||
: 'border-neutral-300 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Zoom
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.out()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Minus size={14} /> Out
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.in()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Plus size={14} /> In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.reset()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<RotateCcw size={14} /> Reset
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Keyboard shortcuts
|
||||
</h3>
|
||||
<dl className="flex flex-col gap-1 text-sm">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<div key={s.keys} className="flex items-center justify-between">
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">{s.action}</dt>
|
||||
<dd className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">
|
||||
{s.keys}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTerminalStore } from '../../stores/terminalStore'
|
||||
|
||||
export function TerminalPanel(): React.JSX.Element | null {
|
||||
const isPanelOpen = useTerminalStore((s) => s.isPanelOpen)
|
||||
const activeOperationId = useTerminalStore((s) => s.activeOperationId)
|
||||
const operation = useTerminalStore((s) =>
|
||||
s.activeOperationId ? s.operations[s.activeOperationId] : null
|
||||
)
|
||||
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
||||
}, [operation?.lines.length])
|
||||
|
||||
if (!isPanelOpen || !activeOperationId || !operation) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-64 flex-shrink-0 flex-col border-t border-cyan-500/20 bg-neutral-950 shadow-[0_-16px_40px_rgba(15,23,42,0.2)] dark:border-cyan-400/20">
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/[0.03] px-3 py-1.5">
|
||||
<span className="text-xs font-medium text-neutral-300">{operation.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen(false)}
|
||||
className="rounded p-1 text-neutral-400 transition hover:bg-white/10 hover:text-neutral-200"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto px-3 py-2 font-mono text-xs text-neutral-200"
|
||||
>
|
||||
<pre className="whitespace-pre-wrap">{operation.lines.join('')}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react'
|
||||
import { CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useToastStore, type Toast } from '../../stores/toastStore'
|
||||
|
||||
const AUTO_DISMISS_MS = 4000
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }): React.JSX.Element {
|
||||
const removeToast = useToastStore((s) => s.removeToast)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => removeToast(toast.id), AUTO_DISMISS_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toast.id, removeToast])
|
||||
|
||||
const isSuccess = toast.variant === 'success'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg backdrop-blur',
|
||||
isSuccess
|
||||
? 'border-emerald-200 bg-emerald-50/95 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/95 dark:text-emerald-300'
|
||||
: 'border-red-200 bg-red-50/95 text-red-800 dark:border-red-900 dark:bg-red-950/95 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{isSuccess ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||
{toast.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Toaster(): React.JSX.Element {
|
||||
const toasts = useToastStore((s) => s.toasts)
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem toast={toast} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
|
||||
// Applies the resolved theme (light/dark/system) to <html class="dark">,
|
||||
// which is what the `@custom-variant dark` rule in main.css keys off. Must
|
||||
// run somewhere mounted once near the app root.
|
||||
export function useAppliedTheme(): void {
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const apply = (): void => {
|
||||
const isDark = theme === 'dark' || (theme === 'system' && media.matches)
|
||||
root.classList.toggle('dark', isDark)
|
||||
}
|
||||
|
||||
apply()
|
||||
|
||||
if (theme === 'system') {
|
||||
media.addEventListener('change', apply)
|
||||
return () => media.removeEventListener('change', apply)
|
||||
}
|
||||
return undefined
|
||||
}, [theme])
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult
|
||||
} from '@tanstack/react-query'
|
||||
import type { AuroraProjectDetail, AuroraProjectSummary, EnvironmentUpdate } from '@shared/types'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
|
||||
const PROJECTS_KEY = ['projects'] as const
|
||||
const projectDetailKey = (name: string): readonly [string, string] => ['project', name] as const
|
||||
|
||||
export function useProjects(): UseQueryResult<AuroraProjectSummary[], Error> {
|
||||
return useQuery({
|
||||
queryKey: PROJECTS_KEY,
|
||||
queryFn: () => window.api.projects.list(),
|
||||
refetchInterval: 5000
|
||||
})
|
||||
}
|
||||
|
||||
export function useProjectDetail(name: string | null): UseQueryResult<AuroraProjectDetail, Error> {
|
||||
return useQuery({
|
||||
queryKey: name ? projectDetailKey(name) : ['project', 'none'],
|
||||
queryFn: () => window.api.projects.describe(name!),
|
||||
enabled: name !== null,
|
||||
retry: false,
|
||||
refetchInterval: (query) => query.state.status === 'error' ? false : 5000
|
||||
})
|
||||
}
|
||||
|
||||
// Runs a streamed Aurora command (start/stop/restart) for a project. Generates
|
||||
// the operationId here so the terminal panel and status bar can start
|
||||
// tracking it before the IPC call even resolves.
|
||||
function useProjectAction(
|
||||
verb: string,
|
||||
action: (operationId: string, name: string) => Promise<void>
|
||||
): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const operationId = crypto.randomUUID()
|
||||
const label = `${verb} ${name}`
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
await action(operationId, name)
|
||||
},
|
||||
onSettled: (_data, _error, name) => {
|
||||
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartProject(): UseMutationResult<void, Error, string> {
|
||||
return useProjectAction('Start', (operationId, name) =>
|
||||
window.api.projects.start(operationId, name)
|
||||
)
|
||||
}
|
||||
|
||||
export function useStopProject(): UseMutationResult<void, Error, string> {
|
||||
return useProjectAction('Stop', (operationId, name) =>
|
||||
window.api.projects.stop(operationId, name)
|
||||
)
|
||||
}
|
||||
|
||||
export function useRestartProject(): UseMutationResult<void, Error, string> {
|
||||
return useProjectAction('Restart', (operationId, name) =>
|
||||
window.api.projects.restart(operationId, name)
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeleteProject(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ name: string; approot: string; deleteFiles: boolean }
|
||||
> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, approot, deleteFiles }) => {
|
||||
const operationId = crypto.randomUUID()
|
||||
const label = `Delete ${name}`
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
await window.api.projects.delete(operationId, name, approot, deleteFiles)
|
||||
},
|
||||
onSettled: (_data, _error, { name }) => {
|
||||
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only runs `Aurora config` — the caller is expected to follow a successful
|
||||
// call with useRestartProject() if the project is currently running, same
|
||||
// split as useCreateProject's configure/start pair.
|
||||
export function useUpdateEnvironment(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ name: string; approot: string; updates: EnvironmentUpdate }
|
||||
> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, approot, updates }) => {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, `Update ${name} environment`)
|
||||
useStatusStore.getState().begin(operationId, `Update ${name} environment`)
|
||||
await window.api.projects.updateEnvironment(operationId, name, approot, updates)
|
||||
},
|
||||
onSettled: (_data, _error, { name }) => {
|
||||
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AuroraStackOptions } from '@shared/types'
|
||||
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
|
||||
export interface CreateProjectInput {
|
||||
directory: string
|
||||
projectName: string
|
||||
projectType: string
|
||||
docroot: string
|
||||
stack?: Partial<AuroraStackOptions>
|
||||
}
|
||||
|
||||
export interface WordpressSetupInput {
|
||||
directory: string
|
||||
siteUrl: string
|
||||
title: string
|
||||
adminUser: string
|
||||
adminPassword: string
|
||||
adminEmail: string
|
||||
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||
}
|
||||
|
||||
export interface DrupalSetupInput {
|
||||
directory: string
|
||||
siteName: string
|
||||
adminUser: string
|
||||
adminPassword: string
|
||||
adminEmail: string
|
||||
profile: string
|
||||
}
|
||||
|
||||
function beginOperation(label: string): string {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
return operationId
|
||||
}
|
||||
|
||||
// Only runs `Aurora config`, not `Aurora start` — the caller is expected to
|
||||
// follow a successful creation with the existing useStartProject() mutation,
|
||||
// reusing its own tracked operation/toast lifecycle rather than needing this
|
||||
// one to juggle two unrelated command phases under a single operationId.
|
||||
export function useCreateProject(): UseMutationResult<void, Error, CreateProjectInput> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory, projectName, projectType, docroot, stack }: CreateProjectInput) => {
|
||||
const operationId = beginOperation(`Create project ${projectName}`)
|
||||
await window.api.create.configure(operationId, directory, projectName, projectType, docroot, stack)
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
})
|
||||
}
|
||||
|
||||
// `Aurora config --project-type=wordpress` only scaffolds the wp-config.php
|
||||
// bridge, not WordPress core itself — see create.ts in the main process for
|
||||
// why this and useSetupWordpress are separate tracked operations run after
|
||||
// the project has been started.
|
||||
export function useDownloadWordpress(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ directory: string; locale: string }
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory, locale }) => {
|
||||
const operationId = beginOperation('Download WordPress core')
|
||||
await window.api.create.downloadWordpress(operationId, directory, locale)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSetupInput> {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
directory,
|
||||
siteUrl,
|
||||
title,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
multisite
|
||||
}) => {
|
||||
const operationId = beginOperation('Install WordPress')
|
||||
await window.api.create.setupWordpress(
|
||||
operationId,
|
||||
directory,
|
||||
siteUrl,
|
||||
title,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
multisite
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// `Aurora config --project-type=drupal*` only scaffolds Aurora's settings.php
|
||||
// bridge, not Drupal core itself — see create.ts in the main process for why
|
||||
// this, useRequireDrush, and useSetupDrupal are separate tracked operations
|
||||
// run after the project has been started.
|
||||
export function useDownloadDrupal(): UseMutationResult<void, Error, { directory: string }> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory }) => {
|
||||
const operationId = beginOperation('Download Drupal core')
|
||||
await window.api.create.downloadDrupal(operationId, directory)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useRequireDrush(): UseMutationResult<void, Error, { directory: string }> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory }) => {
|
||||
const operationId = beginOperation('Add Drush')
|
||||
await window.api.create.requireDrush(operationId, directory)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetupDrupal(): UseMutationResult<void, Error, DrupalSetupInput> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory, siteName, adminUser, adminPassword, adminEmail, profile }) => {
|
||||
const operationId = beginOperation('Install Drupal')
|
||||
await window.api.create.setupDrupal(
|
||||
operationId,
|
||||
directory,
|
||||
siteName,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
profile
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult
|
||||
} from '@tanstack/react-query'
|
||||
import type { AuroraSnapshot } from '@shared/types'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
|
||||
const snapshotsKey = (name: string): readonly [string, string] => ['snapshots', name] as const
|
||||
|
||||
export function useSnapshots(name: string, approot: string): UseQueryResult<AuroraSnapshot[], Error> {
|
||||
return useQuery({
|
||||
queryKey: snapshotsKey(name),
|
||||
queryFn: () => window.api.database.listSnapshots(name, approot)
|
||||
})
|
||||
}
|
||||
|
||||
// Same tracked-operation pattern as useProjectAction in useAurora.ts: generate
|
||||
// an operationId up front so the terminal panel/status bar pick it up before
|
||||
// the (potentially slow) Aurora command resolves.
|
||||
function beginOperation(label: string): string {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
return operationId
|
||||
}
|
||||
|
||||
export function useCreateSnapshot(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<void, Error, string | undefined> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (snapshotName?: string) => {
|
||||
const operationId = beginOperation(`Create snapshot for ${name}`)
|
||||
await window.api.database.createSnapshot(operationId, approot, snapshotName)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
|
||||
})
|
||||
}
|
||||
|
||||
export function useRestoreSnapshot(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (snapshotName: string) => {
|
||||
const operationId = beginOperation(`Restore snapshot ${snapshotName}`)
|
||||
await window.api.database.restoreSnapshot(operationId, approot, snapshotName)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteSnapshot(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (snapshotName: string) => {
|
||||
const operationId = beginOperation(`Delete snapshot ${snapshotName}`)
|
||||
await window.api.database.deleteSnapshot(operationId, approot, snapshotName)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves to false if the user cancels the file picker, true if the import ran.
|
||||
export function useImportDatabase(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<boolean, Error, void> {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const filePath = await window.api.database.pickImportFile()
|
||||
if (!filePath) return false
|
||||
const operationId = beginOperation(`Import database into ${name}`)
|
||||
await window.api.database.importFile(operationId, approot, filePath)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves to false if the user cancels the save dialog, true if the export ran.
|
||||
export function useExportDatabase(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<boolean, Error, void> {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const filePath = await window.api.database.pickExportPath(`${name}.sql.gz`)
|
||||
if (!filePath) return false
|
||||
const operationId = beginOperation(`Export database from ${name}`)
|
||||
await window.api.database.exportFile(operationId, approot, filePath)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ShortcutHandlers {
|
||||
onNewProject: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
// Global keyboard shortcuts: Cmd/Ctrl+N (new project), Cmd/Ctrl+, (settings),
|
||||
// Cmd/Ctrl +/-/0 (zoom in/out/reset). Mount once near the app root.
|
||||
export function useKeyboardShortcuts({ onNewProject, onOpenSettings }: ShortcutHandlers): void {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'n':
|
||||
e.preventDefault()
|
||||
onNewProject()
|
||||
break
|
||||
case ',':
|
||||
e.preventDefault()
|
||||
onOpenSettings()
|
||||
break
|
||||
case '=':
|
||||
case '+':
|
||||
e.preventDefault()
|
||||
window.api.zoom.in()
|
||||
break
|
||||
case '-':
|
||||
e.preventDefault()
|
||||
window.api.zoom.out()
|
||||
break
|
||||
case '0':
|
||||
e.preventDefault()
|
||||
window.api.zoom.reset()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onNewProject, onOpenSettings])
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface LogLine {
|
||||
stream: 'stdout' | 'stderr'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface UseLogStreamResult {
|
||||
lines: LogLine[]
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
// Owns the lifecycle of a single `Aurora logs -f` subprocess for one
|
||||
// (name, service) pair. Callers must remount this (e.g. `key={service}`)
|
||||
// when the service changes — state resets via fresh useState initializers
|
||||
// on mount rather than manual resets inside the effect, since React's
|
||||
// hooks lint flags synchronous setState-to-reset calls in an effect body.
|
||||
// Always stops the subprocess on unmount so a closed log viewer doesn't
|
||||
// leave an orphaned `Aurora logs -f` process running.
|
||||
export function useLogStream(name: string, service: string): UseLogStreamResult {
|
||||
const [lines, setLines] = useState<LogLine[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(true)
|
||||
// Data arrives as arbitrary byte chunks, not newline-delimited — a chunk
|
||||
// can span partial lines or bundle many lines together. Buffer per stream
|
||||
// so filtering/display operates on real lines instead of raw chunks.
|
||||
const buffers = useRef({ stdout: '', stderr: '' })
|
||||
|
||||
useEffect(() => {
|
||||
const operationId = crypto.randomUUID()
|
||||
buffers.current = { stdout: '', stderr: '' }
|
||||
|
||||
const unsubData = window.api.logs.onData((event) => {
|
||||
if (event.operationId !== operationId) return
|
||||
const combined = buffers.current[event.stream] + event.chunk
|
||||
const parts = combined.split('\n')
|
||||
buffers.current[event.stream] = parts.pop() ?? ''
|
||||
if (parts.length === 0) return
|
||||
setLines((prev) => [...prev, ...parts.map((text) => ({ stream: event.stream, text }))])
|
||||
})
|
||||
const unsubExit = window.api.logs.onExit((event) => {
|
||||
if (event.operationId !== operationId) return
|
||||
setIsStreaming(false)
|
||||
})
|
||||
|
||||
window.api.logs.start(operationId, name, service)
|
||||
|
||||
return () => {
|
||||
unsubData()
|
||||
unsubExit()
|
||||
window.api.terminal.cancel(operationId)
|
||||
}
|
||||
}, [name, service])
|
||||
|
||||
return { lines, isStreaming }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMutation, useQuery, useQueryClient, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query'
|
||||
import type { AuroraInstalledModule, AuroraModuleManifest } from '@shared/types'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
|
||||
const installedModulesKey = (name: string): readonly [string, string, string] => ['modules', 'installed', name] as const
|
||||
|
||||
export function useModuleRegistry(): UseQueryResult<AuroraModuleManifest[], Error> {
|
||||
return useQuery({ queryKey: ['modules', 'registry'], queryFn: () => window.api.modules.listRegistry(), staleTime: Infinity })
|
||||
}
|
||||
|
||||
export function useInstalledModules(name: string): UseQueryResult<AuroraInstalledModule[], Error> {
|
||||
return useQuery({ queryKey: installedModulesKey(name), queryFn: () => window.api.modules.listInstalled(name) })
|
||||
}
|
||||
|
||||
function beginOperation(label: string): string {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
return operationId
|
||||
}
|
||||
|
||||
type InstallInput = { moduleId: string; settings?: Record<string, string | number | boolean> }
|
||||
|
||||
export function useInstallModule(name: string): UseMutationResult<void, Error, InstallInput> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ moduleId, settings = {} }) => {
|
||||
const operationId = beginOperation(`Install ${moduleId}`)
|
||||
await window.api.modules.install(operationId, name, moduleId, settings)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: installedModulesKey(name) })
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveModule(name: string): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (moduleId) => {
|
||||
const operationId = beginOperation(`Remove ${moduleId}`)
|
||||
await window.api.modules.remove(operationId, name, moduleId)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: installedModulesKey(name) })
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
// Wires the main process's terminal:data / terminal:exit IPC events into the
|
||||
// terminal/status/toast stores. Mount once near the app root — every
|
||||
// long-running Aurora command (start/stop/restart, and later snapshots/addons)
|
||||
// flows through this same event stream regardless of which mutation kicked
|
||||
// it off, so this is the single place that owns "what happens when a
|
||||
// command finishes."
|
||||
export function useTerminalEvents(): void {
|
||||
useEffect(() => {
|
||||
const unsubData = window.api.terminal.onData(({ operationId, chunk }) => {
|
||||
useTerminalStore.getState().appendChunk(operationId, chunk)
|
||||
})
|
||||
|
||||
const unsubExit = window.api.terminal.onExit(({ operationId, exitCode, cancelled }) => {
|
||||
const op = useTerminalStore.getState().operations[operationId]
|
||||
const status = cancelled ? 'cancelled' : exitCode === 0 ? 'success' : 'error'
|
||||
useTerminalStore.getState().finishOperation(operationId, status, exitCode)
|
||||
|
||||
if (useStatusStore.getState().operationId === operationId) {
|
||||
useStatusStore.getState().end()
|
||||
}
|
||||
|
||||
if (op) {
|
||||
if (status === 'success') {
|
||||
useToastStore.getState().addToast('success', `${op.label} succeeded`)
|
||||
} else if (status === 'error') {
|
||||
useToastStore.getState().addToast('error', `${op.label} failed`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubData()
|
||||
unsubExit()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface AppState {
|
||||
selectedProjectName: string | null
|
||||
selectProject: (name: string | null) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
selectedProjectName: null,
|
||||
selectProject: (name): void => set({ selectedProjectName: name })
|
||||
}))
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface StatusState {
|
||||
operationId: string | null
|
||||
label: string | null
|
||||
begin: (operationId: string, label: string) => void
|
||||
end: () => void
|
||||
}
|
||||
|
||||
export const useStatusStore = create<StatusState>((set) => ({
|
||||
operationId: null,
|
||||
label: null,
|
||||
begin: (operationId, label): void => set({ operationId, label }),
|
||||
end: (): void => set({ operationId: null, label: null })
|
||||
}))
|
||||
@@ -0,0 +1,65 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type OperationStatus = 'running' | 'success' | 'error' | 'cancelled'
|
||||
|
||||
export interface TerminalOperation {
|
||||
id: string
|
||||
label: string
|
||||
lines: string[]
|
||||
status: OperationStatus
|
||||
exitCode: number | null
|
||||
}
|
||||
|
||||
interface TerminalState {
|
||||
operations: Record<string, TerminalOperation>
|
||||
activeOperationId: string | null
|
||||
isPanelOpen: boolean
|
||||
startOperation: (id: string, label: string) => void
|
||||
appendChunk: (id: string, chunk: string) => void
|
||||
finishOperation: (
|
||||
id: string,
|
||||
status: Exclude<OperationStatus, 'running'>,
|
||||
exitCode: number | null
|
||||
) => void
|
||||
setActiveOperation: (id: string | null) => void
|
||||
setPanelOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useTerminalStore = create<TerminalState>((set) => ({
|
||||
operations: {},
|
||||
activeOperationId: null,
|
||||
isPanelOpen: false,
|
||||
startOperation: (id, label): void =>
|
||||
set((state) => ({
|
||||
operations: {
|
||||
...state.operations,
|
||||
[id]: { id, label, lines: [], status: 'running', exitCode: null }
|
||||
},
|
||||
activeOperationId: id,
|
||||
isPanelOpen: true
|
||||
})),
|
||||
appendChunk: (id, chunk): void =>
|
||||
set((state) => {
|
||||
const op = state.operations[id]
|
||||
if (!op) return state
|
||||
return {
|
||||
operations: {
|
||||
...state.operations,
|
||||
[id]: { ...op, lines: [...op.lines, chunk] }
|
||||
}
|
||||
}
|
||||
}),
|
||||
finishOperation: (id, status, exitCode): void =>
|
||||
set((state) => {
|
||||
const op = state.operations[id]
|
||||
if (!op) return state
|
||||
return {
|
||||
operations: {
|
||||
...state.operations,
|
||||
[id]: { ...op, status, exitCode }
|
||||
}
|
||||
}
|
||||
}),
|
||||
setActiveOperation: (id): void => set({ activeOperationId: id }),
|
||||
setPanelOpen: (open): void => set({ isPanelOpen: open })
|
||||
}))
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist<ThemeState>(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
setTheme: (theme) => {
|
||||
set({ theme })
|
||||
}
|
||||
}),
|
||||
{ name: 'aurora-dockside-theme' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type ToastVariant = 'success' | 'error'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
variant: ToastVariant
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[]
|
||||
addToast: (variant: ToastVariant, message: string) => void
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (variant, message): void =>
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { id: crypto.randomUUID(), variant, message }]
|
||||
})),
|
||||
removeToast: (id): void => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }))
|
||||
}))
|
||||
@@ -0,0 +1,197 @@
|
||||
export type ProjectStatus = 'running' | 'stopped' | 'paused' | 'starting' | 'stopping' | string
|
||||
|
||||
export interface AuroraProjectSummary {
|
||||
name: string
|
||||
status: ProjectStatus
|
||||
status_desc: string
|
||||
type: string
|
||||
approot: string
|
||||
shortroot: string
|
||||
docroot: string
|
||||
primary_url: string
|
||||
httpurl: string
|
||||
httpsurl: string
|
||||
mutagen_enabled: boolean
|
||||
mutagen_status?: string
|
||||
}
|
||||
|
||||
export interface AuroraServiceHostPortMapping {
|
||||
exposed_port: string
|
||||
host_port: string
|
||||
}
|
||||
|
||||
export interface AuroraService {
|
||||
short_name: string
|
||||
full_name: string
|
||||
status: string
|
||||
image: string
|
||||
exposed_ports: string
|
||||
host_ports: string
|
||||
host_ports_mapping: AuroraServiceHostPortMapping[]
|
||||
http_url?: string
|
||||
https_url?: string
|
||||
host_http_url?: string
|
||||
host_https_url?: string
|
||||
virtual_host?: string
|
||||
'describe-info'?: string
|
||||
'describe-url-port'?: string
|
||||
}
|
||||
|
||||
export interface AuroraDbInfo {
|
||||
database_type: string
|
||||
database_version: string
|
||||
dbPort: string
|
||||
dbname: string
|
||||
host: string
|
||||
password: string
|
||||
published_port: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface AuroraProjectDetail extends AuroraProjectSummary {
|
||||
database_type: string
|
||||
database_version: string
|
||||
dbinfo: AuroraDbInfo
|
||||
hostname: string
|
||||
hostnames: string[]
|
||||
httpURLs: string[]
|
||||
httpsURLs: string[]
|
||||
urls: string[]
|
||||
php_version?: string
|
||||
nodejs_version?: string
|
||||
webserver_type?: string
|
||||
performance_mode?: string
|
||||
router: string
|
||||
router_status?: string
|
||||
certificate_status?: 'generated' | 'missing'
|
||||
ca_trust_status?: 'trusted' | 'not-trusted' | 'unknown'
|
||||
firefox_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
|
||||
chromium_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
|
||||
wordpress_multisite?: 'none' | 'subdirectory' | 'subdomain'
|
||||
wordpress_network_admin_url?: string
|
||||
adminer_url?: string
|
||||
services: Record<string, AuroraService>
|
||||
xdebug_enabled: boolean
|
||||
}
|
||||
|
||||
export interface AuroraSiteCredentials {
|
||||
platform: string
|
||||
adminUrl: string
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface AuroraStackOptions {
|
||||
phpVersion: string
|
||||
nodeVersion: string
|
||||
database: 'mariadb' | 'postgres'
|
||||
databaseVersion: string
|
||||
adminer: boolean
|
||||
redis: boolean
|
||||
mailpit: boolean
|
||||
xdebug: boolean
|
||||
}
|
||||
|
||||
export interface EnvironmentUpdate {
|
||||
phpVersion?: string
|
||||
nodeVersion?: string
|
||||
webserverType?: string
|
||||
database?: string
|
||||
xdebugEnabled?: boolean
|
||||
primaryProtocol?: 'http' | 'https'
|
||||
}
|
||||
|
||||
export interface AuroraSnapshot {
|
||||
Name: string
|
||||
Created: string
|
||||
}
|
||||
|
||||
|
||||
export type AuroraModuleCategory = 'application' | 'service' | 'tool'
|
||||
export type AuroraModuleSettingType = 'boolean' | 'select' | 'text' | 'number'
|
||||
|
||||
export interface AuroraModuleSetting {
|
||||
id: string
|
||||
label: string
|
||||
type: AuroraModuleSettingType
|
||||
default: string | number | boolean
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
export interface AuroraModuleManifest {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
category: AuroraModuleCategory
|
||||
description: string
|
||||
icon?: string
|
||||
dependencies: string[]
|
||||
conflicts: string[]
|
||||
defaults?: { docroot?: string }
|
||||
settings: AuroraModuleSetting[]
|
||||
compose?: {
|
||||
service: string
|
||||
image: string
|
||||
ports?: number[]
|
||||
dependsOn?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuroraInstalledModule {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
category: AuroraModuleCategory
|
||||
}
|
||||
|
||||
export interface AuroraAddonRegistryEntry {
|
||||
title: string
|
||||
github_url: string
|
||||
description: string
|
||||
user: string
|
||||
repo: string
|
||||
repo_id: number
|
||||
default_branch: string
|
||||
tag_name: string | null
|
||||
engine_version_constraint: string
|
||||
dependencies: string[] | null
|
||||
type: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
workflow_status: string
|
||||
stars: number
|
||||
}
|
||||
|
||||
export interface AuroraInstalledAddon {
|
||||
Name: string
|
||||
Repository: string
|
||||
Version: string
|
||||
Dependencies: string[] | null
|
||||
InstallDate: string
|
||||
ProjectFiles: string[] | null
|
||||
GlobalFiles: string[] | null
|
||||
RemovalActions: string[] | null
|
||||
}
|
||||
|
||||
export interface TerminalDataEvent {
|
||||
operationId: string
|
||||
stream: 'stdout' | 'stderr'
|
||||
chunk: string
|
||||
}
|
||||
|
||||
export interface TerminalExitEvent {
|
||||
operationId: string
|
||||
exitCode: number | null
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export interface LogDataEvent {
|
||||
operationId: string
|
||||
stream: 'stdout' | 'stderr'
|
||||
chunk: string
|
||||
}
|
||||
|
||||
export interface LogExitEvent {
|
||||
operationId: string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
// jsdom doesn't implement matchMedia; useAppliedTheme() relies on it to
|
||||
// resolve the "system" theme.
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
}) as MediaQueryList
|
||||
}
|
||||
Reference in New Issue
Block a user