refactor: complete external module contract

This commit is contained in:
reaper
2026-08-14 17:15:34 -05:00
parent a3454e226e
commit 5409d91383
10 changed files with 182 additions and 23 deletions
+2 -9
View File
@@ -235,9 +235,8 @@ export async function describeProject(name: string): Promise<AuroraProjectDetail
const reg = await loadRegistry(); const root = reg.projects[name]; if (!root) throw new Error(`Aurora project '${name}' not found`)
const c = await readConfig(root); const ps = await composeJson(root); const running = ps.length > 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<string, any> = {}; for (const p of ps) services[p.Service]={short_name:p.Service,full_name:p.Name,status:p.State,image:p.Image,exposed_ports:'',host_ports:'',host_ports_mapping:[]}
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<void>{
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<AuroraConfig> { return readConfig(root) }
export async function setWordpressMultisite(root: string, mode: 'none' | 'subdirectory' | 'subdomain'): Promise<void> {
const config = await readConfig(root)
config.wordpressMultisite = mode
await writeConfig(root, config)
}
export async function setProjectModuleMetadata(root: string, metadata: Record<string, string | number | boolean>): Promise<void> {
const config = await readConfig(root)
config.moduleMetadata = { ...config.moduleMetadata, ...metadata }
+28 -2
View File
@@ -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')
})
})
+22 -1
View File
@@ -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<string, unknown>
@@ -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<string, unknown> | undefined
if (!aurora || typeof aurora.core !== 'string' || typeof aurora.moduleApi !== 'string') throw new Error('Manifest must declare aurora.core and aurora.moduleApi')
if (!compatible(aurora.core, CORE_VERSION)) throw new Error(`Module requires Aurora Core '${aurora.core}', running '${CORE_VERSION}'`)
if (!compatible(aurora.moduleApi, MODULE_API_VERSION)) throw new Error(`Module API '${aurora.moduleApi}' is incompatible with '${MODULE_API_VERSION}'`)
if (m.main !== undefined) validateRelativePackagePath(m.main, 'main')
if (m.creation && typeof m.creation === 'object') validateSettings((m.creation as Record<string, unknown>).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<string, unknown>
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<string, unknown>
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
}