feat: add external module architecture
This commit is contained in:
Binary file not shown.
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aurora-dockside",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aurora-dockside",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aurora-dockside",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"version": "2.0.0-alpha.24",
|
||||
"description": "A modern, modular Docker development platform for building, running, and managing web applications locally.",
|
||||
"main": "./out/main/index.js",
|
||||
"homepage": "https://github.com/KrisAsvestas/auroradockside",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Aurora WordPress module
|
||||
|
||||
Local trusted extension package for Aurora Dockside 2.0.0-alpha.24. Install the unpacked package directory from **New Project → Install local module**. Its manifest contributes creation fields; its main-process lifecycle uses only the versioned Core context supplied by module API 1.0.0.
|
||||
|
||||
Uninstalling this package removes only the copy in Aurora's user-data `modules/wordpress` directory. It never removes project files or databases.
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict'
|
||||
|
||||
function safeName(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
|
||||
}
|
||||
|
||||
function wpArgs(context, network) {
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
||||
const gid = typeof process.getgid === 'function' ? process.getgid() : undefined
|
||||
return ['run', '--rm', ...(uid === undefined ? [] : ['--user', `${uid}:${gid}`]), '-e', 'HOME=/tmp', '-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache', ...(network ? ['--network', `aurora-${safeName(context.projectName)}_default`] : []), '-v', `${context.directory}:/app`, '-w', '/app', '--entrypoint', 'php', 'wordpress:cli', '-d', 'memory_limit=512M', '/usr/local/bin/wp']
|
||||
}
|
||||
|
||||
exports.projectCreate = async function projectCreate(context) {
|
||||
const s = context.settings
|
||||
const base = wpArgs(context, false)
|
||||
const networkBase = wpArgs(context, true)
|
||||
const siteUrl = context.urls.https
|
||||
await context.ensureRouter()
|
||||
await context.run('start', 'docker', ['compose', '-f', `${context.directory}/.aurora/compose.yaml`, 'up', '-d', '--build', '--remove-orphans'])
|
||||
await context.run('download', 'docker', [...base, 'core', 'download', `--locale=${s.locale || 'en_US'}`, '--force'])
|
||||
await context.run('config', 'docker', [...networkBase, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force'])
|
||||
await context.run('install', 'docker', [...networkBase, 'core', 'install', `--url=${siteUrl}`, `--title=${s.title}`, `--admin_user=${s.admin_user}`, `--admin_password=${s.admin_password}`, `--admin_email=${s.admin_email}`, '--skip-email'])
|
||||
await context.run('verify-install', 'docker', [...networkBase, 'core', 'is-installed'])
|
||||
if (s.multisite !== 'none') {
|
||||
await context.run('multisite-convert', 'docker', [...networkBase, 'core', 'multisite-convert', `--title=${s.title}`, ...(s.multisite === 'subdomain' ? ['--subdomains'] : [])])
|
||||
await context.run('verify-network', 'docker', [...networkBase, 'core', 'is-installed', '--network'])
|
||||
await context.run('verify-network-db', 'docker', [...networkBase, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names'])
|
||||
}
|
||||
await context.setProjectMetadata({ multisite: String(s.multisite), routingWildcard: s.multisite === 'subdomain' })
|
||||
await context.run('permalinks', 'docker', [...networkBase, 'rewrite', 'structure', '/%postname%/', '--hard'])
|
||||
await context.run('debug', 'docker', [...networkBase, 'config', 'set', 'WP_DEBUG', String(Boolean(s.wp_debug)), '--raw'])
|
||||
await context.run('environment', 'docker', [...networkBase, 'config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'])
|
||||
await context.saveCredentials({ platform: context.moduleId, adminUrl: `${siteUrl}/wp-admin/`, username: String(s.admin_user), password: String(s.admin_password), email: String(s.admin_email) })
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "wordpress",
|
||||
"name": "WordPress",
|
||||
"version": "1.0.0",
|
||||
"category": "application",
|
||||
"description": "WordPress CMS provisioning and WP-CLI tooling.",
|
||||
"main": "main/index.cjs",
|
||||
"aurora": { "core": "2.0.0-alpha.24", "moduleApi": "1.0.0" },
|
||||
"dependencies": [],
|
||||
"conflicts": [],
|
||||
"defaults": { "docroot": "" },
|
||||
"settings": [],
|
||||
"creation": {
|
||||
"databases": ["mariadb"],
|
||||
"setup": [
|
||||
{ "id": "locale", "label": "Locale", "type": "select", "default": "en_US", "options": ["en_US", "en_GB", "de_DE", "fr_FR", "es_ES"] },
|
||||
{ "id": "title", "label": "Site title", "type": "text", "default": "Aurora Site" },
|
||||
{ "id": "admin_user", "label": "Admin username", "type": "text", "default": "admin" },
|
||||
{ "id": "admin_password", "label": "Admin password", "type": "text", "default": "" },
|
||||
{ "id": "admin_email", "label": "Admin email", "type": "text", "default": "[email protected]" },
|
||||
{ "id": "multisite", "label": "Site mode", "type": "select", "default": "none", "options": ["none", "subdirectory", "subdomain"] },
|
||||
{ "id": "wp_debug", "label": "Enable WP_DEBUG", "type": "boolean", "default": true }
|
||||
]
|
||||
},
|
||||
"project": { "adminPath": "/wp-admin/" }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "@aurora/module-wordpress",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "WordPress application module for Aurora Dockside",
|
||||
"files": ["manifest.json", "main", "README.md"]
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"id":"drupal","name":"Drupal","version":"1.0.0","category":"application","description":"Drupal CMS using the recommended Composer project layout.","icon":"layers","dependencies":[],"conflicts":["wordpress","laravel"],"defaults":{"docroot":"web"},"settings":[]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"id":"laravel","name":"Laravel","version":"1.0.0","category":"application","description":"Laravel application with Composer and Vite-ready Node runtime.","icon":"code","dependencies":[],"conflicts":["wordpress","drupal"],"defaults":{"docroot":"public"},
|
||||
"settings":[{"id":"starter","label":"Starter kit","type":"select","default":"none","options":["none","breeze"]}]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"id":"wordpress","name":"WordPress","version":"1.0.0","category":"application","description":"WordPress CMS with WP-CLI tooling.","icon":"globe","dependencies":[],"conflicts":["laravel","drupal"],"defaults":{"docroot":""},
|
||||
"settings":[{"id":"wp_debug","label":"WP_DEBUG","type":"boolean","default":true},{"id":"multisite","label":"Enable Multisite","type":"boolean","default":false}]
|
||||
}
|
||||
+27
-20
@@ -4,7 +4,7 @@ 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'
|
||||
import { CORE_VERSION, MODULE_API_VERSION, getModuleManifest, getModuleRegistry } from './moduleRegistry'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
|
||||
@@ -23,6 +23,7 @@ type AuroraConfig = {
|
||||
moduleSettings?: Record<string, Record<string, string | number | boolean>>
|
||||
primaryProtocol?: 'http' | 'https'
|
||||
wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain'
|
||||
moduleMetadata?: Record<string, string | number | boolean>
|
||||
xdebug?: boolean
|
||||
}
|
||||
|
||||
@@ -87,7 +88,11 @@ 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 coreServices: Record<string, AuroraModuleManifest> = {
|
||||
redis: { id: 'redis', name: 'Redis', version: '1.0.0', category: 'service', description: '', dependencies: [], conflicts: [], settings: [], aurora: { core: CORE_VERSION, moduleApi: MODULE_API_VERSION }, compose: { service: 'redis', image: 'redis:8-alpine', ports: [6379] } },
|
||||
mailpit: { id: 'mailpit', name: 'Mailpit', version: '1.0.0', category: 'tool', description: '', dependencies: [], conflicts: [], settings: [], aurora: { core: CORE_VERSION, moduleApi: MODULE_API_VERSION }, compose: { service: 'mailpit', image: 'axllent/mailpit:latest', ports: [8025, 1025] } }
|
||||
}
|
||||
const manifests = await Promise.all(c.modules.filter((id) => id !== 'adminer').map((id) => coreServices[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:')
|
||||
@@ -124,16 +129,18 @@ server {
|
||||
|
||||
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 (!type) throw new Error('Install and select an application module before creating a project')
|
||||
const application = await getModuleManifest(type)
|
||||
if (application.category !== 'application') throw new Error(`Module '${type}' cannot create application projects`)
|
||||
const normalizedType = application.id
|
||||
const defaultDocroot = docroot || application.defaults?.docroot || ''
|
||||
const modules = [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)
|
||||
@@ -143,10 +150,11 @@ 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[] = []
|
||||
const reg = await loadRegistry(); const out: AuroraProjectSummary[] = []; const availableModules = new Set((await getModuleRegistry()).map((module) => module.id))
|
||||
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 })
|
||||
const moduleAvailable = availableModules.has(c.type)
|
||||
out.push({ name, status: running?'running':'stopped', status_desc: moduleAvailable ? (running?'Running':'Stopped') : `Missing application module: ${c.type}`, type:c.type, approot:root, shortroot:root, docroot:c.docroot, primary_url:primary, httpurl:urls.http, httpsurl:urls.https, mutagen_enabled:false, module_available: moduleAvailable, missing_module_id: moduleAvailable ? undefined : c.type })
|
||||
} catch { /* stale registry entry */ }
|
||||
} return out
|
||||
}
|
||||
@@ -154,7 +162,9 @@ 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 }
|
||||
const legacyMultisite = c.wordpressMultisite ?? 'none'
|
||||
const moduleMultisite = String(c.moduleMetadata?.multisite ?? legacyMultisite) as 'none' | 'subdirectory' | 'subdomain'
|
||||
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:moduleMultisite,wordpress_network_admin_url:moduleMultisite!=='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)
|
||||
@@ -174,6 +184,12 @@ export async function setWordpressMultisite(root: string, mode: 'none' | 'subdir
|
||||
await writeConfig(root, config)
|
||||
}
|
||||
|
||||
export async function setProjectModuleMetadata(root: string, metadata: Record<string, string | number | boolean>): Promise<void> {
|
||||
const config = await readConfig(root)
|
||||
config.moduleMetadata = { ...config.moduleMetadata, ...metadata }
|
||||
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 })
|
||||
@@ -240,17 +256,9 @@ export async function setModule(
|
||||
await writePhpDockerfile(root, config.xdebug === true)
|
||||
}
|
||||
|
||||
export async function scaffoldApplicationModule(name: string, moduleId: string): Promise<void> {
|
||||
const root = await getProjectRoot(name)
|
||||
export async function scaffoldApplicationModule(_name: string, moduleId: string): Promise<void> {
|
||||
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]}
|
||||
@@ -411,7 +419,7 @@ async function writeRouterConfig(): Promise<void> {
|
||||
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'
|
||||
const rule = config.moduleMetadata?.routingWildcard === true
|
||||
? `Host(\`${host}\`) || HostRegexp(\`^[a-z0-9-]+\\.${host.replace(/\./g, '\\.')}$\`)`
|
||||
: `Host(\`${host}\`)`
|
||||
routers.push(
|
||||
@@ -477,4 +485,3 @@ export async function powerOffProjects(): Promise<void> {
|
||||
} catch { /* stale project or Docker unavailable */ }
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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 {
|
||||
@@ -68,7 +67,6 @@ app.whenReady().then(() => {
|
||||
registerCreateIpc()
|
||||
registerWindowIpc()
|
||||
registerSecretsIpc()
|
||||
registerWordpressIpc()
|
||||
|
||||
createWindow()
|
||||
|
||||
|
||||
+6
-207
@@ -1,214 +1,13 @@
|
||||
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 { createProject } from '../auroraEngine'
|
||||
import { runModuleProjectCreate } from '../moduleRuntime'
|
||||
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]
|
||||
const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
|
||||
return result.canceled ? null : result.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 () => {})
|
||||
ipcMain.handle('create:configure', (_event, _id: string, directory: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>) => createProject(directory, name, type, docroot, stack))
|
||||
ipcMain.handle('create:runModuleProjectCreate', (event, id: string, moduleId: string, directory: string, name: string, settings: Record<string, string | number | boolean>) => runModuleProjectCreate(moduleId, id, directory, name, settings, event.sender))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { dialog, ipcMain } from 'electron'
|
||||
import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { installModulePackage, uninstallModulePackage } from '../moduleRegistry'
|
||||
|
||||
export function registerModulesIpc(): void {
|
||||
ipcMain.handle('modules:listRegistry', () => listModules())
|
||||
ipcMain.handle('modules:pickAndInstallPackage', async () => {
|
||||
const picked = await dialog.showOpenDialog({ properties: ['openDirectory'] })
|
||||
if (picked.canceled || !picked.filePaths[0]) return null
|
||||
return installModulePackage(picked.filePaths[0])
|
||||
})
|
||||
ipcMain.handle('modules:installPackage', (_event, source: string) => installModulePackage(source))
|
||||
ipcMain.handle('modules:uninstallPackage', (_event, id: string) => uninstallModulePackage(id))
|
||||
ipcMain.handle('modules:listInstalled', (_event, name: string) => listInstalledModules(name))
|
||||
ipcMain.handle(
|
||||
'modules:install',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, getProjectConfig, projectUrls, AURORA_ENV, trustAuroraCA } from '../auroraEngine'
|
||||
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, 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{
|
||||
@@ -29,16 +26,5 @@ export function registerProjectsIpc():void{
|
||||
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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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,36 @@
|
||||
import { mkdtemp, mkdir, symlink, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('electron', () => ({ app: { getPath: () => '/unused' } }))
|
||||
import { getModuleRegistry, installModulePackage, uninstallModulePackage, validateModuleManifest } from './moduleRegistry'
|
||||
|
||||
const roots: string[] = []
|
||||
async function temp(prefix: string): Promise<string> { const root = await mkdtemp(join(tmpdir(), prefix)); roots.push(root); return root }
|
||||
const manifest = { id: 'sample-app', name: 'Sample', version: '1.0.0', category: 'application', description: 'test', aurora: { core: '2.0.0-alpha.24', moduleApi: '1.0.0' }, dependencies: [], conflicts: [], settings: [] }
|
||||
async function packageDir(value = manifest): Promise<string> { const root = await temp('aurora-package-'); await writeFile(join(root, 'manifest.json'), JSON.stringify(value)); return root }
|
||||
|
||||
afterEach(async () => { const { rm } = await import('fs/promises'); await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) })
|
||||
|
||||
describe('external module registry', () => {
|
||||
it('starts empty', async () => expect(await getModuleRegistry(await temp('aurora-user-'))).toEqual([]))
|
||||
it('installs a valid local package and refreshes after install', async () => {
|
||||
const userData = await temp('aurora-user-'); const source = await packageDir()
|
||||
await installModulePackage(source, userData)
|
||||
expect((await getModuleRegistry(userData)).map((item) => item.id)).toEqual(['sample-app'])
|
||||
})
|
||||
it('rejects invalid and incompatible manifests', () => {
|
||||
expect(() => validateModuleManifest({ ...manifest, id: '../escape' })).toThrow(/module id/)
|
||||
expect(() => validateModuleManifest({ ...manifest, aurora: { core: '9.0.0', moduleApi: '1.0.0' } })).toThrow(/requires Aurora Core/)
|
||||
})
|
||||
it('rejects symbolic links in package paths', async () => {
|
||||
const userData = await temp('aurora-user-'); const source = await packageDir(); await mkdir(join(source, 'main')); await symlink('/tmp', join(source, 'main', 'escape'))
|
||||
await expect(installModulePackage(source, userData)).rejects.toThrow(/symbolic links/)
|
||||
})
|
||||
it('refreshes after uninstall without touching source', async () => {
|
||||
const userData = await temp('aurora-user-'); const source = await packageDir(); await installModulePackage(source, userData); await uninstallModulePackage('sample-app', userData)
|
||||
expect(await getModuleRegistry(userData)).toEqual([])
|
||||
expect(await import('fs/promises').then(({ stat }) => stat(join(source, 'manifest.json')))).toBeTruthy()
|
||||
})
|
||||
})
|
||||
+93
-12
@@ -1,24 +1,105 @@
|
||||
import { app } from 'electron'
|
||||
import { readFile, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { AuroraModuleManifest } from '../shared/types'
|
||||
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat } from 'fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'path'
|
||||
import type { AuroraModuleInstallResult, AuroraModuleManifest, AuroraModuleSetting } from '../shared/types'
|
||||
|
||||
export const CORE_VERSION = '2.0.0-alpha.24'
|
||||
export const MODULE_API_VERSION = '1.0.0'
|
||||
let cache: AuroraModuleManifest[] | null = null
|
||||
|
||||
function moduleDirectory(): string {
|
||||
return join(app.getAppPath(), 'resources', 'modules')
|
||||
export function moduleDirectory(userData = app.getPath('userData')): string { return join(userData, 'modules') }
|
||||
function validVersion(value: unknown): value is string { return typeof value === 'string' && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value) }
|
||||
function compatible(range: string, version: string): boolean {
|
||||
if (range === '*' || range === version) return true
|
||||
const major = version.split('.')[0]
|
||||
return range === `^${major}.0.0` || range.split(/\s+/).includes(version)
|
||||
}
|
||||
function validateSettings(value: unknown, field: string): asserts value is AuroraModuleSetting[] {
|
||||
if (!Array.isArray(value)) throw new Error(`${field} must be an array`)
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') throw new Error(`${field} entries must be objects`)
|
||||
const setting = item as Record<string, unknown>
|
||||
if (typeof setting.id !== 'string' || !/^[a-z][a-z0-9_-]*$/.test(setting.id)) throw new Error(`Invalid ${field} id`)
|
||||
if (typeof setting.label !== 'string' || !['boolean', 'select', 'text', 'number'].includes(String(setting.type))) throw new Error(`Invalid ${field} entry '${setting.id}'`)
|
||||
if (setting.type === 'select' && (!Array.isArray(setting.options) || !setting.options.every((x) => typeof x === 'string'))) throw new Error(`Select setting '${setting.id}' requires string options`)
|
||||
}
|
||||
}
|
||||
|
||||
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 function validateModuleManifest(value: unknown): AuroraModuleManifest {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Module manifest must be an object')
|
||||
const m = value as Record<string, unknown>
|
||||
if (typeof m.id !== 'string' || !/^[a-z][a-z0-9-]{1,63}$/.test(m.id)) throw new Error('Invalid module id')
|
||||
if (typeof m.name !== 'string' || !m.name.trim()) throw new Error('Invalid module name')
|
||||
if (!validVersion(m.version)) throw new Error('Invalid module version')
|
||||
if (!['application', 'service', 'tool'].includes(String(m.category))) throw new Error('Invalid module category')
|
||||
if (typeof m.description !== 'string') throw new Error('Invalid module description')
|
||||
for (const field of ['dependencies', 'conflicts'] as const) if (!Array.isArray(m[field]) || !(m[field] as unknown[]).every((x) => typeof x === 'string')) throw new Error(`${field} must be a string array`)
|
||||
validateSettings(m.settings, 'settings')
|
||||
const aurora = m.aurora as Record<string, unknown> | undefined
|
||||
if (!aurora || typeof aurora.core !== 'string' || typeof aurora.moduleApi !== 'string') throw new Error('Manifest must declare aurora.core and aurora.moduleApi')
|
||||
if (!compatible(aurora.core, CORE_VERSION)) throw new Error(`Module requires Aurora Core '${aurora.core}', running '${CORE_VERSION}'`)
|
||||
if (!compatible(aurora.moduleApi, MODULE_API_VERSION)) throw new Error(`Module API '${aurora.moduleApi}' is incompatible with '${MODULE_API_VERSION}'`)
|
||||
if (m.creation && typeof m.creation === 'object') validateSettings((m.creation as Record<string, unknown>).setup ?? [], 'creation.setup')
|
||||
return value as AuroraModuleManifest
|
||||
}
|
||||
|
||||
async function assertSafePackageTree(root: string): Promise<void> {
|
||||
const rootReal = await realpath(root)
|
||||
async function visit(directory: string): Promise<void> {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
const info = await lstat(path)
|
||||
if (info.isSymbolicLink()) throw new Error(`Module packages may not contain symbolic links: ${relative(root, path)}`)
|
||||
const resolved = await realpath(path)
|
||||
if (resolved !== rootReal && !resolved.startsWith(`${rootReal}${sep}`)) throw new Error('Unsafe module package path')
|
||||
if (entry.isDirectory()) await visit(path)
|
||||
}
|
||||
}
|
||||
await visit(root)
|
||||
}
|
||||
|
||||
export async function getModuleRegistry(userData?: string): Promise<AuroraModuleManifest[]> {
|
||||
if (!userData && cache) return cache
|
||||
const dir = moduleDirectory(userData)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const manifests: AuroraModuleManifest[] = []
|
||||
for (const entry of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isDirectory()) continue
|
||||
try { manifests.push(validateModuleManifest(JSON.parse(await readFile(join(dir, entry.name, 'manifest.json'), 'utf8')))) } catch { /* omit damaged installations */ }
|
||||
}
|
||||
if (!userData) cache = manifests
|
||||
return manifests
|
||||
}
|
||||
|
||||
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}'`)
|
||||
if (!module) throw new Error(`Required Aurora module '${id}' is not installed`)
|
||||
return module
|
||||
}
|
||||
export function invalidateModuleRegistry(): void { cache = null }
|
||||
|
||||
export async function installModulePackage(source: string, userData?: string): Promise<AuroraModuleInstallResult> {
|
||||
if (!isAbsolute(source)) throw new Error('Module package path must be absolute')
|
||||
if (!(await stat(source)).isDirectory()) throw new Error('Select an unpacked local module directory')
|
||||
await assertSafePackageTree(source)
|
||||
const manifest = validateModuleManifest(JSON.parse(await readFile(join(source, 'manifest.json'), 'utf8')))
|
||||
const modules = moduleDirectory(userData)
|
||||
await mkdir(modules, { recursive: true })
|
||||
const destination = resolve(modules, manifest.id)
|
||||
if (dirname(destination) !== resolve(modules) || basename(destination) !== manifest.id) throw new Error('Unsafe module destination')
|
||||
const staging = join(modules, `.${manifest.id}-${process.pid}-${Date.now()}`)
|
||||
await cp(source, staging, { recursive: true, errorOnExist: true })
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
await rename(staging, destination)
|
||||
invalidateModuleRegistry()
|
||||
return { manifest, installedPath: destination }
|
||||
}
|
||||
|
||||
export async function uninstallModulePackage(id: string, userData?: string): Promise<void> {
|
||||
if (!/^[a-z][a-z0-9-]{1,63}$/.test(id)) throw new Error('Invalid module id')
|
||||
const modules = resolve(moduleDirectory(userData))
|
||||
const destination = resolve(modules, id)
|
||||
if (dirname(destination) !== modules) throw new Error('Unsafe module destination')
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
invalidateModuleRegistry()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRequire } from 'module'
|
||||
import { dirname, join, resolve, sep } from 'path'
|
||||
import type { WebContents } from 'electron'
|
||||
import { getModuleManifest, moduleDirectory } from './moduleRegistry'
|
||||
import { runCommandStreamed } from './commandRunner'
|
||||
import { ensureRouter, getProjectConfigByRoot, projectUrls, setProjectModuleMetadata } from './auroraEngine'
|
||||
import { saveSiteCredentials } from './ipc/secrets'
|
||||
|
||||
type Settings = Record<string, string | number | boolean>
|
||||
type ExternalHook = (context: Record<string, unknown>) => Promise<void>
|
||||
|
||||
export async function runModuleProjectCreate(moduleId: string, operationId: string, directory: string, projectName: string, settings: Settings, sender: WebContents): Promise<void> {
|
||||
const manifest = await getModuleManifest(moduleId)
|
||||
if (!manifest.main) return
|
||||
const root = resolve(moduleDirectory(), moduleId)
|
||||
const entry = resolve(root, manifest.main)
|
||||
if (entry !== root && !entry.startsWith(`${root}${sep}`)) throw new Error('Unsafe module main entry')
|
||||
const loaded = createRequire(join(dirname(entry), 'loader.cjs'))(entry) as { projectCreate?: ExternalHook }
|
||||
if (typeof loaded.projectCreate !== 'function') return
|
||||
const config = await getProjectConfigByRoot(directory)
|
||||
const urls = projectUrls(config.name)
|
||||
await loaded.projectCreate(Object.freeze({
|
||||
moduleId,
|
||||
directory,
|
||||
projectName,
|
||||
settings: Object.freeze({ ...settings }),
|
||||
urls: Object.freeze(urls),
|
||||
run: (suffix: string, command: string, args: string[]) => runCommandStreamed(`${operationId}-${suffix}`, command, args, sender, { cwd: directory }),
|
||||
ensureRouter,
|
||||
setProjectMetadata: (metadata: Record<string, string | number | boolean>) => setProjectModuleMetadata(directory, metadata),
|
||||
saveCredentials: (credentials: { platform: string; adminUrl: string; username: string; password: string; email: string }) => saveSiteCredentials(directory, credentials)
|
||||
}))
|
||||
}
|
||||
+8
-50
@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type {
|
||||
AuroraInstalledModule,
|
||||
AuroraModuleInstallResult,
|
||||
AuroraModuleManifest,
|
||||
AuroraProjectDetail,
|
||||
AuroraProjectSummary,
|
||||
@@ -48,10 +49,6 @@ const api = {
|
||||
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),
|
||||
@@ -87,6 +84,11 @@ const api = {
|
||||
listRegistry: (): Promise<AuroraModuleManifest[]> => ipcRenderer.invoke('modules:listRegistry'),
|
||||
listInstalled: (name: string): Promise<AuroraInstalledModule[]> =>
|
||||
ipcRenderer.invoke('modules:listInstalled', name),
|
||||
pickAndInstallPackage: (): Promise<AuroraModuleInstallResult | null> =>
|
||||
ipcRenderer.invoke('modules:pickAndInstallPackage'),
|
||||
installPackage: (source: string): Promise<AuroraModuleInstallResult> =>
|
||||
ipcRenderer.invoke('modules:installPackage', source),
|
||||
uninstallPackage: (id: string): Promise<void> => ipcRenderer.invoke('modules:uninstallPackage', id),
|
||||
install: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
@@ -129,52 +131,8 @@ const api = {
|
||||
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
|
||||
)
|
||||
runModuleProjectCreate: (operationId: string, moduleId: string, directory: string, projectName: string, settings: Record<string, string | number | boolean>): Promise<void> =>
|
||||
ipcRenderer.invoke('create:runModuleProjectCreate', operationId, moduleId, directory, projectName, settings),
|
||||
},
|
||||
secrets: {
|
||||
getSiteCredentials: (approot: string): Promise<AuroraSiteCredentials | null> =>
|
||||
|
||||
@@ -35,6 +35,6 @@ describe('App', () => {
|
||||
|
||||
it('shows an empty state when there are no Aurora projects', async () => {
|
||||
renderApp()
|
||||
await waitFor(() => expect(screen.getByText(/No Aurora projects found/)).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText(/No Aurora projects yet/)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,20 +5,16 @@ import {
|
||||
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 { useInstallModulePackage, useModuleRegistry } from '../../hooks/useModules'
|
||||
import { GenericSetup } from './types/GenericSetup'
|
||||
import { WordpressSetup } from './types/WordpressSetup'
|
||||
import { ExternalModuleSetup } from './types/ExternalModuleSetup'
|
||||
import type { TypeSetupHandle } from './types/shared'
|
||||
import { isValidProjectName, slugifyProjectName } from './projectName'
|
||||
import docksideIcon from '../../assets/dockside-icon.png'
|
||||
@@ -32,17 +28,7 @@ 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
|
||||
'': Sparkles
|
||||
}
|
||||
|
||||
export function CreateProjectModal({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||
@@ -65,10 +51,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
const createProject = useCreateProject()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
const setupRef = useRef<TypeSetupHandle>(null)
|
||||
const { data: moduleRegistry = [] } = useModuleRegistry()
|
||||
const installPackage = useInstallModulePackage()
|
||||
const applicationModules = moduleRegistry.filter((module) => module.category === 'application')
|
||||
const selectedModule = applicationModules.find((module) => module.id === projectType)
|
||||
const getTypeLabel = (type: string): string => applicationModules.find((module) => module.id === type)?.name ?? 'project'
|
||||
|
||||
const trimmedName = projectName.trim()
|
||||
const nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName)
|
||||
const canContinue = directory !== null && nameValid
|
||||
const canContinue = directory !== null && nameValid && selectedModule !== undefined
|
||||
const canSubmit = canContinue && setupValid && !isSubmitting
|
||||
|
||||
async function handlePickDirectory(): Promise<void> {
|
||||
@@ -233,7 +224,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
<div>
|
||||
<label className={labelClass}>Project type</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{PROJECT_TYPES.map((t) => {
|
||||
{applicationModules.map((module) => ({ value: module.id, label: module.name, defaults: module.defaults })).map((t) => {
|
||||
const Icon = TYPE_ICONS[t.value] ?? Boxes
|
||||
const isSelected = projectType === t.value
|
||||
|
||||
@@ -243,13 +234,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
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') }
|
||||
if (!docroot.trim() && t.defaults?.docroot) setDocroot(t.defaults.docroot)
|
||||
}}
|
||||
className={clsx(
|
||||
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
|
||||
@@ -277,6 +262,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{applicationModules.length === 0 && <div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100"><p className="font-semibold">No application modules installed</p><p className="mt-1">Install a compatible local Aurora module package to create a project.</p><button type="button" onClick={() => installPackage.mutate()} className="mt-3 rounded-lg bg-cyan-600 px-3 py-2 font-semibold text-white">Install local module…</button></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -288,7 +274,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
<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><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)}}>{selectedModule?.creation?.databases?.includes('mariadb') !== false && <><option value="mariadb:11.8">MariaDB 11.8</option><option value="mariadb:10.11">MariaDB 10.11</option></>}{selectedModule?.creation?.databases?.includes('postgres') !== false && <><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>
|
||||
@@ -306,18 +292,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : projectType === 'wordpress' ? (
|
||||
<WordpressSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : projectType === 'drupal' ? (
|
||||
<DrupalSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : selectedModule ? (
|
||||
<ExternalModuleSetup ref={setupRef} module={selectedModule} projectName={projectName.trim()} onValidityChange={setSetupValid} />
|
||||
) : (
|
||||
<GenericSetup
|
||||
ref={setupRef}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
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,25 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import type { AuroraModuleManifest } from '@shared/types'
|
||||
import type { TypeSetupHandle, TypeSetupProps } from './shared'
|
||||
|
||||
export const ExternalModuleSetup = forwardRef<TypeSetupHandle, TypeSetupProps & { module: AuroraModuleManifest }>(
|
||||
function ExternalModuleSetup({ module, projectName, onValidityChange }, ref) {
|
||||
const fields = module.creation?.setup ?? []
|
||||
const [values, setValues] = useState<Record<string, string | number | boolean>>(() => Object.fromEntries(fields.map((field) => [field.id, field.default])))
|
||||
const valid = fields.every((field) => field.type === 'boolean' || field.type === 'number' || String(values[field.id] ?? '').trim().length > 0)
|
||||
useEffect(() => onValidityChange(valid), [valid, onValidityChange])
|
||||
useImperativeHandle(ref, () => ({
|
||||
runPostCreate: async ({ directory }) => {
|
||||
await window.api.create.runModuleProjectCreate(crypto.randomUUID(), module.id, directory, projectName, values)
|
||||
}
|
||||
}), [module.id, projectName, values])
|
||||
return <div className="grid gap-4">
|
||||
<div><h3 className="font-semibold">{module.name} setup</h3><p className="text-sm text-neutral-500">Provided by {module.name} {module.version}.</p></div>
|
||||
{fields.map((field) => <label key={field.id} className="grid gap-1.5 text-sm"><span className="font-medium">{field.label}</span>
|
||||
{field.type === 'boolean' ? <input type="checkbox" checked={Boolean(values[field.id])} onChange={(event) => setValues({ ...values, [field.id]: event.target.checked })}/>
|
||||
: field.type === 'select' ? <select value={String(values[field.id] ?? '')} onChange={(event) => setValues({ ...values, [field.id]: event.target.value })} className="rounded-lg border p-2 dark:bg-neutral-950">{field.options?.map((option) => <option key={option}>{option}</option>)}</select>
|
||||
: <input type={field.id.toLowerCase().includes('password') ? 'password' : field.type === 'number' ? 'number' : 'text'} value={String(values[field.id] ?? '')} onChange={(event) => setValues({ ...values, [field.id]: field.type === 'number' ? Number(event.target.value) : event.target.value })} className="rounded-lg border p-2 dark:bg-neutral-950"/>}
|
||||
</label>)}
|
||||
</div>
|
||||
}
|
||||
)
|
||||
@@ -1,200 +0,0 @@
|
||||
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>
|
||||
)
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
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'
|
||||
}
|
||||
@@ -33,11 +33,11 @@ import {
|
||||
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'
|
||||
import { useModuleRegistry } from '../../hooks/useModules'
|
||||
|
||||
const NODE_VERSIONS = ['20', '22', '24']
|
||||
|
||||
@@ -79,6 +79,8 @@ const heroFieldClass =
|
||||
|
||||
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
||||
const { data: moduleRegistry = [] } = useModuleRegistry()
|
||||
const applicationManifest = moduleRegistry.find((module) => module.id === project?.type)
|
||||
const startProject = useStartProject()
|
||||
const stopProject = useStopProject()
|
||||
const restartProject = useRestartProject()
|
||||
@@ -93,7 +95,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!project || project.type !== 'wordpress') {
|
||||
if (!project || !applicationManifest?.project?.adminPath) {
|
||||
setSiteCredentials(null)
|
||||
return () => { active = false }
|
||||
}
|
||||
@@ -101,7 +103,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
if (active) setSiteCredentials(credentials)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [project?.approot, project?.type])
|
||||
}, [project?.approot, applicationManifest?.project?.adminPath])
|
||||
|
||||
function copyCredential(value: string): void {
|
||||
void navigator.clipboard.writeText(value)
|
||||
@@ -164,7 +166,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
: 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 allowedDatabaseOptions = applicationManifest?.creation?.databases?.length ? DATABASE_OPTIONS.filter((option) => applicationManifest.creation?.databases?.some((database) => option.value.startsWith(`${database}:`))) : DATABASE_OPTIONS
|
||||
const databaseOptions = allowedDatabaseOptions.some((o) => o.value === currentDatabase)
|
||||
? DATABASE_OPTIONS
|
||||
: [
|
||||
@@ -226,17 +228,17 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
>
|
||||
<FileText size={14} /> Logs
|
||||
</button>
|
||||
{project.type === 'wordpress' && isRunning && (
|
||||
{applicationManifest?.project?.adminPath && isRunning && (
|
||||
<a
|
||||
href={`${project.primary_url}/wp-admin/`}
|
||||
href={`${project.primary_url.replace(/\/$/, '')}${applicationManifest.project.adminPath}`}
|
||||
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
|
||||
<KeyRound size={14} /> Application Admin
|
||||
</a>
|
||||
)}
|
||||
{project.type === 'wordpress' && isRunning && project.wordpress_network_admin_url && (
|
||||
{applicationManifest?.project?.adminPath && 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>
|
||||
@@ -257,7 +259,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
<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>}
|
||||
<p className="mt-1 truncate text-sm font-semibold text-white">{applicationManifest?.name ?? project.type}</p>{applicationManifest?.project?.adminPath && <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">
|
||||
@@ -419,7 +421,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{project.type === 'wordpress' && siteCredentials && (
|
||||
{applicationManifest?.project?.adminPath && 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">
|
||||
@@ -443,7 +445,6 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
</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]">
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -3,133 +3,17 @@ import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/r
|
||||
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 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}`)
|
||||
mutationFn: async ({ directory, projectName, projectType, docroot, stack }) => {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, `Create project ${projectName}`)
|
||||
useStatusStore.getState().begin(operationId, `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
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,23 @@ 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 })
|
||||
return useQuery({ queryKey: ['modules', 'registry'], queryFn: () => window.api.modules.listRegistry() })
|
||||
}
|
||||
|
||||
export function useInstallModulePackage(): UseMutationResult<void, Error, void> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async () => { await window.api.modules.pickAndInstallPackage() },
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', 'registry'] })
|
||||
})
|
||||
}
|
||||
|
||||
export function useUninstallModulePackage(): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id) => window.api.modules.uninstallPackage(id),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', 'registry'] })
|
||||
})
|
||||
}
|
||||
|
||||
export function useInstalledModules(name: string): UseQueryResult<AuroraInstalledModule[], Error> {
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface AuroraProjectSummary {
|
||||
httpsurl: string
|
||||
mutagen_enabled: boolean
|
||||
mutagen_status?: string
|
||||
module_available?: boolean
|
||||
missing_module_id?: string
|
||||
}
|
||||
|
||||
export interface AuroraServiceHostPortMapping {
|
||||
@@ -126,10 +128,21 @@ export interface AuroraModuleManifest {
|
||||
category: AuroraModuleCategory
|
||||
description: string
|
||||
icon?: string
|
||||
main?: string
|
||||
dependencies: string[]
|
||||
conflicts: string[]
|
||||
defaults?: { docroot?: string }
|
||||
settings: AuroraModuleSetting[]
|
||||
aurora: { core: string; moduleApi: string }
|
||||
creation?: {
|
||||
databases?: Array<'mariadb' | 'postgres'>
|
||||
setup?: AuroraModuleSetting[]
|
||||
}
|
||||
hooks?: Partial<Record<AuroraModuleLifecycleHook, AuroraModuleCommand[]>>
|
||||
project?: {
|
||||
adminPath?: string
|
||||
tools?: Array<{ id: string; label: string; hook: AuroraModuleLifecycleHook }>
|
||||
}
|
||||
compose?: {
|
||||
service: string
|
||||
image: string
|
||||
@@ -138,6 +151,14 @@ export interface AuroraModuleManifest {
|
||||
}
|
||||
}
|
||||
|
||||
export type AuroraModuleLifecycleHook = 'projectCreate' | 'projectStart' | 'projectRemove' | 'packageUninstall'
|
||||
|
||||
export interface AuroraModuleCommand {
|
||||
command: string
|
||||
args: string[]
|
||||
operationLabel?: string
|
||||
}
|
||||
|
||||
export interface AuroraInstalledModule {
|
||||
id: string
|
||||
name: string
|
||||
@@ -145,6 +166,11 @@ export interface AuroraInstalledModule {
|
||||
category: AuroraModuleCategory
|
||||
}
|
||||
|
||||
export interface AuroraModuleInstallResult {
|
||||
manifest: AuroraModuleManifest
|
||||
installedPath: string
|
||||
}
|
||||
|
||||
export interface AuroraAddonRegistryEntry {
|
||||
title: string
|
||||
github_url: string
|
||||
|
||||
Reference in New Issue
Block a user