From 31e6150ecd3a8f95159e7b7c4badd6e1b11466e2 Mon Sep 17 00:00:00 2001 From: reaper Date: Fri, 14 Aug 2026 12:08:21 -0500 Subject: [PATCH] feat: add external module architecture --- artifacts/aurora-module-wordpress-1.0.0.tgz | Bin 0 -> 2009 bytes package-lock.json | 4 +- package.json | 2 +- packages/aurora-module-wordpress/README.md | 5 + .../aurora-module-wordpress/main/index.cjs | 34 +++ .../aurora-module-wordpress/manifest.json | 26 +++ packages/aurora-module-wordpress/package.json | 7 + resources/modules/drupal.json | 3 - resources/modules/laravel.json | 4 - resources/modules/wordpress.json | 4 - src/main/auroraEngine.ts | 47 ++-- src/main/index.ts | 2 - src/main/ipc/create.ts | 213 +----------------- src/main/ipc/modules.ts | 10 +- src/main/ipc/projects.ts | 16 +- src/main/ipc/wordpress.ts | 55 ----- src/main/moduleRegistry.test.ts | 36 +++ src/main/moduleRegistry.ts | 105 ++++++++- src/main/moduleRuntime.ts | 33 +++ src/preload/index.ts | 58 +---- src/renderer/src/App.test.tsx | 2 +- .../components/create/CreateProjectModal.tsx | 54 ++--- .../components/create/types/DrupalSetup.tsx | 170 -------------- .../create/types/ExternalModuleSetup.tsx | 25 ++ .../create/types/WordpressSetup.tsx | 200 ---------------- .../src/components/create/types/registry.ts | 10 - .../src/components/projects/ProjectDetail.tsx | 23 +- .../components/projects/WordpressTools.tsx | 56 ----- src/renderer/src/hooks/useCreateProject.ts | 126 +---------- src/renderer/src/hooks/useModules.ts | 18 +- src/shared/types.ts | 26 +++ 31 files changed, 389 insertions(+), 985 deletions(-) create mode 100644 artifacts/aurora-module-wordpress-1.0.0.tgz create mode 100644 packages/aurora-module-wordpress/README.md create mode 100644 packages/aurora-module-wordpress/main/index.cjs create mode 100644 packages/aurora-module-wordpress/manifest.json create mode 100644 packages/aurora-module-wordpress/package.json delete mode 100644 resources/modules/drupal.json delete mode 100644 resources/modules/laravel.json delete mode 100644 resources/modules/wordpress.json delete mode 100644 src/main/ipc/wordpress.ts create mode 100644 src/main/moduleRegistry.test.ts create mode 100644 src/main/moduleRuntime.ts delete mode 100644 src/renderer/src/components/create/types/DrupalSetup.tsx create mode 100644 src/renderer/src/components/create/types/ExternalModuleSetup.tsx delete mode 100644 src/renderer/src/components/create/types/WordpressSetup.tsx delete mode 100644 src/renderer/src/components/create/types/registry.ts delete mode 100644 src/renderer/src/components/projects/WordpressTools.tsx diff --git a/artifacts/aurora-module-wordpress-1.0.0.tgz b/artifacts/aurora-module-wordpress-1.0.0.tgz new file mode 100644 index 0000000000000000000000000000000000000000..7eddca88512f888d68202cab36394aac82731fa1 GIT binary patch literal 2009 zcmV;~2PXI*iwFP!00002|Ls}ta@)8O_h&!FR?Q8iM5O3Hai+TQ-1(1s@zhQ{aa>O) z&&2W=`*S^8XwP9*4 z41b43hG7WR?|;ega*d#k;*s&z*N3@?43`2l8{!+5VpFgbTX4G$pwQ$>01*NskL3i_ z0n^xQ1q!o-McC|o_?h{?g#MrU{^g6#s12U))wn_+1g3q79%ar_xWO-Jz zJ<0aeNH-%X41O|g5NIY+T{|x#DYSu{$FK`#nxPy*reuU#2O~6=zPk%vsmy~P?(mx} z=t3@H9CCqirE#=EL2VKyzcc6kKS2!XOj6* z!u^OvV=OEeQ6zhw?-w(&#&RVU^Pk;TdtO97D0YWvtyw?DAuwPT&YrBGxu!YwQw5XSAsgK|pevNp|_*0t$aYO@;;x?5Y} zj-%;op83%jqigRAJmo?gmL!k5>cTXazViKCB@`l;L==`Q-#6S8fwNBqmBlF+eY+51 z&8G`|S{WV2noLPrki(Smq$XtN#kSbRTf)i}-=N~dss9@nuFDrNcAEJRnmS1H#Bd4$ z{DELvNP(U^(MxSt;(jE>4c>Y?*NEUCxx3IMJM+A9FnuIZt8 z%`y8o$II?a`Tmz0@?DNUV9N>oJx4Y5+R!+A{q_Rh@9iJ=&fxgyFFo)kS$~kok@miC z)J}#>@>KYA$O_n)-OCe==G0;J2^tnN!KeBj*6U7~7XruDT}hs`2MS{phOCYig7I!=AhPULy24W_$8y&28&Yn1T3{h`7v8x`=1|Kb^jB5h}r}nwG?aL^8UBE z`Rd;NZ{yXg@ag{dEd~|J29FzEXjE-@qfJCQT|~Z+Dn6Z)Z%}D21#vclFbEwrVg^T2 zO^M91Ea4HOhTAb?)RE$uDPF4-!okTIwd6OPG;%QlCStfa^$(7Zz(|>JF$$at!fbkX z@iT?vxyH58gaRbuMR4gMUr6F~trt-#6(?WfvTeoXT8dS@u^|eBclqt^QLBmQr4S7Z!I|Z`i zHpg|rw2*`iFd@=$MWSmfQ8XqP8KM-f645rzM3Z(0oZX6uqW{yrd!v8YqlcmD|M{I| z>3;95ap~5!;Ee5c7JNCQ5?hf=e+bp@c(Z3W3rN1PMDrZO7u4?&xx)NMOjsSOm6ruz ziGe!n0RyXb+uE^2aYcX3?H}!Q#CaA!=K9d@ob>}_--68 z-O|z|DEo{BM|y${5`?lpsk<|99m=9VbRD+sAHXFwg7(l9w{&;RY*ow&jUCz8cI~K zUk?-?FaBi%2_IrKjS{48QiBx9)H+vy8^&-T6_%eQK<5Nm z7{g#%BG%sN5m@{S*4NKP@r{ixFJ{bjwKn&4yk;Ph*%Y`aBIaq^&H8j3fGek2cPhZW rn#YD#3j%LYRXU6Q2rNW`R8ZY1f+rMw8c*YCeDU}fXPCQ+03rYY(U0z$ literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index 1427fbb..24eb065 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9ae5966..2019c63 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/aurora-module-wordpress/README.md b/packages/aurora-module-wordpress/README.md new file mode 100644 index 0000000..cfebdb1 --- /dev/null +++ b/packages/aurora-module-wordpress/README.md @@ -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. diff --git a/packages/aurora-module-wordpress/main/index.cjs b/packages/aurora-module-wordpress/main/index.cjs new file mode 100644 index 0000000..b001b7f --- /dev/null +++ b/packages/aurora-module-wordpress/main/index.cjs @@ -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) }) +} diff --git a/packages/aurora-module-wordpress/manifest.json b/packages/aurora-module-wordpress/manifest.json new file mode 100644 index 0000000..ba598b8 --- /dev/null +++ b/packages/aurora-module-wordpress/manifest.json @@ -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": "admin@example.test" }, + { "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/" } +} diff --git a/packages/aurora-module-wordpress/package.json b/packages/aurora-module-wordpress/package.json new file mode 100644 index 0000000..096609b --- /dev/null +++ b/packages/aurora-module-wordpress/package.json @@ -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"] +} diff --git a/resources/modules/drupal.json b/resources/modules/drupal.json deleted file mode 100644 index 2e2509b..0000000 --- a/resources/modules/drupal.json +++ /dev/null @@ -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":[] -} diff --git a/resources/modules/laravel.json b/resources/modules/laravel.json deleted file mode 100644 index e8a2e15..0000000 --- a/resources/modules/laravel.json +++ /dev/null @@ -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"]}] -} diff --git a/resources/modules/wordpress.json b/resources/modules/wordpress.json deleted file mode 100644 index b5676f9..0000000 --- a/resources/modules/wordpress.json +++ /dev/null @@ -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}] -} diff --git a/src/main/auroraEngine.ts b/src/main/auroraEngine.ts index f4edd7a..b9e09bd 100644 --- a/src/main/auroraEngine.ts +++ b/src/main/auroraEngine.ts @@ -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> primaryProtocol?: 'http' | 'https' wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain' + moduleMetadata?: Record xdebug?: boolean } @@ -87,7 +88,11 @@ async function renderCompose(c: AuroraConfig): Promise { 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 = { + 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): Promise { 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'), `${name}

Aurora Dockside is running.

';`) } export async function unregisterProject(name: string, deleteFiles: boolean): Promise { 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 { 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 { - 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): Promisep.State==='running'); const currentRouterStatus = await routerStatus(); const urlSet=projectUrls(c.name); const primary=c.primaryProtocol === 'http' ? urlSet.http : urlSet.https const services: Record = {}; 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{ 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): Promise { + 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 { - const root = await getProjectRoot(name) +export async function scaffoldApplicationModule(_name: string, moduleId: string): Promise { 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{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 { 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 { } catch { /* stale project or Docker unavailable */ } })) } - diff --git a/src/main/index.ts b/src/main/index.ts index 13469ee..825c91b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc/create.ts b/src/main/ipc/create.ts index 77c622f..93db8e2 100644 --- a/src/main/ipc/create.ts +++ b/src/main/ipc/create.ts @@ -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 { - 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 { - 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 { - 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) => - 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) => createProject(directory, name, type, docroot, stack)) + ipcMain.handle('create:runModuleProjectCreate', (event, id: string, moduleId: string, directory: string, name: string, settings: Record) => runModuleProjectCreate(moduleId, id, directory, name, settings, event.sender)) } diff --git a/src/main/ipc/modules.ts b/src/main/ipc/modules.ts index 0ab7c0b..983f133 100644 --- a/src/main/ipc/modules.ts +++ b/src/main/ipc/modules.ts @@ -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', diff --git a/src/main/ipc/projects.ts b/src/main/ipc/projects.ts index 5c585b0..e29ac69 100644 --- a/src/main/ipc/projects.ts +++ b/src/main/ipc/projects.ts @@ -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 }) - } - } }) } diff --git a/src/main/ipc/wordpress.ts b/src/main/ipc/wordpress.ts deleted file mode 100644 index 2c61e85..0000000 --- a/src/main/ipc/wordpress.ts +++ /dev/null @@ -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 }) - }) -} diff --git a/src/main/moduleRegistry.test.ts b/src/main/moduleRegistry.test.ts new file mode 100644 index 0000000..c2f43d9 --- /dev/null +++ b/src/main/moduleRegistry.test.ts @@ -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 { 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 { 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() + }) +}) diff --git a/src/main/moduleRegistry.ts b/src/main/moduleRegistry.ts index 8fac5ea..840bd31 100644 --- a/src/main/moduleRegistry.ts +++ b/src/main/moduleRegistry.ts @@ -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 + 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 { - 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 + 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 | 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).setup ?? [], 'creation.setup') + return value as AuroraModuleManifest +} + +async function assertSafePackageTree(root: string): Promise { + const rootReal = await realpath(root) + async function visit(directory: string): Promise { + 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 { + 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 { 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 { + 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 { + 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() +} diff --git a/src/main/moduleRuntime.ts b/src/main/moduleRuntime.ts new file mode 100644 index 0000000..f05413f --- /dev/null +++ b/src/main/moduleRuntime.ts @@ -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 +type ExternalHook = (context: Record) => Promise + +export async function runModuleProjectCreate(moduleId: string, operationId: string, directory: string, projectName: string, settings: Settings, sender: WebContents): Promise { + 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) => setProjectModuleMetadata(directory, metadata), + saveCredentials: (credentials: { platform: string; adminUrl: string; username: string; password: string; email: string }) => saveSiteCredentials(directory, credentials) + })) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index b9b5082..6442ad8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('projects:openTerminal', name) }, - wordpress: { - run: (operationId: string, name: string, action: string, payload?: string): Promise => - ipcRenderer.invoke('wordpress:run', operationId, name, action, payload) - }, terminal: { cancel: (operationId: string): Promise => ipcRenderer.invoke('terminal:cancel', operationId), @@ -87,6 +84,11 @@ const api = { listRegistry: (): Promise => ipcRenderer.invoke('modules:listRegistry'), listInstalled: (name: string): Promise => ipcRenderer.invoke('modules:listInstalled', name), + pickAndInstallPackage: (): Promise => + ipcRenderer.invoke('modules:pickAndInstallPackage'), + installPackage: (source: string): Promise => + ipcRenderer.invoke('modules:installPackage', source), + uninstallPackage: (id: string): Promise => 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 => - 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 => - ipcRenderer.invoke( - 'create:setupWordpress', - operationId, - directory, - siteUrl, - title, - adminUser, - adminPassword, - adminEmail, - multisite - ), - downloadDrupal: (operationId: string, directory: string): Promise => - ipcRenderer.invoke('create:downloadDrupal', operationId, directory), - requireDrush: (operationId: string, directory: string): Promise => - ipcRenderer.invoke('create:requireDrush', operationId, directory), - setupDrupal: ( - operationId: string, - directory: string, - siteName: string, - adminUser: string, - adminPassword: string, - adminEmail: string, - profile: string - ): Promise => - ipcRenderer.invoke( - 'create:setupDrupal', - operationId, - directory, - siteName, - adminUser, - adminPassword, - adminEmail, - profile - ) + runModuleProjectCreate: (operationId: string, moduleId: string, directory: string, projectName: string, settings: Record): Promise => + ipcRenderer.invoke('create:runModuleProjectCreate', operationId, moduleId, directory, projectName, settings), }, secrets: { getSiteCredentials: (approot: string): Promise => diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 464c2cc..2ca7275 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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()) }) }) diff --git a/src/renderer/src/components/create/CreateProjectModal.tsx b/src/renderer/src/components/create/CreateProjectModal.tsx index 4fedee0..3aedb5d 100644 --- a/src/renderer/src/components/create/CreateProjectModal.tsx +++ b/src/renderer/src/components/create/CreateProjectModal.tsx @@ -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 = { - '': 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(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 { @@ -233,7 +224,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
- {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. ) })} + {applicationModules.length === 0 &&

No application modules installed

Install a compatible local Aurora module package to create a project.

}
@@ -288,7 +274,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
-
+
{[['Adminer',adminer,setAdminer],['Redis',redis,setRedis],['Mailpit',mailpit,setMailpit],['Xdebug',xdebug,setXdebug]].map(([label,value,setter])=>)}
@@ -306,18 +292,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. />
- ) : projectType === 'wordpress' ? ( - - ) : projectType === 'drupal' ? ( - + ) : selectedModule ? ( + ) : ( (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 ( -
-
-
-
- -
-
-

Drupal install

-

- Core downloads via Composer and the project starts for setup. -

-
-
-
- -
-
- -
- - setSiteName(e.target.value)} - placeholder={projectName || 'My Drupal Site'} - className={`${inputClass} pl-9`} - /> -
-
-
- -
- - setAdminUser(e.target.value)} - className={`${inputClass} pl-9`} - /> -
-
-
- -
- - setAdminPassword(e.target.value)} - className={`${inputClass} pl-9`} - /> -
-
-
- -
- - setAdminEmail(e.target.value)} - placeholder="admin@example.com" - className={`${inputClass} pl-9`} - /> -
-
-
- - - - {showAdvanced && ( -
-
- - -
-
- )} -
- ) -}) diff --git a/src/renderer/src/components/create/types/ExternalModuleSetup.tsx b/src/renderer/src/components/create/types/ExternalModuleSetup.tsx new file mode 100644 index 0000000..9e6d9b8 --- /dev/null +++ b/src/renderer/src/components/create/types/ExternalModuleSetup.tsx @@ -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( + function ExternalModuleSetup({ module, projectName, onValidityChange }, ref) { + const fields = module.creation?.setup ?? [] + const [values, setValues] = useState>(() => 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
+

{module.name} setup

Provided by {module.name} {module.version}.

+ {fields.map((field) => )} +
+ } +) diff --git a/src/renderer/src/components/create/types/WordpressSetup.tsx b/src/renderer/src/components/create/types/WordpressSetup.tsx deleted file mode 100644 index 366d4a8..0000000 --- a/src/renderer/src/components/create/types/WordpressSetup.tsx +++ /dev/null @@ -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(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('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 ( -
-
-
-
- -
-
-

- WordPress install -

-

- Core downloads automatically and the project starts for setup. -

-
-
-
- -
-
- -
- - setSiteTitle(e.target.value)} - placeholder={projectName || 'My WordPress Site'} - className={`${inputClass} pl-9`} - /> -
-
-
- -
- - setAdminUser(e.target.value)} - className={`${inputClass} pl-9`} - /> -
-
-
- -
- - setAdminPassword(e.target.value)} - className={`${inputClass} pl-9`} - /> -
-
-
- -
- - setAdminEmail(e.target.value)} - placeholder="admin@example.com" - className={`${inputClass} pl-9`} - /> -
-
-
- - - - {showAdvanced && ( -
-
- - -
-
- - -
-
- )} -
- ) -}) diff --git a/src/renderer/src/components/create/types/registry.ts b/src/renderer/src/components/create/types/registry.ts deleted file mode 100644 index 79b56b1..0000000 --- a/src/renderer/src/components/create/types/registry.ts +++ /dev/null @@ -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' -} diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index 0413c15..0829712 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -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 { > Logs - {project.type === 'wordpress' && isRunning && ( + {applicationManifest?.project?.adminPath && isRunning && ( - WP Admin + Application Admin )} - {project.type === 'wordpress' && isRunning && project.wordpress_network_admin_url && ( + {applicationManifest?.project?.adminPath && isRunning && project.wordpress_network_admin_url && ( Network Admin @@ -257,7 +259,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {

Project type

-

{project.type}

{project.type === 'wordpress' &&

{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}

} +

{applicationManifest?.name ?? project.type}

{applicationManifest?.project?.adminPath &&

{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}

}
@@ -419,7 +421,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
- {project.type === 'wordpress' && siteCredentials && ( + {applicationManifest?.project?.adminPath && siteCredentials && (

@@ -443,7 +445,6 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {

)} - {project.type === 'wordpress' && isRunning && } {isRunning && }
diff --git a/src/renderer/src/components/projects/WordpressTools.tsx b/src/renderer/src/components/projects/WordpressTools.tsx deleted file mode 100644 index 6c76291..0000000 --- a/src/renderer/src/components/projects/WordpressTools.tsx +++ /dev/null @@ -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(null) - const [debug, setDebug] = useState(null) - const [maintenance, setMaintenance] = useState(false) - - async function run(action: string, label: string, payload?: string): Promise { - 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 ( -
-
-
-

WordPress developer tools

-

WP-CLI commands run inside the project network and stream output to Dockside's terminal.

-
-
-
PHP {project.php_version ?? '—'} · Node {project.nodejs_version ?? '—'}
-
{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}
-
-
-
- - - - - - - - -
-
- ) -} diff --git a/src/renderer/src/hooks/useCreateProject.ts b/src/renderer/src/hooks/useCreateProject.ts index aca828c..513bf7f 100644 --- a/src/renderer/src/hooks/useCreateProject.ts +++ b/src/renderer/src/hooks/useCreateProject.ts @@ -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 -} +export interface CreateProjectInput { directory: string; projectName: string; projectType: string; docroot: string; stack?: Partial } -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 { 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 { - 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 { - return useMutation({ - mutationFn: async ({ directory }) => { - const operationId = beginOperation('Download Drupal core') - await window.api.create.downloadDrupal(operationId, directory) - } - }) -} - -export function useRequireDrush(): UseMutationResult { - return useMutation({ - mutationFn: async ({ directory }) => { - const operationId = beginOperation('Add Drush') - await window.api.create.requireDrush(operationId, directory) - } - }) -} - -export function useSetupDrupal(): UseMutationResult { - 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 - ) - } - }) -} diff --git a/src/renderer/src/hooks/useModules.ts b/src/renderer/src/hooks/useModules.ts index 2b73a10..6a7b094 100644 --- a/src/renderer/src/hooks/useModules.ts +++ b/src/renderer/src/hooks/useModules.ts @@ -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 { - 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 { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async () => { await window.api.modules.pickAndInstallPackage() }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', 'registry'] }) + }) +} + +export function useUninstallModulePackage(): UseMutationResult { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id) => window.api.modules.uninstallPackage(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['modules', 'registry'] }) + }) } export function useInstalledModules(name: string): UseQueryResult { diff --git a/src/shared/types.ts b/src/shared/types.ts index f2cab41..de40509 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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> + 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