refactor: complete external module contract
This commit is contained in:
@@ -23,5 +23,25 @@
|
|||||||
{ "id": "wp_debug", "label": "Enable WP_DEBUG", "type": "boolean", "default": true, "advanced": true }
|
{ "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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 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 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 moduleMetadata = { ...(c.wordpressMultisite ? { multisite: c.wordpressMultisite } : {}), ...c.moduleMetadata }
|
||||||
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(),module_metadata:moduleMetadata,adminer_url:c.modules.includes('adminer')?`https://adminer.${projectHost(c.name)}`:undefined,services,xdebug_enabled:c.xdebug===true }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
export async function updateEnvironment(root:string, updates:{phpVersion?:string;nodeVersion?:string;webserverType?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise<void>{
|
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)
|
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 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> {
|
export async function setProjectModuleMetadata(root: string, metadata: Record<string, string | number | boolean>): Promise<void> {
|
||||||
const config = await readConfig(root)
|
const config = await readConfig(root)
|
||||||
config.moduleMetadata = { ...config.moduleMetadata, ...metadata }
|
config.moduleMetadata = { ...config.moduleMetadata, ...metadata }
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { mkdtemp, mkdir, symlink, writeFile } from 'fs/promises'
|
import { mkdtemp, mkdir, readFile, symlink, writeFile } from 'fs/promises'
|
||||||
import { join } from 'path'
|
import { join, resolve } from 'path'
|
||||||
import { tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
@@ -29,6 +29,9 @@ describe('external module registry', () => {
|
|||||||
it('rejects invalid and incompatible manifests', () => {
|
it('rejects invalid and incompatible manifests', () => {
|
||||||
expect(() => validateModuleManifest({ ...manifest, id: '../escape' })).toThrow(/module id/)
|
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, 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 () => {
|
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'))
|
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 getModuleRegistry(userData)).toEqual([])
|
||||||
expect(await import('fs/promises').then(({ stat }) => stat(join(source, 'manifest.json')))).toBeTruthy()
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
export function validateModuleManifest(value: unknown): AuroraModuleManifest {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Module manifest must be an object')
|
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Module manifest must be an object')
|
||||||
const m = value as Record<string, unknown>
|
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 (!validVersion(m.version)) throw new Error('Invalid module version')
|
||||||
if (!['application', 'service', 'tool'].includes(String(m.category))) throw new Error('Invalid module category')
|
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')
|
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')
|
validateSettings(m.settings, 'settings')
|
||||||
const aurora = m.aurora as Record<string, unknown> | undefined
|
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 (!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.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 (!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.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
|
return value as AuroraModuleManifest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(<QueryClientProvider client={queryClient}><CreateProjectModal onClose={vi.fn()} /></QueryClientProvider>)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -83,7 +83,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
return
|
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
|
// etc.) can run long. Close the modal as soon as the quick `Aurora
|
||||||
// config` step succeeds instead of blocking the whole wizard on it —
|
// config` step succeeds instead of blocking the whole wizard on it —
|
||||||
// the terminal panel already tracks and surfaces this operation's
|
// the terminal panel already tracks and surfaces this operation's
|
||||||
|
|||||||
@@ -165,6 +165,17 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
: PHP_VERSIONS
|
: PHP_VERSIONS
|
||||||
|
|
||||||
const currentDatabase = `${project.dbinfo.database_type}:${project.dbinfo.database_version}`
|
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 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)
|
const databaseOptions = allowedDatabaseOptions.some((o) => o.value === currentDatabase)
|
||||||
? DATABASE_OPTIONS
|
? DATABASE_OPTIONS
|
||||||
@@ -178,6 +189,12 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-6">
|
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-6">
|
||||||
|
{project.module_available === false && (
|
||||||
|
<div role="alert" className="rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100">
|
||||||
|
<p className="font-semibold">Required application module is missing</p>
|
||||||
|
<p className="mt-1">Install <span className="font-mono">{project.missing_module_id ?? project.type}</span> from Modules to restore application-specific setup and tools. Existing project files and databases have not been changed.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<header className="overflow-hidden rounded-2xl border border-cyan-200/80 bg-gradient-to-br from-white via-slate-50 to-cyan-50 text-neutral-950 shadow-[0_20px_55px_rgba(15,23,42,0.10)] dark:border-white/10 dark:bg-neutral-950 dark:bg-none dark:text-white dark:shadow-[0_24px_70px_rgba(0,0,0,0.28)]">
|
<header className="overflow-hidden rounded-2xl border border-cyan-200/80 bg-gradient-to-br from-white via-slate-50 to-cyan-50 text-neutral-950 shadow-[0_20px_55px_rgba(15,23,42,0.10)] dark:border-white/10 dark:bg-neutral-950 dark:bg-none dark:text-white dark:shadow-[0_24px_70px_rgba(0,0,0,0.28)]">
|
||||||
<div className="relative p-5">
|
<div className="relative p-5">
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(8,145,178,0.07)_1px,transparent_1px),linear-gradient(90deg,rgba(8,145,178,0.07)_1px,transparent_1px)] bg-[size:36px_36px] dark:bg-[linear-gradient(rgba(45,212,191,0.09)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.09)_1px,transparent_1px)]" />
|
<div className="absolute inset-0 bg-[linear-gradient(rgba(8,145,178,0.07)_1px,transparent_1px),linear-gradient(90deg,rgba(8,145,178,0.07)_1px,transparent_1px)] bg-[size:36px_36px] dark:bg-[linear-gradient(rgba(45,212,191,0.09)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.09)_1px,transparent_1px)]" />
|
||||||
@@ -237,11 +254,11 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
<KeyRound size={14} /> Application Admin
|
<KeyRound size={14} /> Application Admin
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{applicationManifest?.project?.adminPath && isRunning && project.wordpress_network_admin_url && (
|
{isRunning && projectActions.map((action) => (
|
||||||
<a href={project.wordpress_network_admin_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/[0.15]">
|
<a key={action.id} href={`${project.primary_url.replace(/\/$/, '')}${action.path}`} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white/80 px-3 py-1.5 text-sm font-medium text-neutral-700 shadow-sm transition hover:border-cyan-200 hover:bg-cyan-50 dark:border-white/10 dark:bg-white/10 dark:text-white dark:hover:bg-white/[0.15]">
|
||||||
<Globe2 size={14} /> Network Admin
|
<Globe2 size={14} /> {action.label}
|
||||||
</a>
|
</a>
|
||||||
)}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
@@ -258,7 +275,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
<p className="text-xs font-medium uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||||
Project type
|
Project type
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 truncate text-sm font-semibold text-neutral-900 dark:text-white">{applicationManifest?.name ?? project.type}</p>{applicationManifest?.project?.adminPath && <p className="mt-1 text-[11px] text-neutral-500 dark:text-neutral-400">{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}</p>}
|
<p className="mt-1 truncate text-sm font-semibold text-neutral-900 dark:text-white">{applicationManifest?.name ?? project.type}</p>{projectSummary && <p className="mt-1 text-[11px] text-neutral-500 dark:text-neutral-400">{projectSummary}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-neutral-200/80 bg-white/80 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/[0.08]">
|
<div className="rounded-xl border border-neutral-200/80 bg-white/80 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/[0.08]">
|
||||||
@@ -368,7 +385,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
|||||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200/70 pt-3 dark:border-white/10">
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200/70 pt-3 dark:border-white/10">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-neutral-700 dark:text-neutral-200">Primary protocol</p>
|
<p className="text-xs font-semibold text-neutral-700 dark:text-neutral-200">Primary protocol</p>
|
||||||
<p className="text-[11px] text-neutral-500">Both remain available. WordPress canonical URLs follow this setting.</p>
|
<p className="text-[11px] text-neutral-500">Both remain available. The application’s canonical URL follows this setting when supported by its module.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex rounded-lg border border-neutral-200 bg-neutral-100 p-1 dark:border-white/10 dark:bg-white/5">
|
<div className="inline-flex rounded-lg border border-neutral-200 bg-neutral-100 p-1 dark:border-white/10 dark:bg-white/5">
|
||||||
{(['http','https'] as const).map((protocol) => { const active = project.primary_url.startsWith(`${protocol}:`); return <button key={protocol} type="button" disabled={isEnvUpdating || !isRunning} onClick={() => void applyEnvironmentChange({ primaryProtocol: protocol })} className={clsx('rounded-md px-3 py-1.5 text-xs font-semibold uppercase transition disabled:cursor-not-allowed disabled:opacity-50', active ? 'bg-white text-cyan-700 shadow-sm dark:bg-neutral-800 dark:text-cyan-300' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white')}>{protocol}</button> })}
|
{(['http','https'] as const).map((protocol) => { const active = project.primary_url.startsWith(`${protocol}:`); return <button key={protocol} type="button" disabled={isEnvUpdating || !isRunning} onClick={() => void applyEnvironmentChange({ primaryProtocol: protocol })} className={clsx('rounded-md px-3 py-1.5 text-xs font-semibold uppercase transition disabled:cursor-not-allowed disabled:opacity-50', active ? 'bg-white text-cyan-700 shadow-sm dark:bg-neutral-800 dark:text-cyan-300' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white')}>{protocol}</button> })}
|
||||||
|
|||||||
@@ -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(<QueryClientProvider client={queryClient}><ProjectList /></QueryClientProvider>)
|
||||||
|
expect(await screen.findByText('Missing application module: legacy-cms')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -68,7 +68,7 @@ export function ProjectList(): React.JSX.Element {
|
|||||||
<StatusBadge status={project.status} />
|
<StatusBadge status={project.status} />
|
||||||
</div>
|
</div>
|
||||||
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{project.type} · {project.shortroot}
|
{project.module_available === false ? project.status_desc : `${project.type} · ${project.shortroot}`}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+13
-2
@@ -69,8 +69,7 @@ export interface AuroraProjectDetail extends AuroraProjectSummary {
|
|||||||
ca_trust_status?: 'trusted' | 'not-trusted' | 'unknown'
|
ca_trust_status?: 'trusted' | 'not-trusted' | 'unknown'
|
||||||
firefox_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
|
firefox_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
|
||||||
chromium_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
|
chromium_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
|
||||||
wordpress_multisite?: 'none' | 'subdirectory' | 'subdomain'
|
module_metadata?: Record<string, string | number | boolean>
|
||||||
wordpress_network_admin_url?: string
|
|
||||||
adminer_url?: string
|
adminer_url?: string
|
||||||
services: Record<string, AuroraService>
|
services: Record<string, AuroraService>
|
||||||
xdebug_enabled: boolean
|
xdebug_enabled: boolean
|
||||||
@@ -150,6 +149,18 @@ export interface AuroraModuleManifest {
|
|||||||
hooks?: Partial<Record<AuroraModuleLifecycleHook, AuroraModuleCommand[]>>
|
hooks?: Partial<Record<AuroraModuleLifecycleHook, AuroraModuleCommand[]>>
|
||||||
project?: {
|
project?: {
|
||||||
adminPath?: string
|
adminPath?: string
|
||||||
|
actions?: Array<{
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
path: string
|
||||||
|
metadataKey?: string
|
||||||
|
hiddenValues?: Array<string | number | boolean>
|
||||||
|
}>
|
||||||
|
summary?: {
|
||||||
|
metadataKey: string
|
||||||
|
labels: Record<string, string>
|
||||||
|
fallback?: string
|
||||||
|
}
|
||||||
tools?: Array<{ id: string; label: string; hook: AuroraModuleLifecycleHook }>
|
tools?: Array<{ id: string; label: string; hook: AuroraModuleLifecycleHook }>
|
||||||
}
|
}
|
||||||
compose?: {
|
compose?: {
|
||||||
|
|||||||
Reference in New Issue
Block a user