feat: run WordPress projects on native runtime
This commit is contained in:
+735
-146
File diff suppressed because it is too large
Load Diff
+190
-29
@@ -1,39 +1,200 @@
|
||||
import { ipcMain, type WebContents } from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, trustAuroraCA } from '../auroraEngine'
|
||||
import {
|
||||
listProjects,
|
||||
describeProject,
|
||||
getNativeProjectDefinition,
|
||||
getProjectRoot,
|
||||
unregisterProject,
|
||||
updateEnvironment,
|
||||
ensureRouter,
|
||||
trustAuroraCA
|
||||
} from '../auroraEngine'
|
||||
import { nativeServiceSpecs, startNativeProject, stopNativeProject } from '../native/nativeProject'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { runProjectLifecycleHooks } from '../moduleRuntime'
|
||||
import type { EnvironmentUpdate } from '../../shared/types'
|
||||
const composeArgs=(root:string,...args:string[]):string[]=>['compose','-f',`${root}/.aurora/compose.yaml`,...args]
|
||||
const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit'])
|
||||
function launchTerminal(command:string,args:string[]):Promise<void>{return new Promise((resolve,reject)=>{const child=spawn(command,args,{detached:true,stdio:'ignore'});child.once('error',reject);child.once('spawn',()=>{child.unref();resolve()})})}
|
||||
async function startProject(operationId:string,name:string,sender:WebContents,forceRecreate=false):Promise<void>{
|
||||
const root=await getProjectRoot(name); await updateEnvironment(root,{}); await ensureRouter()
|
||||
try { await runCommandStreamed(operationId,'docker',composeArgs(root,'up','-d','--build',...(forceRecreate?['--force-recreate']:[]),'--remove-orphans'),sender,{cwd:root,emitExit:false}); await runProjectLifecycleHooks(root,'projectStart',operationId,sender); if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:0,cancelled:false}) }
|
||||
catch(error){if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:1,cancelled:false});throw error}
|
||||
const composeArgs = (root: string, ...args: string[]): string[] => [
|
||||
'compose',
|
||||
'-f',
|
||||
`${root}/.aurora/compose.yaml`,
|
||||
...args
|
||||
]
|
||||
const allowedServices = new Set(['web', 'php', 'db', 'node', 'adminer', 'redis', 'mailpit'])
|
||||
function launchTerminal(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
|
||||
child.once('error', reject)
|
||||
child.once('spawn', () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
export function registerProjectsIpc():void{
|
||||
ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name))
|
||||
ipcMain.handle('projects:start',(e,id:string,name:string)=>startProject(id,name,e.sender))
|
||||
ipcMain.handle('projects:stop',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'down'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:restart',(e,id:string,name:string)=>startProject(id,name,e.sender,true))
|
||||
ipcMain.handle('projects:restartService',async(e,id:string,name:string,service:string)=>{if(!allowedServices.has(service))throw new Error('Invalid service');const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'restart',service),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:phpInfo',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);return runCommandStreamed(id,'docker',composeArgs(root,'exec','-T','php','php','-i'),e.sender,{cwd:root})})
|
||||
ipcMain.handle('projects:openTerminal',async(_e,name:string)=>{const root=await getProjectRoot(name);const candidates: Array<[string,string[]]>=process.platform==='linux'?[['ptyxis',['--new-window','--working-directory',root,'--title',`Aurora · ${name}`]],['x-terminal-emulator',['--new-window','--working-directory',root]],['gnome-terminal',[`--working-directory=${root}`]],['kgx',['--working-directory',root]],['konsole',['--workdir',root]]]:[];for(const [cmd,args] of candidates){try{await launchTerminal(cmd,args);return}catch{/* try the next supported terminal */}}throw new Error('No supported terminal application was found.')})
|
||||
ipcMain.handle('projects:delete',async(e,id:string,name:string,approot:string,deleteFiles:boolean)=>{
|
||||
let root=approot
|
||||
try { root=await getProjectRoot(name) } catch { /* use renderer-provided root for stale entries */ }
|
||||
try { await runProjectLifecycleHooks(root,'projectRemove',id,e.sender) } catch (error) { console.warn(`Aurora module cleanup for '${name}' failed:`, error) }
|
||||
async function startProject(
|
||||
operationId: string,
|
||||
name: string,
|
||||
sender: WebContents,
|
||||
forceRecreate = false
|
||||
): Promise<void> {
|
||||
const root = await getProjectRoot(name)
|
||||
await updateEnvironment(root, {})
|
||||
const native = await getNativeProjectDefinition(name)
|
||||
try {
|
||||
await runCommandStreamed(id,'docker',composeArgs(root,'down','-v','--remove-orphans'),e.sender,{cwd:root})
|
||||
if (native) {
|
||||
if (!sender.isDestroyed())
|
||||
sender.send('terminal:data', {
|
||||
operationId,
|
||||
stream: 'stdout',
|
||||
chunk: 'Starting Aurora Native database, PHP-FPM, and nginx…\n'
|
||||
})
|
||||
await startNativeProject(native)
|
||||
} else {
|
||||
await ensureRouter()
|
||||
await runCommandStreamed(
|
||||
operationId,
|
||||
'docker',
|
||||
composeArgs(
|
||||
root,
|
||||
'up',
|
||||
'-d',
|
||||
'--build',
|
||||
...(forceRecreate ? ['--force-recreate'] : []),
|
||||
'--remove-orphans'
|
||||
),
|
||||
sender,
|
||||
{ cwd: root, emitExit: false }
|
||||
)
|
||||
}
|
||||
await runProjectLifecycleHooks(root, 'projectStart', operationId, sender)
|
||||
if (!sender.isDestroyed())
|
||||
sender.send('terminal:exit', { operationId, exitCode: 0, cancelled: false })
|
||||
} catch (error) {
|
||||
// A malformed/missing compose file must never make a project undeletable.
|
||||
console.warn(`Aurora cleanup for '${name}' skipped:`, error)
|
||||
if (!sender.isDestroyed())
|
||||
sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false })
|
||||
throw error
|
||||
}
|
||||
await unregisterProject(name,deleteFiles)
|
||||
})
|
||||
ipcMain.handle('projects:trustCA',async()=>{ await trustAuroraCA(); await ensureRouter() })
|
||||
ipcMain.handle('projects:updateEnvironment',async(_e,_id:string,_name:string,root:string,updates:EnvironmentUpdate)=>{
|
||||
await updateEnvironment(root,updates)
|
||||
})
|
||||
}
|
||||
export function registerProjectsIpc(): void {
|
||||
ipcMain.handle('projects:list', () => listProjects())
|
||||
ipcMain.handle('projects:describe', (_e, name: string) => describeProject(name))
|
||||
ipcMain.handle('projects:start', (e, id: string, name: string) =>
|
||||
startProject(id, name, e.sender)
|
||||
)
|
||||
ipcMain.handle('projects:stop', async (e, id: string, name: string) => {
|
||||
const native = await getNativeProjectDefinition(name)
|
||||
if (native) {
|
||||
await stopNativeProject(native)
|
||||
if (!e.sender.isDestroyed())
|
||||
e.sender.send('terminal:exit', { operationId: id, exitCode: 0, cancelled: false })
|
||||
return
|
||||
}
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(id, 'docker', composeArgs(root, 'down'), e.sender, { cwd: root })
|
||||
})
|
||||
ipcMain.handle('projects:restart', async (e, id: string, name: string) => {
|
||||
const native = await getNativeProjectDefinition(name)
|
||||
if (native) await stopNativeProject(native)
|
||||
return startProject(id, name, e.sender, true)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'projects:restartService',
|
||||
async (e, id: string, name: string, service: string) => {
|
||||
if (!allowedServices.has(service)) throw new Error('Invalid service')
|
||||
const native = await getNativeProjectDefinition(name)
|
||||
if (native) {
|
||||
await stopNativeProject(native)
|
||||
return startProject(id, name, e.sender)
|
||||
}
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(id, 'docker', composeArgs(root, 'restart', service), e.sender, {
|
||||
cwd: root
|
||||
})
|
||||
}
|
||||
)
|
||||
ipcMain.handle('projects:phpInfo', async (e, id: string, name: string) => {
|
||||
const native = await getNativeProjectDefinition(name)
|
||||
if (native) {
|
||||
const php = nativeServiceSpecs(native)
|
||||
.find((spec) => spec.id.endsWith(':php'))
|
||||
?.command.replace(/php-fpm$/, 'php')
|
||||
if (!php) throw new Error('Native PHP executable not found.')
|
||||
return runCommandStreamed(id, php, ['-i'], e.sender, { cwd: native.root })
|
||||
}
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(
|
||||
id,
|
||||
'docker',
|
||||
composeArgs(root, 'exec', '-T', 'php', 'php', '-i'),
|
||||
e.sender,
|
||||
{ cwd: root }
|
||||
)
|
||||
})
|
||||
ipcMain.handle('projects:openTerminal', async (_e, name: string) => {
|
||||
const root = await getProjectRoot(name)
|
||||
const candidates: Array<[string, string[]]> =
|
||||
process.platform === 'linux'
|
||||
? [
|
||||
[
|
||||
'ptyxis',
|
||||
['--new-window', '--working-directory', root, '--title', `Aurora · ${name}`]
|
||||
],
|
||||
['x-terminal-emulator', ['--new-window', '--working-directory', root]],
|
||||
['gnome-terminal', [`--working-directory=${root}`]],
|
||||
['kgx', ['--working-directory', root]],
|
||||
['konsole', ['--workdir', root]]
|
||||
]
|
||||
: []
|
||||
for (const [cmd, args] of candidates) {
|
||||
try {
|
||||
await launchTerminal(cmd, args)
|
||||
return
|
||||
} catch {
|
||||
/* try the next supported terminal */
|
||||
}
|
||||
}
|
||||
throw new Error('No supported terminal application was found.')
|
||||
})
|
||||
ipcMain.handle(
|
||||
'projects:delete',
|
||||
async (e, id: string, name: string, approot: string, deleteFiles: boolean) => {
|
||||
let root = approot
|
||||
try {
|
||||
root = await getProjectRoot(name)
|
||||
} catch {
|
||||
/* use renderer-provided root for stale entries */
|
||||
}
|
||||
try {
|
||||
await runProjectLifecycleHooks(root, 'projectRemove', id, e.sender)
|
||||
} catch (error) {
|
||||
console.warn(`Aurora module cleanup for '${name}' failed:`, error)
|
||||
}
|
||||
try {
|
||||
const native = await getNativeProjectDefinition(name).catch(() => null)
|
||||
if (native) await stopNativeProject(native)
|
||||
else
|
||||
await runCommandStreamed(
|
||||
id,
|
||||
'docker',
|
||||
composeArgs(root, 'down', '-v', '--remove-orphans'),
|
||||
e.sender,
|
||||
{ cwd: root }
|
||||
)
|
||||
} catch (error) {
|
||||
// A malformed/missing compose file must never make a project undeletable.
|
||||
console.warn(`Aurora cleanup for '${name}' skipped:`, error)
|
||||
}
|
||||
await unregisterProject(name, deleteFiles)
|
||||
}
|
||||
)
|
||||
ipcMain.handle('projects:trustCA', async () => {
|
||||
await trustAuroraCA()
|
||||
await ensureRouter()
|
||||
})
|
||||
ipcMain.handle(
|
||||
'projects:updateEnvironment',
|
||||
async (_e, _id: string, _name: string, root: string, updates: EnvironmentUpdate) => {
|
||||
await updateEnvironment(root, updates)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+149
-37
@@ -6,77 +6,174 @@ import type { WebContents } from 'electron'
|
||||
import type { AuroraModuleLifecycleHook } from '../shared/types'
|
||||
import { getModuleManifest, moduleDirectory } from './moduleRegistry'
|
||||
import { runCommandStreamed } from './commandRunner'
|
||||
import { AURORA_ENV, ensureRouter, getProjectConfig, projectUrls, setProjectModuleMetadata } from './auroraEngine'
|
||||
import {
|
||||
AURORA_ENV,
|
||||
ensureRouter,
|
||||
getNativeProjectDefinition,
|
||||
getProjectConfig,
|
||||
projectUrls,
|
||||
setProjectModuleMetadata
|
||||
} from './auroraEngine'
|
||||
import { startNativeProject } from './native/nativeProject'
|
||||
import { runtimeRoot } from './nativeRuntime'
|
||||
import { saveSiteCredentials } from './ipc/secrets'
|
||||
|
||||
type Settings = Record<string, string | number | boolean>
|
||||
type ExternalHook = (context: Record<string, unknown>) => Promise<void> | void
|
||||
type LoadedModule = Partial<Record<AuroraModuleLifecycleHook, ExternalHook>>
|
||||
type HookOptions = { directory?: string; projectName?: string; settings?: Settings; toolId?: string; operationId?: string; sender?: WebContents }
|
||||
type HookOptions = {
|
||||
directory?: string
|
||||
projectName?: string
|
||||
settings?: Settings
|
||||
toolId?: string
|
||||
operationId?: string
|
||||
sender?: WebContents
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const CORE_MODULE_IDS = new Set(['adminer', 'redis', 'mailpit'])
|
||||
|
||||
function sendExit(sender: WebContents, operationId: string, exitCode: number): void {
|
||||
if (!sender.isDestroyed()) sender.send('terminal:exit', { operationId, exitCode, cancelled: false })
|
||||
if (!sender.isDestroyed())
|
||||
sender.send('terminal:exit', { operationId, exitCode, cancelled: false })
|
||||
}
|
||||
|
||||
function loadModule(moduleId: string, main: string): LoadedModule {
|
||||
const root = resolve(moduleDirectory(), moduleId)
|
||||
const entry = resolve(root, main)
|
||||
if (entry !== root && !entry.startsWith(`${root}${sep}`)) throw new Error('Unsafe module main entry')
|
||||
if (entry !== root && !entry.startsWith(`${root}${sep}`))
|
||||
throw new Error('Unsafe module main entry')
|
||||
return createRequire(join(dirname(entry), 'loader.cjs'))(entry) as LoadedModule
|
||||
}
|
||||
|
||||
export async function runModuleLifecycleHook(moduleId: string, hook: AuroraModuleLifecycleHook, options: HookOptions = {}): Promise<boolean> {
|
||||
export async function runModuleLifecycleHook(
|
||||
moduleId: string,
|
||||
hook: AuroraModuleLifecycleHook,
|
||||
options: HookOptions = {}
|
||||
): Promise<boolean> {
|
||||
const manifest = await getModuleManifest(moduleId)
|
||||
if (!manifest.main) return false
|
||||
const handler = loadModule(moduleId, manifest.main)[hook]
|
||||
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 nativeDefinition = options.projectName
|
||||
? await getNativeProjectDefinition(options.projectName)
|
||||
: null
|
||||
const urls =
|
||||
projectConfig?.runtimeEngine === 'native' && projectConfig.nativePorts
|
||||
? {
|
||||
http: `http://127.0.0.1:${projectConfig.nativePorts.http}`,
|
||||
https: `http://127.0.0.1:${projectConfig.nativePorts.http}`
|
||||
}
|
||||
: options.projectName
|
||||
? projectUrls(options.projectName)
|
||||
: undefined
|
||||
const run = async (label: string, command: string, args: string[]): Promise<void> => {
|
||||
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` })
|
||||
return runCommandStreamed(options.operationId, command, args, options.sender, { cwd: directory ?? resolve(moduleDirectory(), moduleId), emitExit: false })
|
||||
if (!options.sender.isDestroyed())
|
||||
options.sender.send('terminal:data', {
|
||||
operationId: options.operationId,
|
||||
stream: 'stdout',
|
||||
chunk: `\n[${manifest.name}] ${label}\n`
|
||||
})
|
||||
return runCommandStreamed(options.operationId, command, args, options.sender, {
|
||||
cwd: directory ?? resolve(moduleDirectory(), moduleId),
|
||||
emitExit: false
|
||||
})
|
||||
}
|
||||
await execFileAsync(command, args, { cwd: directory ?? resolve(moduleDirectory(), moduleId), env: AURORA_ENV })
|
||||
await execFileAsync(command, args, {
|
||||
cwd: directory ?? resolve(moduleDirectory(), moduleId),
|
||||
env: AURORA_ENV
|
||||
})
|
||||
}
|
||||
await handler(Object.freeze({
|
||||
moduleId,
|
||||
hook,
|
||||
directory,
|
||||
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,
|
||||
setProjectMetadata: async (metadata: Settings) => {
|
||||
if (!directory) throw new Error('Project metadata is unavailable outside a project lifecycle hook')
|
||||
await setProjectModuleMetadata(directory, metadata)
|
||||
},
|
||||
saveCredentials: async (credentials: { platform: string; adminUrl: string; username: string; password: string; email: string }) => {
|
||||
if (!directory) throw new Error('Project credentials are unavailable outside a project lifecycle hook')
|
||||
await saveSiteCredentials(directory, credentials)
|
||||
}
|
||||
}))
|
||||
await handler(
|
||||
Object.freeze({
|
||||
moduleId,
|
||||
hook,
|
||||
directory,
|
||||
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,
|
||||
runtimeEngine: projectConfig.runtimeEngine ?? 'container'
|
||||
})
|
||||
: undefined,
|
||||
urls: urls ? Object.freeze(urls) : undefined,
|
||||
native: nativeDefinition
|
||||
? Object.freeze({
|
||||
php: join(runtimeRoot(), 'bin', 'php'),
|
||||
wp: join(runtimeRoot(), 'bin', 'wp'),
|
||||
databaseHost: '127.0.0.1',
|
||||
databasePort: nativeDefinition.ports.database,
|
||||
start: () => startNativeProject(nativeDefinition)
|
||||
})
|
||||
: undefined,
|
||||
run,
|
||||
ensureRouter,
|
||||
setProjectMetadata: async (metadata: Settings) => {
|
||||
if (!directory)
|
||||
throw new Error('Project metadata is unavailable outside a project lifecycle hook')
|
||||
await setProjectModuleMetadata(directory, metadata)
|
||||
},
|
||||
saveCredentials: async (credentials: {
|
||||
platform: string
|
||||
adminUrl: string
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
}) => {
|
||||
if (!directory)
|
||||
throw new Error('Project credentials are unavailable outside a project lifecycle hook')
|
||||
await saveSiteCredentials(directory, credentials)
|
||||
}
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function runProjectLifecycleHooks(directory: string, hook: 'projectStart' | 'projectRemove', operationId: string, sender: WebContents): Promise<void> {
|
||||
export async function runProjectLifecycleHooks(
|
||||
directory: string,
|
||||
hook: 'projectStart' | 'projectRemove',
|
||||
operationId: string,
|
||||
sender: WebContents
|
||||
): Promise<void> {
|
||||
const config = await getProjectConfig(directory)
|
||||
for (const moduleId of config.modules) {
|
||||
if (CORE_MODULE_IDS.has(moduleId)) continue
|
||||
await runModuleLifecycleHook(moduleId, hook, { directory, projectName: config.name, settings: config.moduleSettings?.[moduleId], operationId, sender })
|
||||
await runModuleLifecycleHook(moduleId, hook, {
|
||||
directory,
|
||||
projectName: config.name,
|
||||
settings: config.moduleSettings?.[moduleId],
|
||||
operationId,
|
||||
sender
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function runModuleProjectCreate(moduleId: string, operationId: string, directory: string, projectName: string, settings: Settings, sender: WebContents): Promise<void> {
|
||||
export async function runModuleProjectCreate(
|
||||
moduleId: string,
|
||||
operationId: string,
|
||||
directory: string,
|
||||
projectName: string,
|
||||
settings: Settings,
|
||||
sender: WebContents
|
||||
): Promise<void> {
|
||||
try {
|
||||
await runModuleLifecycleHook(moduleId, 'projectCreate', { directory, projectName, settings, operationId, sender })
|
||||
await runModuleLifecycleHook(moduleId, 'projectCreate', {
|
||||
directory,
|
||||
projectName,
|
||||
settings,
|
||||
operationId,
|
||||
sender
|
||||
})
|
||||
sendExit(sender, operationId, 0)
|
||||
} catch (error) {
|
||||
sendExit(sender, operationId, 1)
|
||||
@@ -84,13 +181,28 @@ export async function runModuleProjectCreate(moduleId: string, operationId: stri
|
||||
}
|
||||
}
|
||||
|
||||
export async function runModuleProjectTool(moduleId: string, toolId: string, operationId: string, directory: string, sender: WebContents): Promise<void> {
|
||||
export async function runModuleProjectTool(
|
||||
moduleId: string,
|
||||
toolId: string,
|
||||
operationId: string,
|
||||
directory: string,
|
||||
sender: WebContents
|
||||
): Promise<void> {
|
||||
const manifest = await getModuleManifest(moduleId)
|
||||
if (!manifest.project?.tools?.some((tool) => tool.id === toolId)) throw new Error(`Unknown module tool '${toolId}'`)
|
||||
if (!manifest.project?.tools?.some((tool) => tool.id === toolId))
|
||||
throw new Error(`Unknown module tool '${toolId}'`)
|
||||
const config = await getProjectConfig(directory)
|
||||
if (!config.modules.includes(moduleId)) throw new Error(`Module '${moduleId}' is not installed in this project`)
|
||||
if (!config.modules.includes(moduleId))
|
||||
throw new Error(`Module '${moduleId}' is not installed in this project`)
|
||||
try {
|
||||
const handled = await runModuleLifecycleHook(moduleId, 'projectTool', { directory, projectName: config.name, settings: config.moduleSettings?.[moduleId], toolId, operationId, sender })
|
||||
const handled = await runModuleLifecycleHook(moduleId, 'projectTool', {
|
||||
directory,
|
||||
projectName: config.name,
|
||||
settings: config.moduleSettings?.[moduleId],
|
||||
toolId,
|
||||
operationId,
|
||||
sender
|
||||
})
|
||||
if (!handled) throw new Error(`Module '${moduleId}' does not implement projectTool`)
|
||||
sendExit(sender, operationId, 0)
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { createRequire } from 'module'
|
||||
import { mkdtemp, readFile, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderMariaDbConfig, renderNginxConfig, renderPhpFpmConfig } from './nativeProject'
|
||||
import {
|
||||
renderMariaDbConfig,
|
||||
renderNginxConfig,
|
||||
renderPhpFpmConfig,
|
||||
startNativeProject,
|
||||
stopNativeProject
|
||||
} from './nativeProject'
|
||||
import { allocateNativePorts } from './portAllocator'
|
||||
|
||||
const project = {
|
||||
name: 'demo',
|
||||
@@ -7,6 +20,7 @@ const project = {
|
||||
docroot: 'public',
|
||||
ports: { http: 41001, php: 41002, database: 41003, node: 41004 }
|
||||
}
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
describe('native project configuration', () => {
|
||||
it('isolates PHP-FPM on its allocated loopback port', () =>
|
||||
@@ -25,3 +39,89 @@ describe('native project configuration', () => {
|
||||
expect(config).toContain('/.aurora/native/data/mariadb')
|
||||
})
|
||||
})
|
||||
|
||||
it.runIf(Boolean(process.env.AURORA_NATIVE_SMOKE_ROOT))(
|
||||
'serves PHP through the complete native stack',
|
||||
async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'aurora-native-project-'))
|
||||
await writeFile(join(root, 'index.php'), '<?php echo "aurora-native-ok";')
|
||||
const definition = {
|
||||
name: `smoke-${Date.now()}`,
|
||||
root,
|
||||
docroot: '',
|
||||
ports: await allocateNativePorts(),
|
||||
installedRuntimeRoot: process.env.AURORA_NATIVE_SMOKE_ROOT
|
||||
}
|
||||
try {
|
||||
await startNativeProject(definition)
|
||||
const response = await fetch(`http://127.0.0.1:${definition.ports.http}`)
|
||||
expect(await response.text()).toBe('aurora-native-ok')
|
||||
} finally {
|
||||
await stopNativeProject(definition)
|
||||
}
|
||||
},
|
||||
45000
|
||||
)
|
||||
|
||||
it.runIf(Boolean(process.env.AURORA_NATIVE_WORDPRESS_SMOKE_ROOT))(
|
||||
'provisions WordPress with the bundled native runtime',
|
||||
async () => {
|
||||
const runtime = process.env.AURORA_NATIVE_WORDPRESS_SMOKE_ROOT!
|
||||
const root = await mkdtemp(join(tmpdir(), 'aurora-native-wordpress-'))
|
||||
const definition = {
|
||||
name: `wordpress-${Date.now()}`,
|
||||
root,
|
||||
docroot: '',
|
||||
ports: await allocateNativePorts(),
|
||||
installedRuntimeRoot: runtime
|
||||
}
|
||||
const wordpress = createRequire(import.meta.url)(
|
||||
'../../../packages/aurora-module-wordpress/main/index.cjs'
|
||||
) as { projectCreate: (context: Record<string, unknown>) => Promise<void> }
|
||||
try {
|
||||
await wordpress.projectCreate({
|
||||
moduleId: 'wordpress',
|
||||
directory: root,
|
||||
projectName: definition.name,
|
||||
settings: {
|
||||
title: 'Aurora native smoke',
|
||||
admin_user: 'aurora-admin',
|
||||
admin_password: 'aurora-native-test-password',
|
||||
admin_email: '[email protected]',
|
||||
locale: 'en_US',
|
||||
multisite: 'none',
|
||||
wp_debug: false
|
||||
},
|
||||
environment: { runtimeEngine: 'native' },
|
||||
urls: {
|
||||
http: `http://127.0.0.1:${definition.ports.http}`,
|
||||
https: `http://127.0.0.1:${definition.ports.http}`
|
||||
},
|
||||
native: {
|
||||
php: join(runtime, 'bin', 'php'),
|
||||
wp: join(runtime, 'bin', 'wp'),
|
||||
databasePort: definition.ports.database,
|
||||
start: () => startNativeProject(definition)
|
||||
},
|
||||
run: async (_label: string, command: string, args: string[]) => {
|
||||
await execFileAsync(command, args, {
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
maxBuffer: 32 * 1024 * 1024
|
||||
})
|
||||
},
|
||||
setProjectMetadata: async () => undefined,
|
||||
saveCredentials: async () => undefined
|
||||
})
|
||||
expect(await readFile(join(root, 'wp-config.php'), 'utf8')).toContain(
|
||||
`127.0.0.1:${definition.ports.database}`
|
||||
)
|
||||
const response = await fetch(`http://127.0.0.1:${definition.ports.http}`)
|
||||
expect(response.ok).toBe(true)
|
||||
expect(await response.text()).toContain('Aurora native smoke')
|
||||
} finally {
|
||||
await stopNativeProject(definition)
|
||||
}
|
||||
},
|
||||
120000
|
||||
)
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface NativeProjectDefinition {
|
||||
root: string
|
||||
docroot: string
|
||||
ports: AuroraNativePorts
|
||||
installedRuntimeRoot?: string
|
||||
}
|
||||
|
||||
function installedRoot(project: NativeProjectDefinition): string {
|
||||
return project.installedRuntimeRoot ?? runtimeRoot()
|
||||
}
|
||||
|
||||
function nativeDirectory(root: string): string {
|
||||
@@ -53,6 +58,11 @@ error_log "${quoteNginx(join(directory, 'logs', 'nginx.log'))}" info;
|
||||
events { worker_connections 256; }
|
||||
http {
|
||||
access_log "${quoteNginx(join(directory, 'logs', 'nginx-access.log'))}";
|
||||
client_body_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-client'))}";
|
||||
proxy_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-proxy'))}";
|
||||
fastcgi_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-fastcgi'))}";
|
||||
uwsgi_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-uwsgi'))}";
|
||||
scgi_temp_path "${quoteNginx(join(directory, 'tmp', 'nginx-scgi'))}";
|
||||
server {
|
||||
listen 127.0.0.1:${project.ports.http};
|
||||
server_name localhost;
|
||||
@@ -65,9 +75,17 @@ http {
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||
fastcgi_param REQUEST_METHOD $request_method;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
fastcgi_param QUERY_STRING $query_string;
|
||||
fastcgi_param CONTENT_TYPE $content_type;
|
||||
fastcgi_param CONTENT_LENGTH $content_length;
|
||||
fastcgi_param DOCUMENT_ROOT $document_root;
|
||||
fastcgi_param SERVER_PROTOCOL $server_protocol;
|
||||
fastcgi_param SERVER_NAME $server_name;
|
||||
fastcgi_param SERVER_PORT $server_port;
|
||||
fastcgi_param HTTP_HOST $http_host;
|
||||
fastcgi_param REMOTE_ADDR $remote_addr;
|
||||
fastcgi_param REMOTE_PORT $remote_port;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,18 +112,32 @@ skip-name-resolve
|
||||
|
||||
export async function provisionNativeProject(project: NativeProjectDefinition): Promise<void> {
|
||||
const directory = nativeDirectory(project.root)
|
||||
for (const child of ['config', 'data/mariadb', 'logs', 'pids', 'tmp'])
|
||||
for (const child of [
|
||||
'config',
|
||||
'data/mariadb',
|
||||
'logs',
|
||||
'pids',
|
||||
'tmp',
|
||||
'tmp/nginx-client',
|
||||
'tmp/nginx-proxy',
|
||||
'tmp/nginx-fastcgi',
|
||||
'tmp/nginx-uwsgi',
|
||||
'tmp/nginx-scgi'
|
||||
])
|
||||
await mkdir(join(directory, child), { recursive: true })
|
||||
await Promise.all([
|
||||
writeFile(join(directory, 'config', 'php-fpm.conf'), renderPhpFpmConfig(project)),
|
||||
writeFile(join(directory, 'config', 'nginx.conf'), renderNginxConfig(project)),
|
||||
writeFile(join(directory, 'config', 'mariadb.cnf'), renderMariaDbConfig(project))
|
||||
writeFile(
|
||||
join(directory, 'config', 'mariadb.cnf'),
|
||||
renderMariaDbConfig(project, installedRoot(project))
|
||||
)
|
||||
])
|
||||
try {
|
||||
await access(join(directory, 'data', 'mariadb', 'mysql'))
|
||||
} catch {
|
||||
await execFileAsync(
|
||||
join(runtimeRoot(), 'bin', 'mariadb-install-db'),
|
||||
join(installedRoot(project), 'bin', 'mariadb-install-db'),
|
||||
[
|
||||
'--no-defaults',
|
||||
`--datadir=${join(directory, 'data', 'mariadb')}`,
|
||||
@@ -129,19 +161,19 @@ export function nativeServiceSpecs(project: NativeProjectDefinition): NativeServ
|
||||
return [
|
||||
{
|
||||
...common('database'),
|
||||
command: join(runtimeRoot(), 'bin', 'mariadbd'),
|
||||
command: join(installedRoot(project), 'bin', 'mariadbd'),
|
||||
args: [`--defaults-file=${join(directory, 'config', 'mariadb.cnf')}`],
|
||||
ready: { port: project.ports.database, timeoutMs: 30000 }
|
||||
},
|
||||
{
|
||||
...common('php'),
|
||||
command: join(runtimeRoot(), 'bin', 'php-fpm'),
|
||||
command: join(installedRoot(project), 'bin', 'php-fpm'),
|
||||
args: ['--nodaemonize', '--fpm-config', join(directory, 'config', 'php-fpm.conf')],
|
||||
ready: { port: project.ports.php }
|
||||
},
|
||||
{
|
||||
...common('web'),
|
||||
command: join(runtimeRoot(), 'bin', 'nginx'),
|
||||
command: join(installedRoot(project), 'bin', 'nginx'),
|
||||
args: ['-c', join(directory, 'config', 'nginx.conf'), '-p', `${directory}/`],
|
||||
ready: { port: project.ports.http }
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
const [redis, setRedis] = useState(false)
|
||||
const [mailpit, setMailpit] = useState(false)
|
||||
const [xdebug, setXdebug] = useState(false)
|
||||
const [runtimeEngine] = useState<'container' | 'native'>('container')
|
||||
const [runtimeEngine, setRuntimeEngine] = useState<'container' | 'native'>('container')
|
||||
|
||||
const createProject = useCreateProject()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
@@ -60,13 +60,32 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
const { data: runtimeStatus } = useRuntimeStatus()
|
||||
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 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 && selectedModule !== undefined
|
||||
const canSubmit = canContinue && setupValid && !isSubmitting
|
||||
|
||||
function selectRuntimeEngine(engine: 'container' | 'native'): void {
|
||||
if (engine === 'native' && !runtimeStatus?.native.available) return
|
||||
setRuntimeEngine(engine)
|
||||
if (engine === 'native') {
|
||||
const nativePhp = runtimeStatus?.native.components.find(
|
||||
(component) => component.id === 'php'
|
||||
)?.version
|
||||
if (nativePhp) setPhpVersion(nativePhp.split('.').slice(0, 2).join('.'))
|
||||
setWebServer('nginx')
|
||||
setDatabase('mariadb')
|
||||
setDatabaseVersion('11.8')
|
||||
setAdminer(false)
|
||||
setRedis(false)
|
||||
setMailpit(false)
|
||||
setXdebug(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePickDirectory(): Promise<void> {
|
||||
const picked = await window.api.create.pickDirectory()
|
||||
if (!picked) return
|
||||
@@ -82,7 +101,24 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
const name = projectName.trim()
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot, stack: { runtimeEngine, phpVersion, nodeVersion, webServer, database, databaseVersion, adminer, redis, mailpit, xdebug } })
|
||||
await createProject.mutateAsync({
|
||||
directory,
|
||||
projectName: name,
|
||||
projectType,
|
||||
docroot,
|
||||
stack: {
|
||||
runtimeEngine,
|
||||
phpVersion,
|
||||
nodeVersion,
|
||||
webServer,
|
||||
database,
|
||||
databaseVersion,
|
||||
adminer,
|
||||
redis,
|
||||
mailpit,
|
||||
xdebug
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
@@ -180,138 +216,285 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
{step === 'site' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Project folder</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickDirectory}
|
||||
className={clsx(
|
||||
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
|
||||
directory
|
||||
? 'border-cyan-200 bg-cyan-50/60 text-neutral-900 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-neutral-100'
|
||||
: 'border-neutral-300 bg-neutral-50/70 text-neutral-500 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
|
||||
{step === 'site' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className={labelClass}>Project folder</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePickDirectory}
|
||||
className={clsx(
|
||||
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
|
||||
directory
|
||||
? 'border-cyan-200 bg-cyan-50/60 text-neutral-900 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-neutral-100'
|
||||
: 'border-neutral-300 bg-neutral-50/70 text-neutral-500 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
|
||||
)}
|
||||
>
|
||||
<span className="grid size-9 flex-shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-sm dark:bg-neutral-950/70 dark:text-cyan-300">
|
||||
<FolderOpen size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{directory ?? 'Choose a folder…'}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
This becomes the project root.
|
||||
</span>
|
||||
</span>
|
||||
{directory && (
|
||||
<Check size={16} className="text-cyan-700 dark:text-cyan-300" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Project name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="my-project"
|
||||
className={fieldClass}
|
||||
/>
|
||||
{trimmedName.length > 0 && !nameValid && (
|
||||
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
Use only letters, numbers, and hyphens — no spaces (e.g. "
|
||||
{slugifyProjectName(trimmedName) || 'my-project'}").
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<span className="grid size-9 flex-shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-sm dark:bg-neutral-950/70 dark:text-cyan-300">
|
||||
<FolderOpen size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{directory ?? 'Choose a folder…'}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
This becomes the project root.
|
||||
</span>
|
||||
</span>
|
||||
{directory && <Check size={16} className="text-cyan-700 dark:text-cyan-300" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Project name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
placeholder="my-project"
|
||||
className={fieldClass}
|
||||
/>
|
||||
{trimmedName.length > 0 && !nameValid && (
|
||||
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400">
|
||||
Use only letters, numbers, and hyphens — no spaces (e.g. "
|
||||
{slugifyProjectName(trimmedName) || 'my-project'}").
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Project type</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{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
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
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(
|
||||
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
|
||||
isSelected
|
||||
? 'border-cyan-300 bg-cyan-50 text-cyan-950 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
|
||||
: 'border-neutral-200 bg-white/70 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'grid size-9 flex-shrink-0 place-items-center rounded-lg',
|
||||
isSelected
|
||||
? 'bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950'
|
||||
: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-950/70 dark:text-neutral-400'
|
||||
)}
|
||||
>
|
||||
<Icon size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">{t.label}</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t.value ? 'Use Aurora type preset' : 'Let Aurora inspect it'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{applicationModules.length === 0 && <div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100"><p className="font-semibold">No application modules installed</p><p className="mt-1">Close this window and use <strong>Modules</strong> at the bottom of the sidebar to install one.</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-semibold">Development stack</p>
|
||||
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">Aurora core owns the runtime; the application is a module layered on top.</p>
|
||||
</div>
|
||||
<div className="mb-4 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-cyan-300 bg-cyan-50 p-3 text-cyan-950 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100"><div className="flex items-center gap-2 text-sm font-semibold"><Container size={16}/> Container engine</div><p className="mt-1 text-xs text-cyan-800/80 dark:text-cyan-100/70">Current compatible engine · {runtimeStatus?.container.available ? runtimeStatus.container.provider : 'not detected'}</p></div>
|
||||
<div aria-disabled="true" className="rounded-xl border border-neutral-200 bg-neutral-100/70 p-3 opacity-70 dark:border-white/10 dark:bg-white/[0.03]"><div className="flex items-center gap-2 text-sm font-semibold"><Cpu size={16}/> Aurora Native</div><p className="mt-1 text-xs text-neutral-500">{runtimeStatus?.native.available ? `Runtime ${runtimeStatus.native.runtimeVersion} detected · provisioning checks pending` : 'Runtime bundle not installed yet'}</p></div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={labelClass}>PHP</label><select className={fieldClass} value={phpVersion} onChange={(e)=>setPhpVersion(e.target.value)}>{(selectedModule?.creation?.phpVersions ?? ['8.2','8.3','8.4','8.5']).map(v=><option key={v}>{v}</option>)}</select></div>
|
||||
<div><label className={labelClass}>Node.js</label><select className={fieldClass} value={nodeVersion} onChange={(e)=>setNodeVersion(e.target.value)}>{['20','22','24'].map(v=><option key={v}>{v}</option>)}</select></div>
|
||||
<div><label className={labelClass}>Web server</label><select className={fieldClass} value={webServer} onChange={(e)=>setWebServer(e.target.value as 'nginx'|'apache')}><option value="nginx">nginx</option><option value="apache">Apache</option></select></div>
|
||||
<div><label className={labelClass}>Database</label><select className={fieldClass} value={`${database}:${databaseVersion}`} onChange={(e)=>{const [kind,version]=e.target.value.split(':');setDatabase(kind as 'mariadb'|'mysql'|'postgres');setDatabaseVersion(version)}}>{selectedModule?.creation?.databases?.includes('mariadb') !== false && <><option value="mariadb:11.8">MariaDB 11.8</option><option value="mariadb:10.11">MariaDB 10.11</option></>}{selectedModule?.creation?.databases?.includes('mysql') !== false && <><option value="mysql:8.4">MySQL 8.4</option><option value="mysql:8.0">MySQL 8.0</option></>}{selectedModule?.creation?.databases?.includes('postgres') !== false && <><option value="postgres:17">PostgreSQL 17</option><option value="postgres:16">PostgreSQL 16</option></>}</select></div>
|
||||
<div className="grid grid-cols-2 gap-2 pt-5">
|
||||
{[['Adminer',adminer,setAdminer],['Redis',redis,setRedis],['Mailpit',mailpit,setMailpit],['Xdebug',xdebug,setXdebug]].map(([label,value,setter])=><label key={label as string} className="flex items-center gap-2 text-xs font-medium"><input type="checkbox" checked={value as boolean} onChange={(e)=>(setter as (v:boolean)=>void)(e.target.checked)} />{label as string}</label>)}
|
||||
<div>
|
||||
<label className={labelClass}>Project type</label>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{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
|
||||
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
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(
|
||||
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
|
||||
isSelected
|
||||
? 'border-cyan-300 bg-cyan-50 text-cyan-950 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
|
||||
: 'border-neutral-200 bg-white/70 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'grid size-9 flex-shrink-0 place-items-center rounded-lg',
|
||||
isSelected
|
||||
? 'bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950'
|
||||
: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-950/70 dark:text-neutral-400'
|
||||
)}
|
||||
>
|
||||
<Icon size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-semibold">
|
||||
{t.label}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t.value ? 'Use Aurora type preset' : 'Let Aurora inspect it'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{applicationModules.length === 0 && (
|
||||
<div className="col-span-full rounded-xl border border-dashed border-amber-300 bg-amber-50 p-5 text-sm text-amber-900 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100">
|
||||
<p className="font-semibold">No application modules installed</p>
|
||||
<p className="mt-1">
|
||||
Close this window and use <strong>Modules</strong> at the bottom of the
|
||||
sidebar to install one.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Docroot (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docroot}
|
||||
onChange={(e) => setDocroot(e.target.value)}
|
||||
placeholder="e.g. web, public — leave blank for project root"
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : selectedModule ? (
|
||||
<ExternalModuleSetup ref={setupRef} module={selectedModule} projectName={projectName.trim()} onValidityChange={setSetupValid} />
|
||||
) : (
|
||||
<GenericSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
)}
|
||||
<div className="rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="mb-3">
|
||||
<p className="text-sm font-semibold">Development stack</p>
|
||||
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Aurora core owns the runtime; the application is a module layered on top.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4 grid gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectRuntimeEngine('container')}
|
||||
className={
|
||||
runtimeEngine === 'container'
|
||||
? 'rounded-xl border border-cyan-400 bg-cyan-50 p-3 text-left text-cyan-950 ring-2 ring-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
|
||||
: 'rounded-xl border border-neutral-200 bg-white p-3 text-left dark:border-white/10 dark:bg-white/[0.03]'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Container size={16} /> Container engine
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{runtimeStatus?.container.available
|
||||
? runtimeStatus.container.provider
|
||||
: 'not detected'}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!runtimeStatus?.native.available}
|
||||
onClick={() => selectRuntimeEngine('native')}
|
||||
className={
|
||||
runtimeEngine === 'native'
|
||||
? 'rounded-xl border border-cyan-400 bg-cyan-50 p-3 text-left text-cyan-950 ring-2 ring-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
|
||||
: 'rounded-xl border border-neutral-200 bg-white p-3 text-left disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-white/[0.03]'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Cpu size={16} /> Aurora Native
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{runtimeStatus?.native.available
|
||||
? `Runtime ${runtimeStatus.native.runtimeVersion} · no Docker required`
|
||||
: 'Install the runtime from Settings first'}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className={labelClass}>PHP</label>
|
||||
<select
|
||||
className={fieldClass}
|
||||
value={phpVersion}
|
||||
onChange={(e) => setPhpVersion(e.target.value)}
|
||||
>
|
||||
{(
|
||||
selectedModule?.creation?.phpVersions ?? ['8.2', '8.3', '8.4', '8.5']
|
||||
).map((v) => (
|
||||
<option key={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Node.js</label>
|
||||
<select
|
||||
disabled={runtimeEngine === 'native'}
|
||||
className={fieldClass}
|
||||
value={nodeVersion}
|
||||
onChange={(e) => setNodeVersion(e.target.value)}
|
||||
>
|
||||
{['20', '22', '24'].map((v) => (
|
||||
<option key={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Web server</label>
|
||||
<select
|
||||
disabled={runtimeEngine === 'native'}
|
||||
className={fieldClass}
|
||||
value={webServer}
|
||||
onChange={(e) => setWebServer(e.target.value as 'nginx' | 'apache')}
|
||||
>
|
||||
<option value="nginx">nginx</option>
|
||||
<option value="apache">Apache</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Database</label>
|
||||
<select
|
||||
disabled={runtimeEngine === 'native'}
|
||||
className={fieldClass}
|
||||
value={`${database}:${databaseVersion}`}
|
||||
onChange={(e) => {
|
||||
const [kind, version] = e.target.value.split(':')
|
||||
setDatabase(kind as 'mariadb' | 'mysql' | 'postgres')
|
||||
setDatabaseVersion(version)
|
||||
}}
|
||||
>
|
||||
{selectedModule?.creation?.databases?.includes('mariadb') !== false && (
|
||||
<>
|
||||
<option value="mariadb:11.8">MariaDB 11.8</option>
|
||||
<option value="mariadb:10.11">MariaDB 10.11</option>
|
||||
</>
|
||||
)}
|
||||
{selectedModule?.creation?.databases?.includes('mysql') !== false && (
|
||||
<>
|
||||
<option value="mysql:8.4">MySQL 8.4</option>
|
||||
<option value="mysql:8.0">MySQL 8.0</option>
|
||||
</>
|
||||
)}
|
||||
{selectedModule?.creation?.databases?.includes('postgres') !== false && (
|
||||
<>
|
||||
<option value="postgres:17">PostgreSQL 17</option>
|
||||
<option value="postgres:16">PostgreSQL 16</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 pt-5">
|
||||
{[
|
||||
['Adminer', adminer, setAdminer],
|
||||
['Redis', redis, setRedis],
|
||||
['Mailpit', mailpit, setMailpit],
|
||||
['Xdebug', xdebug, setXdebug]
|
||||
].map(([label, value, setter]) => (
|
||||
<label
|
||||
key={label as string}
|
||||
className="flex items-center gap-2 text-xs font-medium"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={runtimeEngine === 'native'}
|
||||
checked={value as boolean}
|
||||
onChange={(e) => (setter as (v: boolean) => void)(e.target.checked)}
|
||||
/>
|
||||
{label as string}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Docroot (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={docroot}
|
||||
onChange={(e) => setDocroot(e.target.value)}
|
||||
placeholder="e.g. web, public — leave blank for project root"
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : selectedModule ? (
|
||||
<ExternalModuleSetup
|
||||
ref={setupRef}
|
||||
module={selectedModule}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : (
|
||||
<GenericSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user