From 5409d91383f0df28258bb1bb4dbab638b3138f02 Mon Sep 17 00:00:00 2001 From: reaper Date: Fri, 14 Aug 2026 17:15:34 -0500 Subject: [PATCH] refactor: complete external module contract --- .../aurora-module-wordpress/manifest.json | 22 +++++++++- src/main/auroraEngine.ts | 11 +---- src/main/moduleRegistry.test.ts | 30 ++++++++++++- src/main/moduleRegistry.ts | 23 +++++++++- .../create/CreateProjectModal.test.tsx | 44 +++++++++++++++++++ .../components/create/CreateProjectModal.tsx | 2 +- .../src/components/projects/ProjectDetail.tsx | 29 +++++++++--- .../components/projects/ProjectList.test.tsx | 27 ++++++++++++ .../src/components/projects/ProjectList.tsx | 2 +- src/shared/types.ts | 15 ++++++- 10 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/components/create/CreateProjectModal.test.tsx create mode 100644 src/renderer/src/components/projects/ProjectList.test.tsx diff --git a/packages/aurora-module-wordpress/manifest.json b/packages/aurora-module-wordpress/manifest.json index 0d7c731..04bf924 100644 --- a/packages/aurora-module-wordpress/manifest.json +++ b/packages/aurora-module-wordpress/manifest.json @@ -23,5 +23,25 @@ { "id": "wp_debug", "label": "Enable WP_DEBUG", "type": "boolean", "default": true, "advanced": true } ] }, - "project": { "adminPath": "/wp-admin/" } + "project": { + "adminPath": "/wp-admin/", + "actions": [ + { + "id": "network-admin", + "label": "Network Admin", + "path": "/wp-admin/network/", + "metadataKey": "multisite", + "hiddenValues": ["none", ""] + } + ], + "summary": { + "metadataKey": "multisite", + "labels": { + "none": "Single site", + "subdirectory": "Multisite · Subdirectory", + "subdomain": "Multisite · Subdomain" + }, + "fallback": "Single site" + } + } } diff --git a/src/main/auroraEngine.ts b/src/main/auroraEngine.ts index 0794386..746d0d9 100644 --- a/src/main/auroraEngine.ts +++ b/src/main/auroraEngine.ts @@ -235,9 +235,8 @@ export async function describeProject(name: string): Promise 0 && ps.every(p=>p.State==='running'); const currentRouterStatus = await routerStatus(); const urlSet=projectUrls(c.name); const primary=c.primaryProtocol === 'http' ? urlSet.http : urlSet.https const services: Record = {}; 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:[]} - 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:c.webserver,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 } + const moduleMetadata = { ...(c.wordpressMultisite ? { multisite: c.wordpressMultisite } : {}), ...c.moduleMetadata } + 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:c.webserver,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(),module_metadata:moduleMetadata,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;webserverType?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise{ const c=await readConfig(root) @@ -253,12 +252,6 @@ export async function updateEnvironment(root:string, updates:{phpVersion?:string export async function getProjectConfig(root: string): Promise { return readConfig(root) } -export async function setWordpressMultisite(root: string, mode: 'none' | 'subdirectory' | 'subdomain'): Promise { - const config = await readConfig(root) - config.wordpressMultisite = mode - await writeConfig(root, config) -} - export async function setProjectModuleMetadata(root: string, metadata: Record): Promise { const config = await readConfig(root) config.moduleMetadata = { ...config.moduleMetadata, ...metadata } diff --git a/src/main/moduleRegistry.test.ts b/src/main/moduleRegistry.test.ts index e6e03c7..39062ca 100644 --- a/src/main/moduleRegistry.test.ts +++ b/src/main/moduleRegistry.test.ts @@ -1,5 +1,5 @@ -import { mkdtemp, mkdir, symlink, writeFile } from 'fs/promises' -import { join } from 'path' +import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'fs/promises' +import { join, resolve } from 'path' import { tmpdir } from 'os' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -29,6 +29,9 @@ describe('external module registry', () => { 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/) + 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/) }) 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')) @@ -39,4 +42,27 @@ describe('external module registry', () => { expect(await getModuleRegistry(userData)).toEqual([]) expect(await import('fs/promises').then(({ stat }) => stat(join(source, 'manifest.json')))).toBeTruthy() }) + + it('updates an installed package atomically at the same registry id', async () => { + const userData = await temp('aurora-user-') + await installModulePackage(await packageDir(), userData) + await installModulePackage(await packageDir({ ...manifest, version: '1.1.0' }), userData) + expect((await getModuleRegistry(userData)).map((item) => item.version)).toEqual(['1.1.0']) + }) + + it('uninstalls only the package and preserves existing project data', async () => { + const userData = await temp('aurora-user-') + const project = await temp('aurora-project-') + const sentinel = join(project, 'site-content.txt') + await writeFile(sentinel, 'keep me') + await installModulePackage(await packageDir(), userData) + await uninstallModulePackage('sample-app', userData) + expect(await readFile(sentinel, 'utf8')).toBe('keep me') + }) + + it('accepts the independently packaged WordPress module contract', async () => { + const packageRoot = resolve(process.cwd(), 'packages/aurora-module-wordpress') + const actual = JSON.parse(await readFile(join(packageRoot, 'manifest.json'), 'utf8')) + expect(validateModuleManifest(actual).id).toBe('wordpress') + }) }) diff --git a/src/main/moduleRegistry.ts b/src/main/moduleRegistry.ts index 2acb92d..b75566f 100644 --- a/src/main/moduleRegistry.ts +++ b/src/main/moduleRegistry.ts @@ -25,6 +25,12 @@ function validateSettings(value: unknown, field: string): asserts value is Auror } } +function validateRelativePackagePath(value: unknown, field: string): void { + if (typeof value !== 'string' || !value.trim() || isAbsolute(value)) throw new Error(`${field} must be a relative package path`) + const normalized = value.replace(/\\/g, '/') + if (normalized.split('/').some((part) => part === '..')) throw new Error(`${field} may not leave the module package`) +} + 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 @@ -33,13 +39,28 @@ export function validateModuleManifest(value: unknown): AuroraModuleManifest { 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`) + for (const field of ['dependencies', 'conflicts'] as const) if (!Array.isArray(m[field]) || !(m[field] as unknown[]).every((x) => typeof x === 'string' && /^[a-z][a-z0-9-]{1,63}$/.test(x))) throw new Error(`${field} must be a module id 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.main !== undefined) validateRelativePackagePath(m.main, 'main') if (m.creation && typeof m.creation === 'object') validateSettings((m.creation as Record).setup ?? [], 'creation.setup') + 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 + if (project.adminPath !== undefined && (typeof project.adminPath !== 'string' || !project.adminPath.startsWith('/'))) throw new Error('project.adminPath must start with /') + if (project.actions !== undefined) { + if (!Array.isArray(project.actions)) throw new Error('project.actions must be an array') + for (const actionValue of project.actions) { + const action = actionValue as Record + if (!action || typeof action !== 'object' || typeof action.id !== 'string' || typeof action.label !== 'string' || typeof action.path !== 'string' || !action.path.startsWith('/')) throw new Error('Invalid project action') + if (action.metadataKey !== undefined && typeof action.metadataKey !== 'string') throw new Error('Invalid project action metadataKey') + if (action.hiddenValues !== undefined && !Array.isArray(action.hiddenValues)) throw new Error('Invalid project action hiddenValues') + } + } + } return value as AuroraModuleManifest } diff --git a/src/renderer/src/components/create/CreateProjectModal.test.tsx b/src/renderer/src/components/create/CreateProjectModal.test.tsx new file mode 100644 index 0000000..7c3443c --- /dev/null +++ b/src/renderer/src/components/create/CreateProjectModal.test.tsx @@ -0,0 +1,44 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import type { AuroraModuleManifest } from '@shared/types' +import { CreateProjectModal } from './CreateProjectModal' + +const wordpress: AuroraModuleManifest = { + id: 'wordpress', + name: 'WordPress', + version: '1.2.0', + category: 'application', + description: 'CMS', + dependencies: [], + conflicts: [], + settings: [], + aurora: { core: '2.0.0-alpha.24', moduleApi: '1.0.0' } +} + +function renderModal(modules: AuroraModuleManifest[]): { queryClient: QueryClient } { + vi.stubGlobal('api', { + modules: { listRegistry: vi.fn().mockResolvedValue(modules) }, + create: { pickDirectory: vi.fn(), project: vi.fn() }, + terminal: { onData: vi.fn().mockReturnValue(() => {}), onExit: vi.fn().mockReturnValue(() => {}) } + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + return { queryClient } +} + +describe('external application choices', () => { + it('shows the module-install route when the registry is empty', async () => { + renderModal([]) + expect(await screen.findByText('No application modules installed')).toBeInTheDocument() + expect(screen.getByText(/bottom of the sidebar/)).toBeInTheDocument() + }) + + it('adds and removes an installed application without rebuilding Core', async () => { + const { queryClient } = renderModal([wordpress]) + expect(await screen.findByText('WordPress')).toBeInTheDocument() + queryClient.setQueryData(['modules', 'registry'], []) + await waitFor(() => expect(screen.queryByText('WordPress')).not.toBeInTheDocument()) + expect(screen.getByText('No application modules installed')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/create/CreateProjectModal.tsx b/src/renderer/src/components/create/CreateProjectModal.tsx index 2517ee7..a09d801 100644 --- a/src/renderer/src/components/create/CreateProjectModal.tsx +++ b/src/renderer/src/components/create/CreateProjectModal.tsx @@ -83,7 +83,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React. return } - // Post-create (starting the project, downloading/installing WordPress, + // Module post-create hooks (starting services, provisioning an application, // etc.) can run long. Close the modal as soon as the quick `Aurora // config` step succeeds instead of blocking the whole wizard on it — // the terminal panel already tracks and surfaces this operation's diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index e40306d..a1f42ae 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -165,6 +165,17 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element { : PHP_VERSIONS const currentDatabase = `${project.dbinfo.database_type}:${project.dbinfo.database_version}` + const projectContribution = applicationManifest?.project + const summaryContribution = projectContribution?.summary + const summaryValue = summaryContribution ? project.module_metadata?.[summaryContribution.metadataKey] : undefined + const projectSummary = summaryContribution + ? summaryContribution.labels[String(summaryValue ?? '')] ?? summaryContribution.fallback + : undefined + const projectActions = (projectContribution?.actions ?? []).filter((action) => { + if (!action.metadataKey) return true + const value = project.module_metadata?.[action.metadataKey] + return !(action.hiddenValues ?? []).includes(value ?? '') + }) 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 @@ -178,6 +189,12 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element { return (
+ {project.module_available === false && ( +
+

Required application module is missing

+

Install {project.missing_module_id ?? project.type} from Modules to restore application-specific setup and tools. Existing project files and databases have not been changed.

+
+ )}
@@ -237,11 +254,11 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element { Application Admin )} - {applicationManifest?.project?.adminPath && isRunning && project.wordpress_network_admin_url && ( - - Network Admin + {isRunning && projectActions.map((action) => ( + + {action.label} - )} + ))}
@@ -368,7 +385,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {

Primary protocol

-

Both remain available. WordPress canonical URLs follow this setting.

+

Both remain available. The application’s canonical URL follows this setting when supported by its module.

{(['http','https'] as const).map((protocol) => { const active = project.primary_url.startsWith(`${protocol}:`); return })} diff --git a/src/renderer/src/components/projects/ProjectList.test.tsx b/src/renderer/src/components/projects/ProjectList.test.tsx new file mode 100644 index 0000000..7c28478 --- /dev/null +++ b/src/renderer/src/components/projects/ProjectList.test.tsx @@ -0,0 +1,27 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { ProjectList } from './ProjectList' + +describe('project compatibility state', () => { + it('reports an existing project whose application module is missing', async () => { + vi.stubGlobal('api', { projects: { list: vi.fn().mockResolvedValue([{ + name: 'legacy-site', + status: 'stopped', + status_desc: 'Missing application module: legacy-cms', + type: 'legacy-cms', + approot: '/projects/legacy-site', + shortroot: '/projects/legacy-site', + docroot: '', + primary_url: 'https://legacy-site.aurora.localhost', + httpurl: 'http://legacy-site.aurora.localhost', + httpsurl: 'https://legacy-site.aurora.localhost', + mutagen_enabled: false, + module_available: false, + missing_module_id: 'legacy-cms' + }]) } }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render() + expect(await screen.findByText('Missing application module: legacy-cms')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/projects/ProjectList.tsx b/src/renderer/src/components/projects/ProjectList.tsx index c6986fe..e8139d3 100644 --- a/src/renderer/src/components/projects/ProjectList.tsx +++ b/src/renderer/src/components/projects/ProjectList.tsx @@ -68,7 +68,7 @@ export function ProjectList(): React.JSX.Element {
- {project.type} · {project.shortroot} + {project.module_available === false ? project.status_desc : `${project.type} · ${project.shortroot}`} diff --git a/src/shared/types.ts b/src/shared/types.ts index 8618810..f90767e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -69,8 +69,7 @@ export interface AuroraProjectDetail extends AuroraProjectSummary { ca_trust_status?: 'trusted' | 'not-trusted' | 'unknown' firefox_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown' chromium_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown' - wordpress_multisite?: 'none' | 'subdirectory' | 'subdomain' - wordpress_network_admin_url?: string + module_metadata?: Record adminer_url?: string services: Record xdebug_enabled: boolean @@ -150,6 +149,18 @@ export interface AuroraModuleManifest { hooks?: Partial> project?: { adminPath?: string + actions?: Array<{ + id: string + label: string + path: string + metadataKey?: string + hiddenValues?: Array + }> + summary?: { + metadataKey: string + labels: Record + fallback?: string + } tools?: Array<{ id: string; label: string; hook: AuroraModuleLifecycleHook }> } compose?: {