From 68237f8b68356f823af4abda861186544166d2b7 Mon Sep 17 00:00:00 2001 From: reaper Date: Fri, 14 Aug 2026 22:41:10 -0500 Subject: [PATCH] feat: add Drupal application module --- docs/ALPHA24_COMPLETION_REPORT.md | 15 ++++++- packages/aurora-module-drupal/README.md | 5 +++ packages/aurora-module-drupal/main/index.cjs | 39 +++++++++++++++++++ packages/aurora-module-drupal/manifest.json | 34 ++++++++++++++++ packages/aurora-module-drupal/package.json | 7 ++++ src/main/auroraEngine.ts | 8 ++-- src/main/moduleRegistry.test.ts | 11 ++++++ src/main/moduleRegistry.ts | 6 ++- src/main/moduleRuntime.ts | 2 + .../components/create/CreateProjectModal.tsx | 5 ++- .../src/components/projects/ProjectDetail.tsx | 8 ++-- src/shared/types.ts | 1 + templates/aurora-module/README.md | 2 +- 13 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 packages/aurora-module-drupal/README.md create mode 100644 packages/aurora-module-drupal/main/index.cjs create mode 100644 packages/aurora-module-drupal/manifest.json create mode 100644 packages/aurora-module-drupal/package.json diff --git a/docs/ALPHA24_COMPLETION_REPORT.md b/docs/ALPHA24_COMPLETION_REPORT.md index bb39a09..f1a66ed 100644 --- a/docs/ALPHA24_COMPLETION_REPORT.md +++ b/docs/ALPHA24_COMPLETION_REPORT.md @@ -39,6 +39,18 @@ WordPress provisioning resides in `packages/aurora-module-wordpress`, including: Core contains no `type === 'wordpress'` or `moduleId === 'wordpress'` behavior. +## Drupal module + +Drupal provisioning resides in `packages/aurora-module-drupal`, including: + +- Drupal 11's Composer-recommended `web/` document-root layout +- PHP 8.4 and 8.3 compatibility constraints enforced in both the wizard and Core +- MariaDB, MySQL, and PostgreSQL installation through Drush +- Standard, Minimal, and Umami installation profiles +- Credential persistence and Application Admin integration +- A copyable **Rebuild Drupal cache** project tool +- Drupal-required DOM, cURL, GD, PDO, OPcache, XML, and multilingual runtime support + ## Verification Commands completed successfully: @@ -51,7 +63,7 @@ npm run build:modules npx electron-builder --linux AppImage ``` -Automated result: 6 test files and 27 tests passed. Coverage includes: +Automated result: 6 test files and 28 tests passed. Coverage includes: - Empty registry - Available local packages @@ -66,6 +78,7 @@ Automated result: 6 test files and 27 tests passed. Coverage includes: - Application appearing after install and disappearing after uninstall - Existing-project missing-module state - Validation of the independently packaged WordPress contract +- Validation and `.pac` installation of the independently packaged Drupal contract - Production compilation of the lifecycle IPC, preload, and renderer tool-action path - Generation of the embedded WordPress `.pac` catalog with the lifecycle-aware author tooling present diff --git a/packages/aurora-module-drupal/README.md b/packages/aurora-module-drupal/README.md new file mode 100644 index 0000000..87a3a31 --- /dev/null +++ b/packages/aurora-module-drupal/README.md @@ -0,0 +1,5 @@ +# Aurora Drupal module + +Installs Drupal 11 from `drupal/recommended-project`, adds Drush, and performs an unattended site installation against the Aurora project database. + +The project uses Drupal's recommended `web/` document root. The project toolbar provides a cache-rebuild action through Drush. diff --git a/packages/aurora-module-drupal/main/index.cjs b/packages/aurora-module-drupal/main/index.cjs new file mode 100644 index 0000000..cdb93cc --- /dev/null +++ b/packages/aurora-module-drupal/main/index.cjs @@ -0,0 +1,39 @@ +'use strict' + +function containerUser() { + if (typeof process.getuid !== 'function') return [] + return ['--user', `${process.getuid()}:${process.getgid()}`] +} + +function compose(context, ...args) { + return ['compose', '-f', `${context.directory}/.aurora/compose.yaml`, ...args] +} + +function phpExec(context, ...args) { + return compose(context, 'exec', '-T', ...containerUser(), '-e', 'HOME=/tmp', '-e', 'COMPOSER_HOME=/tmp/composer', 'php', ...args) +} + +function databaseUrl(context) { + const driver = context.environment.database === 'postgres' ? 'pgsql' : 'mysql' + return `${driver}://db:db@db:3306/db`.replace(':3306/', context.environment.database === 'postgres' ? ':5432/' : ':3306/') +} + +exports.projectCreate = async function projectCreate(context) { + const settings = context.settings + const siteName = String(settings.site_name || '').trim() || context.projectName + const temporaryProject = '/tmp/aurora-drupal-project' + await context.ensureRouter() + await context.run('Start project services', 'docker', compose(context, 'up', '-d', '--build', '--remove-orphans')) + await context.run('Download Drupal 11', 'docker', phpExec(context, 'sh', '-lc', `rm -rf ${temporaryProject} && composer create-project drupal/recommended-project:^11 ${temporaryProject} --no-interaction --no-progress && cp -a ${temporaryProject}/. /var/www/html/ && rm -rf ${temporaryProject}`)) + await context.run('Install Drush', 'docker', phpExec(context, 'composer', 'require', 'drush/drush:^13', '--no-interaction', '--no-progress')) + await context.run('Install Drupal', 'docker', phpExec(context, 'vendor/bin/drush', 'site:install', String(settings.profile || 'standard'), `--db-url=${databaseUrl(context)}`, `--site-name=${siteName}`, `--account-name=${settings.admin_user}`, `--account-pass=${settings.admin_password}`, `--account-mail=${settings.admin_email}`, '--yes')) + await context.run('Prepare writable files', 'docker', phpExec(context, 'sh', '-lc', 'mkdir -p web/sites/default/files && chmod 0777 web/sites/default/files')) + await context.run('Verify Drupal', 'docker', phpExec(context, 'vendor/bin/drush', 'status', '--field=drupal-version')) + await context.setProjectMetadata({ drupalVersion: 'Drupal 11' }) + await context.saveCredentials({ platform: context.moduleId, adminUrl: `${context.urls.https}/user/login`, username: String(settings.admin_user), password: String(settings.admin_password), email: String(settings.admin_email) }) +} + +exports.projectTool = async function projectTool(context) { + if (context.toolId !== 'rebuild-cache') throw new Error(`Unknown Drupal tool '${context.toolId}'`) + await context.run('Rebuild Drupal cache', 'docker', phpExec(context, 'vendor/bin/drush', 'cache:rebuild')) +} diff --git a/packages/aurora-module-drupal/manifest.json b/packages/aurora-module-drupal/manifest.json new file mode 100644 index 0000000..2d13255 --- /dev/null +++ b/packages/aurora-module-drupal/manifest.json @@ -0,0 +1,34 @@ +{ + "id": "drupal", + "name": "Drupal", + "version": "1.0.0", + "category": "application", + "description": "Drupal 11 CMS with Composer, Drush, and the recommended web-root layout.", + "main": "main/index.cjs", + "aurora": { "core": "2.0.0-alpha.24", "moduleApi": "1.0.0" }, + "dependencies": [], + "conflicts": [], + "defaults": { "docroot": "web" }, + "settings": [], + "creation": { + "databases": ["mariadb", "mysql", "postgres"], + "phpVersions": ["8.4", "8.3"], + "intro": { + "title": "Drupal 11 install", + "description": "Composer downloads Drupal and Drush, then Aurora configures the site automatically.", + "icon": "globe" + }, + "setup": [ + { "id": "site_name", "label": "Site name", "type": "text", "default": "", "placeholder": "My Drupal Site", "required": true, "icon": "title", "span": 2 }, + { "id": "admin_user", "label": "Admin username", "type": "text", "default": "admin", "required": true, "icon": "user" }, + { "id": "admin_password", "label": "Admin password", "type": "text", "default": "", "required": true, "secret": true, "icon": "key" }, + { "id": "admin_email", "label": "Admin email", "type": "text", "default": "", "required": true, "placeholder": "admin@example.com", "icon": "mail", "span": 2 }, + { "id": "profile", "label": "Installation profile", "type": "select", "default": "standard", "advanced": true, "icon": "settings", "options": ["standard", "minimal", "demo_umami"], "optionLabels": { "standard": "Standard", "minimal": "Minimal", "demo_umami": "Umami demo" } } + ] + }, + "project": { + "adminPath": "/admin/", + "summary": { "metadataKey": "drupalVersion", "labels": {}, "fallback": "Drupal 11" }, + "tools": [{ "id": "rebuild-cache", "label": "Rebuild Drupal cache" }] + } +} diff --git a/packages/aurora-module-drupal/package.json b/packages/aurora-module-drupal/package.json new file mode 100644 index 0000000..fe93124 --- /dev/null +++ b/packages/aurora-module-drupal/package.json @@ -0,0 +1,7 @@ +{ + "name": "@aurora/module-drupal", + "version": "1.0.0", + "private": true, + "description": "Drupal application module for Aurora Dockside", + "files": ["manifest.json", "main", "README.md"] +} diff --git a/src/main/auroraEngine.ts b/src/main/auroraEngine.ts index 41d7bd9..f74a0f2 100644 --- a/src/main/auroraEngine.ts +++ b/src/main/auroraEngine.ts @@ -132,9 +132,9 @@ async function renderCompose(c: AuroraConfig): Promise { async function writePhpDockerfile(root: string, xdebug = false): Promise { await writeFile(phpDockerfilePath(root), `ARG PHP_VERSION=8.4 FROM php:${'${PHP_VERSION}'}-fpm-alpine -RUN apk add --no-cache icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev postgresql-dev \ +RUN apk add --no-cache icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev postgresql-dev curl-dev libxml2-dev \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install -j2 mysqli pdo_mysql pdo_pgsql intl zip gd mbstring + && docker-php-ext-install -j2 mysqli pdo_mysql pdo_pgsql intl zip gd mbstring curl dom opcache COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer ${xdebug ? 'RUN apk add --no-cache $PHPIZE_DEPS linux-headers && pecl install xdebug && docker-php-ext-enable xdebug' : ''} `) @@ -211,7 +211,9 @@ export async function createProject(root: string, name: string, type: string, do 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: stack?.webServer || 'nginx', database: stack?.database || 'mariadb', databaseVersion: stack?.databaseVersion || '11.8', modules, moduleSettings: {}, primaryProtocol: 'https', xdebug: stack?.xdebug === true } + const phpVersion = stack?.phpVersion || application.creation?.phpVersions?.[0] || '8.4' + if (application.creation?.phpVersions?.length && !application.creation.phpVersions.includes(phpVersion)) throw new Error(`${application.name} does not support PHP ${phpVersion}`) + const config: AuroraConfig = { name, type: normalizedType, docroot: defaultDocroot, php: phpVersion, node: stack?.nodeVersion || '24', webserver: stack?.webServer || 'nginx', database: stack?.database || 'mariadb', databaseVersion: stack?.databaseVersion || '11.8', modules, moduleSettings: {}, primaryProtocol: 'https', xdebug: stack?.xdebug === true } await writeConfig(root, config); await writeWebServerConfig(root, config); await writePhpDockerfile(root, config.xdebug) const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg) } diff --git a/src/main/moduleRegistry.test.ts b/src/main/moduleRegistry.test.ts index dd41ed3..b6d9a11 100644 --- a/src/main/moduleRegistry.test.ts +++ b/src/main/moduleRegistry.test.ts @@ -77,6 +77,7 @@ describe('external module registry', () => { expect(() => validateModuleManifest({ ...manifest, main: '../outside.cjs' })).toThrow(/may not leave/) expect(() => validateModuleManifest({ ...manifest, dependencies: ['../escape'] })).toThrow(/module id array/) expect(() => validateModuleManifest({ ...manifest, project: { adminPath: 'admin' } })).toThrow(/must start with/) + expect(() => validateModuleManifest({ ...manifest, creation: { phpVersions: ['latest'] } })).toThrow(/PHP minor versions/) }) 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')) @@ -135,4 +136,14 @@ describe('external module registry', () => { expect((await installModulePackage(archive, userData)).manifest.id).toBe('wordpress') expect((await getModuleRegistry(userData)).map((item) => item.id)).toEqual(['wordpress']) }) + + it('accepts and packages the Drupal application module contract', async () => { + const packageRoot = resolve(process.cwd(), 'packages/aurora-module-drupal') + const actual = JSON.parse(await readFile(join(packageRoot, 'manifest.json'), 'utf8')) + expect(validateModuleManifest(actual)).toMatchObject({ id: 'drupal', defaults: { docroot: 'web' } }) + const archive = join(await temp('aurora-pac-'), 'drupal.pac') + await execFileAsync('zip', ['-qr', archive, '.'], { cwd: packageRoot }) + const userData = await temp('aurora-user-') + expect((await installModulePackage(archive, userData)).manifest.id).toBe('drupal') + }) }) diff --git a/src/main/moduleRegistry.ts b/src/main/moduleRegistry.ts index 59c7c32..b122ef6 100644 --- a/src/main/moduleRegistry.ts +++ b/src/main/moduleRegistry.ts @@ -51,7 +51,11 @@ export function validateModuleManifest(value: unknown): AuroraModuleManifest { 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.main !== undefined) validateRelativePackagePath(m.main, 'main') - if (m.creation && typeof m.creation === 'object') validateSettings((m.creation as Record).setup ?? [], 'creation.setup') + if (m.creation && typeof m.creation === 'object') { + const creation = m.creation as Record + validateSettings(creation.setup ?? [], 'creation.setup') + if (creation.phpVersions !== undefined && (!Array.isArray(creation.phpVersions) || !creation.phpVersions.every((version) => typeof version === 'string' && /^\d+\.\d+$/.test(version)))) throw new Error('creation.phpVersions must contain PHP minor versions') + } if (m.project !== undefined) { if (!m.project || typeof m.project !== 'object' || Array.isArray(m.project)) throw new Error('project must be an object') const project = m.project as Record diff --git a/src/main/moduleRuntime.ts b/src/main/moduleRuntime.ts index 9d3a312..d566f46 100644 --- a/src/main/moduleRuntime.ts +++ b/src/main/moduleRuntime.ts @@ -35,6 +35,7 @@ export async function runModuleLifecycleHook(moduleId: string, hook: AuroraModul if (typeof handler !== 'function') return false const directory = options.directory const urls = options.projectName ? projectUrls(options.projectName) : undefined + const projectConfig = directory ? await getProjectConfig(directory) : undefined const run = async (label: string, command: string, args: string[]): Promise => { if (options.sender && options.operationId) { if (!options.sender.isDestroyed()) options.sender.send('terminal:data', { operationId: options.operationId, stream: 'stdout', chunk: `\n[${manifest.name}] ${label}\n` }) @@ -49,6 +50,7 @@ export async function runModuleLifecycleHook(moduleId: string, hook: AuroraModul projectName: options.projectName, toolId: options.toolId, settings: Object.freeze({ ...(options.settings ?? {}) }), + environment: projectConfig ? Object.freeze({ php: projectConfig.php, node: projectConfig.node, webserver: projectConfig.webserver, database: projectConfig.database, databaseVersion: projectConfig.databaseVersion, docroot: projectConfig.docroot }) : undefined, urls: urls ? Object.freeze(urls) : undefined, run, ensureRouter, diff --git a/src/renderer/src/components/create/CreateProjectModal.tsx b/src/renderer/src/components/create/CreateProjectModal.tsx index a09d801..ade76c2 100644 --- a/src/renderer/src/components/create/CreateProjectModal.tsx +++ b/src/renderer/src/components/create/CreateProjectModal.tsx @@ -224,7 +224,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
- {applicationModules.map((module) => ({ value: module.id, label: module.name, defaults: module.defaults })).map((t) => { + {applicationModules.map((module) => ({ value: module.id, label: module.name, defaults: module.defaults, creation: module.creation })).map((t) => { const Icon = TYPE_ICONS[t.value] ?? Boxes const isSelected = projectType === t.value @@ -234,6 +234,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. type="button" onClick={() => { setProjectType(t.value) + setPhpVersion(t.creation?.phpVersions?.[0] ?? '8.4') if (!docroot.trim() && t.defaults?.docroot) setDocroot(t.defaults.docroot) }} className={clsx( @@ -272,7 +273,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.

Aurora core owns the runtime; the application is a module layered on top.

-
+
diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index 8fefa47..47d48db 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -162,10 +162,10 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element { const services = Object.values(project.services) const runningServices = services.filter((service) => service.status === 'running').length - const phpVersions = - project.php_version && !PHP_VERSIONS.includes(project.php_version) - ? [project.php_version, ...PHP_VERSIONS] - : PHP_VERSIONS + const supportedPhpVersions = applicationManifest?.creation?.phpVersions ?? PHP_VERSIONS + const phpVersions = project.php_version && !supportedPhpVersions.includes(project.php_version) + ? [project.php_version, ...supportedPhpVersions] + : supportedPhpVersions const currentDatabase = `${project.dbinfo.database_type}:${project.dbinfo.database_version}` const projectContribution = applicationManifest?.project diff --git a/src/shared/types.ts b/src/shared/types.ts index 4f9e14b..77b78f9 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -143,6 +143,7 @@ export interface AuroraModuleManifest { aurora: { core: string; moduleApi: string } creation?: { databases?: Array<'mariadb' | 'mysql' | 'postgres'> + phpVersions?: string[] setup?: AuroraModuleSetting[] intro?: { title: string; description: string; icon?: string } } diff --git a/templates/aurora-module/README.md b/templates/aurora-module/README.md index 7e77924..2681a7a 100644 --- a/templates/aurora-module/README.md +++ b/templates/aurora-module/README.md @@ -16,4 +16,4 @@ The resulting `.pac` file is written to `dist/module-catalog/`. Lifecycle exports are optional. `projectCreate` runs during initial provisioning, `projectStart` after the containers start, `projectRemove` before project-scoped removal, and `packageUninstall` before the installed package is deleted. A manifest entry in `project.tools` calls `projectTool(context)` with its `id` in `context.toolId`. -The context provides `moduleId`, `hook`, `directory`, `projectName`, `settings`, `urls`, and `toolId`. It also provides `run(label, command, args)`, `ensureRouter()`, `setProjectMetadata(values)`, and `saveCredentials(values)`. Project-only values and helpers are unavailable to `packageUninstall`. +The context provides `moduleId`, `hook`, `directory`, `projectName`, `settings`, `urls`, `environment`, and `toolId`. `environment` contains the project's PHP, Node.js, web-server, database, database-version, and document-root choices. The context also provides `run(label, command, args)`, `ensureRouter()`, `setProjectMetadata(values)`, and `saveCredentials(values)`. Project-only values and helpers are unavailable to `packageUninstall`.