feat: run WordPress projects on native runtime
This commit is contained in:
@@ -12,24 +12,25 @@ Each signed runtime bundle contains a `runtime.json` manifest plus versioned exe
|
||||
|
||||
Runtime archives are created from a staging directory with `npm run build:native-runtime -- <staging-directory> <output.tar.gz>`. The packager resolves every executable inside the staging root, calculates its SHA-256 checksum, writes the immutable `runtime.json`, excludes the build template, and creates the distributable archive. Dockside independently verifies those checksums before declaring a runtime available.
|
||||
|
||||
The first reproducible bundle target is Linux x64. Run `npm run build:native-linux-x64` on a Docker-capable build machine. Docker is used only to create the portable artifact; users of that artifact do not need Docker. The recipe pins PHP 8.5.9, nginx 1.30.4, and MariaDB 11.8.8 with their runtime libraries, runs version smoke checks outside the build container, and then invokes the normal checksum packager.
|
||||
The first reproducible bundle target is Linux x64. Run `npm run build:native-linux-x64` on a Docker-capable build machine. Docker is used only to create the portable artifact; users of that artifact do not need Docker. The recipe pins PHP 8.5.9, nginx 1.30.4, MariaDB 11.8.8, and WP-CLI 2.12.0 with their runtime libraries, runs executable, PHP-extension, and database-initialization smoke checks outside the build container, and then invokes the normal checksum packager.
|
||||
|
||||
Dockside can install a bundled runtime or a user-selected runtime archive from Settings. Installation rejects absolute and parent-traversing archive entries, rejects symbolic links, verifies the target platform and architecture, and verifies every declared executable checksum before atomically replacing an older runtime. Electron packages include matching archives from `dist/native-runtime` when they are present at packaging time.
|
||||
|
||||
## Isolation model
|
||||
|
||||
Every project receives reserved loopback ports, generated service configuration, isolated database data, logs, PID files, and environment variables below `.aurora/native`. A shared Aurora router owns friendly HTTPS project hostnames. Project files remain directly accessible on the host.
|
||||
Every project receives reserved loopback ports, generated service configuration, isolated database data, logs, PID files, nginx temporary storage, and environment variables below `.aurora/native`. Native projects currently use a direct loopback HTTP URL; container projects retain Aurora's friendly HTTP/HTTPS router names. Project files remain directly accessible on the host.
|
||||
|
||||
## Delivery sequence
|
||||
|
||||
1. Runtime manifest, platform detection, checksum verification, and engine abstraction.
|
||||
2. Native process supervisor and loopback port allocator.
|
||||
3. Linux x64 bundle with PHP 8.4, nginx, and MariaDB 11.8.
|
||||
4. WordPress provisioning, lifecycle, logs, database import/export, and Adminer.
|
||||
5. macOS arm64/x64 and Windows x64 bundles.
|
||||
6. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services.
|
||||
3. Linux x64 bundle with PHP 8.5, nginx, MariaDB 11.8, and WP-CLI.
|
||||
4. Native project lifecycle and single-site WordPress provisioning.
|
||||
5. Native logs, database import/export, and database administration.
|
||||
6. macOS arm64/x64 and Windows x64 bundles.
|
||||
7. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services.
|
||||
|
||||
Native project creation must stay disabled until the platform bundle passes executable, service-health, database, routing, and cleanup checks. Existing projects default to the container engine for backward compatibility.
|
||||
Dockside enables native project creation only when an installed platform bundle contains the selected PHP branch, nginx, MariaDB 11.8, and WP-CLI. Existing projects default to the container engine for backward compatibility. The native smoke suite starts all three services, serves PHP through FastCGI, provisions a real WordPress site, and verifies its HTTP response and cleanup.
|
||||
|
||||
## Runtime update notifications
|
||||
|
||||
|
||||
@@ -1,35 +1,125 @@
|
||||
'use strict'
|
||||
|
||||
function safeName(name) {
|
||||
return name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
|
||||
return (
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'project'
|
||||
)
|
||||
}
|
||||
|
||||
function wpArgs(context, network) {
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
||||
const gid = typeof process.getgid === 'function' ? process.getgid() : undefined
|
||||
return ['run', '--rm', ...(uid === undefined ? [] : ['--user', `${uid}:${gid}`]), '-e', 'HOME=/tmp', '-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache', ...(network ? ['--network', `aurora-${safeName(context.projectName)}_default`] : []), '-v', `${context.directory}:/app`, '-w', '/app', '--entrypoint', 'php', 'wordpress:cli', '-d', 'memory_limit=512M', '/usr/local/bin/wp']
|
||||
return [
|
||||
'run',
|
||||
'--rm',
|
||||
...(uid === undefined ? [] : ['--user', `${uid}:${gid}`]),
|
||||
'-e',
|
||||
'HOME=/tmp',
|
||||
'-e',
|
||||
'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
|
||||
...(network ? ['--network', `aurora-${safeName(context.projectName)}_default`] : []),
|
||||
'-v',
|
||||
`${context.directory}:/app`,
|
||||
'-w',
|
||||
'/app',
|
||||
'--entrypoint',
|
||||
'php',
|
||||
'wordpress:cli',
|
||||
'-d',
|
||||
'memory_limit=512M',
|
||||
'/usr/local/bin/wp'
|
||||
]
|
||||
}
|
||||
|
||||
exports.projectCreate = async function projectCreate(context) {
|
||||
const s = context.settings
|
||||
const title = String(s.title || '').trim() || context.projectName
|
||||
const base = wpArgs(context, false)
|
||||
const networkBase = wpArgs(context, true)
|
||||
const siteUrl = context.urls.https
|
||||
await context.ensureRouter()
|
||||
await context.run('start', 'docker', ['compose', '-f', `${context.directory}/.aurora/compose.yaml`, 'up', '-d', '--build', '--remove-orphans'])
|
||||
await context.run('download', 'docker', [...base, 'core', 'download', `--locale=${s.locale || 'en_US'}`, '--force'])
|
||||
await context.run('config', 'docker', [...networkBase, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force'])
|
||||
await context.run('install', 'docker', [...networkBase, 'core', 'install', `--url=${siteUrl}`, `--title=${title}`, `--admin_user=${s.admin_user}`, `--admin_password=${s.admin_password}`, `--admin_email=${s.admin_email}`, '--skip-email'])
|
||||
await context.run('verify-install', 'docker', [...networkBase, 'core', 'is-installed'])
|
||||
if (s.multisite !== 'none') {
|
||||
await context.run('multisite-convert', 'docker', [...networkBase, 'core', 'multisite-convert', `--title=${title}`, ...(s.multisite === 'subdomain' ? ['--subdomains'] : [])])
|
||||
await context.run('verify-network', 'docker', [...networkBase, 'core', 'is-installed', '--network'])
|
||||
await context.run('verify-network-db', 'docker', [...networkBase, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names'])
|
||||
const native = context.environment.runtimeEngine === 'native'
|
||||
const base = native ? [`--path=${context.directory}`] : wpArgs(context, false)
|
||||
const networkBase = native ? base : wpArgs(context, true)
|
||||
const command = native ? context.native.wp : 'docker'
|
||||
const siteUrl = native ? context.urls.http : context.urls.https
|
||||
const wp = (label, args) => context.run(label, command, [...(native ? [] : networkBase), ...args])
|
||||
if (native) {
|
||||
await context.native.start()
|
||||
const port = Number(context.native.databasePort)
|
||||
const bootstrap = `$db=new mysqli('127.0.0.1','root','',null,${port});if($db->connect_error)throw new Exception($db->connect_error);$db->query('CREATE DATABASE IF NOT EXISTS db');$db->query("CREATE USER IF NOT EXISTS 'db'@'127.0.0.1' IDENTIFIED BY 'db'");$db->query("GRANT ALL ON db.* TO 'db'@'127.0.0.1'");`
|
||||
await context.run('database', context.native.php, ['-r', bootstrap])
|
||||
} else {
|
||||
await context.ensureRouter()
|
||||
await context.run('start', 'docker', [
|
||||
'compose',
|
||||
'-f',
|
||||
`${context.directory}/.aurora/compose.yaml`,
|
||||
'up',
|
||||
'-d',
|
||||
'--build',
|
||||
'--remove-orphans'
|
||||
])
|
||||
}
|
||||
await context.setProjectMetadata({ multisite: String(s.multisite), routingWildcard: s.multisite === 'subdomain' })
|
||||
await context.run('permalinks', 'docker', [...networkBase, 'rewrite', 'structure', '/%postname%/', '--hard'])
|
||||
await context.run('debug', 'docker', [...networkBase, 'config', 'set', 'WP_DEBUG', String(Boolean(s.wp_debug)), '--raw'])
|
||||
await context.run('environment', 'docker', [...networkBase, 'config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'])
|
||||
await context.saveCredentials({ platform: context.moduleId, adminUrl: `${siteUrl}/wp-admin/`, username: String(s.admin_user), password: String(s.admin_password), email: String(s.admin_email) })
|
||||
await context.run('download', command, [
|
||||
...base,
|
||||
'core',
|
||||
'download',
|
||||
`--locale=${s.locale || 'en_US'}`,
|
||||
'--force'
|
||||
])
|
||||
await context.run('config', command, [
|
||||
...networkBase,
|
||||
'config',
|
||||
'create',
|
||||
'--dbname=db',
|
||||
'--dbuser=db',
|
||||
'--dbpass=db',
|
||||
`--dbhost=${native ? `127.0.0.1:${context.native.databasePort}` : 'db:3306'}`,
|
||||
'--skip-check',
|
||||
'--force'
|
||||
])
|
||||
await wp('install', [
|
||||
'core',
|
||||
'install',
|
||||
`--url=${siteUrl}`,
|
||||
`--title=${title}`,
|
||||
`--admin_user=${s.admin_user}`,
|
||||
`--admin_password=${s.admin_password}`,
|
||||
`--admin_email=${s.admin_email}`,
|
||||
'--skip-email'
|
||||
])
|
||||
await wp('verify-install', ['core', 'is-installed'])
|
||||
if (s.multisite !== 'none') {
|
||||
await wp('multisite-convert', [
|
||||
'core',
|
||||
'multisite-convert',
|
||||
`--title=${title}`,
|
||||
...(s.multisite === 'subdomain' ? ['--subdomains'] : [])
|
||||
])
|
||||
await wp('verify-network', ['core', 'is-installed', '--network'])
|
||||
await wp('verify-network-db', [
|
||||
'db',
|
||||
'query',
|
||||
"SHOW TABLES LIKE 'wp_blogs';",
|
||||
'--skip-column-names'
|
||||
])
|
||||
}
|
||||
await context.setProjectMetadata({
|
||||
multisite: String(s.multisite),
|
||||
routingWildcard: s.multisite === 'subdomain'
|
||||
})
|
||||
if (native) {
|
||||
await wp('permalinks', ['option', 'update', 'permalink_structure', '/%postname%/'])
|
||||
} else {
|
||||
await wp('permalinks', ['rewrite', 'structure', '/%postname%/', '--hard'])
|
||||
}
|
||||
await wp('debug', ['config', 'set', 'WP_DEBUG', String(Boolean(s.wp_debug)), '--raw'])
|
||||
await wp('environment', ['config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'])
|
||||
await context.saveCredentials({
|
||||
platform: context.moduleId,
|
||||
adminUrl: `${siteUrl}/wp-admin/`,
|
||||
username: String(s.admin_user),
|
||||
password: String(s.admin_password),
|
||||
email: String(s.admin_email)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ FROM php:8.5.9-fpm-alpine3.23
|
||||
|
||||
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main \
|
||||
nginx=1.30.4-r3 mariadb=11.8.8-r0 mariadb-client=11.8.8-r0 bash file pax-utils \
|
||||
&& mkdir -p /stage/root /stage/bin /stage/lib
|
||||
&& mkdir -p /stage/root /stage/bin /stage/lib \
|
||||
&& curl -fsSLo /tmp/wp-cli.phar https://github.com/wp-cli/wp-cli/releases/download/v2.12.0/wp-cli-2.12.0.phar \
|
||||
&& echo 'ce34ddd838f7351d6759068d09793f26755463b4a4610a5a5c0a97b68220d85c /tmp/wp-cli.phar' | sha256sum -c -
|
||||
|
||||
RUN docker-php-ext-install -j2 mysqli pdo_mysql
|
||||
|
||||
COPY stage-runtime.sh /usr/local/bin/stage-runtime
|
||||
RUN chmod +x /usr/local/bin/stage-runtime && /usr/local/bin/stage-runtime
|
||||
|
||||
@@ -63,6 +63,18 @@ EOF
|
||||
chmod +x "$stage/bin/$name"
|
||||
done
|
||||
|
||||
for name in php php-fpm; do
|
||||
target=/usr/local/bin/php
|
||||
[ "$name" = php-fpm ] && target=/usr/local/sbin/php-fpm
|
||||
cat > "$stage/bin/$name" <<EOF
|
||||
#!/bin/sh
|
||||
runtime_root=\$(CDPATH= cd -- "\$(dirname -- "\$0")/.." && pwd)
|
||||
extension_dir=\$(find "\$runtime_root/root/usr/local/lib/php/extensions" -mindepth 1 -maxdepth 1 -type d -print -quit)
|
||||
exec "\$runtime_root/bin/aurora-exec" "$target" -d "extension_dir=\$extension_dir" "\$@"
|
||||
EOF
|
||||
chmod +x "$stage/bin/$name"
|
||||
done
|
||||
|
||||
# mariadb-install-db is a shell script that calls helpers below --basedir.
|
||||
# Put the ELF programs behind relocatable wrappers so those calls also use the
|
||||
# bundled musl loader instead of the host's /lib interpreter.
|
||||
@@ -91,6 +103,14 @@ exec "$(dirname "$0")/aurora-exec" /usr/libexec/aurora/mariadbd "$@"
|
||||
EOF
|
||||
chmod +x "$stage/bin/mariadbd"
|
||||
|
||||
cp /tmp/wp-cli.phar "$root/usr/local/bin/wp-cli.phar"
|
||||
cat > "$stage/bin/wp" <<'EOF'
|
||||
#!/bin/sh
|
||||
runtime_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
exec "$runtime_root/bin/php" -d memory_limit=512M "$runtime_root/root/usr/local/bin/wp-cli.phar" "$@"
|
||||
EOF
|
||||
chmod +x "$stage/bin/wp"
|
||||
|
||||
php_version=$(php -r 'echo PHP_VERSION;')
|
||||
nginx_version=$(nginx -v 2>&1 | sed 's#nginx version: nginx/##')
|
||||
mariadb_version=$(mariadbd --version | sed -n 's/.* Ver \([^ -]*\).*/\1/p')
|
||||
@@ -103,7 +123,8 @@ cat > "$stage/runtime.template.json" <<EOF
|
||||
"components": [
|
||||
{ "id": "php", "version": "$php_version", "executable": "bin/php-fpm" },
|
||||
{ "id": "nginx", "version": "$nginx_version", "executable": "bin/nginx" },
|
||||
{ "id": "mariadb", "version": "$mariadb_version", "executable": "bin/mariadbd" }
|
||||
{ "id": "mariadb", "version": "$mariadb_version", "executable": "bin/mariadbd" },
|
||||
{ "id": "wp-cli", "version": "2.12.0", "executable": "bin/wp" }
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -28,7 +28,8 @@ for (const executable of [
|
||||
'php-fpm',
|
||||
'nginx',
|
||||
'mariadbd',
|
||||
'mariadb-install-db'
|
||||
'mariadb-install-db',
|
||||
'wp'
|
||||
]) {
|
||||
const path = join(stagingRoot, 'bin', executable)
|
||||
if (!existsSync(path)) throw new Error(`Builder did not produce ${path}`)
|
||||
|
||||
@@ -28,6 +28,12 @@ const checks = [
|
||||
join(root, 'bin/mariadbd'),
|
||||
['--version'],
|
||||
template.components.find((item) => item.id === 'mariadb')?.version
|
||||
],
|
||||
[
|
||||
'WP-CLI',
|
||||
join(root, 'bin/wp'),
|
||||
['--version'],
|
||||
template.components.find((item) => item.id === 'wp-cli')?.version
|
||||
]
|
||||
]
|
||||
for (const [name, command, args, version] of checks) {
|
||||
@@ -40,6 +46,15 @@ for (const [name, command, args, version] of checks) {
|
||||
process.stdout.write(`${name} ${version} OK\n`)
|
||||
}
|
||||
|
||||
const extensions = spawnSync(
|
||||
join(root, 'bin/php'),
|
||||
['-r', "exit(extension_loaded('mysqli') && extension_loaded('pdo_mysql') ? 0 : 1);"],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
if (extensions.error || extensions.status !== 0)
|
||||
throw extensions.error || new Error(`PHP database extensions failed: ${extensions.stderr}`)
|
||||
process.stdout.write('PHP mysqli and pdo_mysql extensions OK\n')
|
||||
|
||||
const databaseDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-'))
|
||||
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-tmp-'))
|
||||
try {
|
||||
|
||||
+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