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.
|
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.
|
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
|
## 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
|
## Delivery sequence
|
||||||
|
|
||||||
1. Runtime manifest, platform detection, checksum verification, and engine abstraction.
|
1. Runtime manifest, platform detection, checksum verification, and engine abstraction.
|
||||||
2. Native process supervisor and loopback port allocator.
|
2. Native process supervisor and loopback port allocator.
|
||||||
3. Linux x64 bundle with PHP 8.4, nginx, and MariaDB 11.8.
|
3. Linux x64 bundle with PHP 8.5, nginx, MariaDB 11.8, and WP-CLI.
|
||||||
4. WordPress provisioning, lifecycle, logs, database import/export, and Adminer.
|
4. Native project lifecycle and single-site WordPress provisioning.
|
||||||
5. macOS arm64/x64 and Windows x64 bundles.
|
5. Native logs, database import/export, and database administration.
|
||||||
6. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services.
|
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
|
## Runtime update notifications
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,125 @@
|
|||||||
'use strict'
|
'use strict'
|
||||||
|
|
||||||
function safeName(name) {
|
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) {
|
function wpArgs(context, network) {
|
||||||
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
||||||
const gid = typeof process.getgid === 'function' ? process.getgid() : 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) {
|
exports.projectCreate = async function projectCreate(context) {
|
||||||
const s = context.settings
|
const s = context.settings
|
||||||
const title = String(s.title || '').trim() || context.projectName
|
const title = String(s.title || '').trim() || context.projectName
|
||||||
const base = wpArgs(context, false)
|
const native = context.environment.runtimeEngine === 'native'
|
||||||
const networkBase = wpArgs(context, true)
|
const base = native ? [`--path=${context.directory}`] : wpArgs(context, false)
|
||||||
const siteUrl = context.urls.https
|
const networkBase = native ? base : wpArgs(context, true)
|
||||||
await context.ensureRouter()
|
const command = native ? context.native.wp : 'docker'
|
||||||
await context.run('start', 'docker', ['compose', '-f', `${context.directory}/.aurora/compose.yaml`, 'up', '-d', '--build', '--remove-orphans'])
|
const siteUrl = native ? context.urls.http : context.urls.https
|
||||||
await context.run('download', 'docker', [...base, 'core', 'download', `--locale=${s.locale || 'en_US'}`, '--force'])
|
const wp = (label, args) => context.run(label, command, [...(native ? [] : networkBase), ...args])
|
||||||
await context.run('config', 'docker', [...networkBase, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force'])
|
if (native) {
|
||||||
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.native.start()
|
||||||
await context.run('verify-install', 'docker', [...networkBase, 'core', 'is-installed'])
|
const port = Number(context.native.databasePort)
|
||||||
if (s.multisite !== 'none') {
|
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('multisite-convert', 'docker', [...networkBase, 'core', 'multisite-convert', `--title=${title}`, ...(s.multisite === 'subdomain' ? ['--subdomains'] : [])])
|
await context.run('database', context.native.php, ['-r', bootstrap])
|
||||||
await context.run('verify-network', 'docker', [...networkBase, 'core', 'is-installed', '--network'])
|
} else {
|
||||||
await context.run('verify-network-db', 'docker', [...networkBase, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names'])
|
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('download', command, [
|
||||||
await context.run('permalinks', 'docker', [...networkBase, 'rewrite', 'structure', '/%postname%/', '--hard'])
|
...base,
|
||||||
await context.run('debug', 'docker', [...networkBase, 'config', 'set', 'WP_DEBUG', String(Boolean(s.wp_debug)), '--raw'])
|
'core',
|
||||||
await context.run('environment', 'docker', [...networkBase, 'config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'])
|
'download',
|
||||||
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) })
|
`--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 \
|
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 \
|
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
|
COPY stage-runtime.sh /usr/local/bin/stage-runtime
|
||||||
RUN chmod +x /usr/local/bin/stage-runtime && /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"
|
chmod +x "$stage/bin/$name"
|
||||||
done
|
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.
|
# 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
|
# Put the ELF programs behind relocatable wrappers so those calls also use the
|
||||||
# bundled musl loader instead of the host's /lib interpreter.
|
# bundled musl loader instead of the host's /lib interpreter.
|
||||||
@@ -91,6 +103,14 @@ exec "$(dirname "$0")/aurora-exec" /usr/libexec/aurora/mariadbd "$@"
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$stage/bin/mariadbd"
|
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;')
|
php_version=$(php -r 'echo PHP_VERSION;')
|
||||||
nginx_version=$(nginx -v 2>&1 | sed 's#nginx version: nginx/##')
|
nginx_version=$(nginx -v 2>&1 | sed 's#nginx version: nginx/##')
|
||||||
mariadb_version=$(mariadbd --version | sed -n 's/.* Ver \([^ -]*\).*/\1/p')
|
mariadb_version=$(mariadbd --version | sed -n 's/.* Ver \([^ -]*\).*/\1/p')
|
||||||
@@ -103,7 +123,8 @@ cat > "$stage/runtime.template.json" <<EOF
|
|||||||
"components": [
|
"components": [
|
||||||
{ "id": "php", "version": "$php_version", "executable": "bin/php-fpm" },
|
{ "id": "php", "version": "$php_version", "executable": "bin/php-fpm" },
|
||||||
{ "id": "nginx", "version": "$nginx_version", "executable": "bin/nginx" },
|
{ "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
|
EOF
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ for (const executable of [
|
|||||||
'php-fpm',
|
'php-fpm',
|
||||||
'nginx',
|
'nginx',
|
||||||
'mariadbd',
|
'mariadbd',
|
||||||
'mariadb-install-db'
|
'mariadb-install-db',
|
||||||
|
'wp'
|
||||||
]) {
|
]) {
|
||||||
const path = join(stagingRoot, 'bin', executable)
|
const path = join(stagingRoot, 'bin', executable)
|
||||||
if (!existsSync(path)) throw new Error(`Builder did not produce ${path}`)
|
if (!existsSync(path)) throw new Error(`Builder did not produce ${path}`)
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ const checks = [
|
|||||||
join(root, 'bin/mariadbd'),
|
join(root, 'bin/mariadbd'),
|
||||||
['--version'],
|
['--version'],
|
||||||
template.components.find((item) => item.id === 'mariadb')?.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) {
|
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`)
|
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 databaseDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-'))
|
||||||
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-tmp-'))
|
const temporaryDirectory = mkdtempSync(join(tmpdir(), 'aurora-native-mariadb-tmp-'))
|
||||||
try {
|
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 { ipcMain, type WebContents } from 'electron'
|
||||||
import { spawn } from 'child_process'
|
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 { runCommandStreamed } from '../commandRunner'
|
||||||
import { runProjectLifecycleHooks } from '../moduleRuntime'
|
import { runProjectLifecycleHooks } from '../moduleRuntime'
|
||||||
import type { EnvironmentUpdate } from '../../shared/types'
|
import type { EnvironmentUpdate } from '../../shared/types'
|
||||||
const composeArgs=(root:string,...args:string[]):string[]=>['compose','-f',`${root}/.aurora/compose.yaml`,...args]
|
const composeArgs = (root: string, ...args: string[]): string[] => [
|
||||||
const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit'])
|
'compose',
|
||||||
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()})})}
|
'-f',
|
||||||
async function startProject(operationId:string,name:string,sender:WebContents,forceRecreate=false):Promise<void>{
|
`${root}/.aurora/compose.yaml`,
|
||||||
const root=await getProjectRoot(name); await updateEnvironment(root,{}); await ensureRouter()
|
...args
|
||||||
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 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{
|
async function startProject(
|
||||||
ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name))
|
operationId: string,
|
||||||
ipcMain.handle('projects:start',(e,id:string,name:string)=>startProject(id,name,e.sender))
|
name: string,
|
||||||
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})})
|
sender: WebContents,
|
||||||
ipcMain.handle('projects:restart',(e,id:string,name:string)=>startProject(id,name,e.sender,true))
|
forceRecreate = false
|
||||||
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})})
|
): Promise<void> {
|
||||||
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})})
|
const root = await getProjectRoot(name)
|
||||||
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.')})
|
await updateEnvironment(root, {})
|
||||||
ipcMain.handle('projects:delete',async(e,id:string,name:string,approot:string,deleteFiles:boolean)=>{
|
const native = await getNativeProjectDefinition(name)
|
||||||
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 {
|
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) {
|
} catch (error) {
|
||||||
// A malformed/missing compose file must never make a project undeletable.
|
if (!sender.isDestroyed())
|
||||||
console.warn(`Aurora cleanup for '${name}' skipped:`, error)
|
sender.send('terminal:exit', { operationId, exitCode: 1, cancelled: false })
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
await unregisterProject(name,deleteFiles)
|
}
|
||||||
})
|
export function registerProjectsIpc(): void {
|
||||||
ipcMain.handle('projects:trustCA',async()=>{ await trustAuroraCA(); await ensureRouter() })
|
ipcMain.handle('projects:list', () => listProjects())
|
||||||
ipcMain.handle('projects:updateEnvironment',async(_e,_id:string,_name:string,root:string,updates:EnvironmentUpdate)=>{
|
ipcMain.handle('projects:describe', (_e, name: string) => describeProject(name))
|
||||||
await updateEnvironment(root,updates)
|
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 type { AuroraModuleLifecycleHook } from '../shared/types'
|
||||||
import { getModuleManifest, moduleDirectory } from './moduleRegistry'
|
import { getModuleManifest, moduleDirectory } from './moduleRegistry'
|
||||||
import { runCommandStreamed } from './commandRunner'
|
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'
|
import { saveSiteCredentials } from './ipc/secrets'
|
||||||
|
|
||||||
type Settings = Record<string, string | number | boolean>
|
type Settings = Record<string, string | number | boolean>
|
||||||
type ExternalHook = (context: Record<string, unknown>) => Promise<void> | void
|
type ExternalHook = (context: Record<string, unknown>) => Promise<void> | void
|
||||||
type LoadedModule = Partial<Record<AuroraModuleLifecycleHook, ExternalHook>>
|
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 execFileAsync = promisify(execFile)
|
||||||
const CORE_MODULE_IDS = new Set(['adminer', 'redis', 'mailpit'])
|
const CORE_MODULE_IDS = new Set(['adminer', 'redis', 'mailpit'])
|
||||||
|
|
||||||
function sendExit(sender: WebContents, operationId: string, exitCode: number): void {
|
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 {
|
function loadModule(moduleId: string, main: string): LoadedModule {
|
||||||
const root = resolve(moduleDirectory(), moduleId)
|
const root = resolve(moduleDirectory(), moduleId)
|
||||||
const entry = resolve(root, main)
|
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
|
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)
|
const manifest = await getModuleManifest(moduleId)
|
||||||
if (!manifest.main) return false
|
if (!manifest.main) return false
|
||||||
const handler = loadModule(moduleId, manifest.main)[hook]
|
const handler = loadModule(moduleId, manifest.main)[hook]
|
||||||
if (typeof handler !== 'function') return false
|
if (typeof handler !== 'function') return false
|
||||||
const directory = options.directory
|
const directory = options.directory
|
||||||
const urls = options.projectName ? projectUrls(options.projectName) : undefined
|
|
||||||
const projectConfig = directory ? await getProjectConfig(directory) : 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> => {
|
const run = async (label: string, command: string, args: string[]): Promise<void> => {
|
||||||
if (options.sender && options.operationId) {
|
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` })
|
if (!options.sender.isDestroyed())
|
||||||
return runCommandStreamed(options.operationId, command, args, options.sender, { cwd: directory ?? resolve(moduleDirectory(), moduleId), emitExit: false })
|
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({
|
await handler(
|
||||||
moduleId,
|
Object.freeze({
|
||||||
hook,
|
moduleId,
|
||||||
directory,
|
hook,
|
||||||
projectName: options.projectName,
|
directory,
|
||||||
toolId: options.toolId,
|
projectName: options.projectName,
|
||||||
settings: Object.freeze({ ...(options.settings ?? {}) }),
|
toolId: options.toolId,
|
||||||
environment: projectConfig ? Object.freeze({ php: projectConfig.php, node: projectConfig.node, webserver: projectConfig.webserver, database: projectConfig.database, databaseVersion: projectConfig.databaseVersion, docroot: projectConfig.docroot }) : undefined,
|
settings: Object.freeze({ ...(options.settings ?? {}) }),
|
||||||
urls: urls ? Object.freeze(urls) : undefined,
|
environment: projectConfig
|
||||||
run,
|
? Object.freeze({
|
||||||
ensureRouter,
|
php: projectConfig.php,
|
||||||
setProjectMetadata: async (metadata: Settings) => {
|
node: projectConfig.node,
|
||||||
if (!directory) throw new Error('Project metadata is unavailable outside a project lifecycle hook')
|
webserver: projectConfig.webserver,
|
||||||
await setProjectModuleMetadata(directory, metadata)
|
database: projectConfig.database,
|
||||||
},
|
databaseVersion: projectConfig.databaseVersion,
|
||||||
saveCredentials: async (credentials: { platform: string; adminUrl: string; username: string; password: string; email: string }) => {
|
docroot: projectConfig.docroot,
|
||||||
if (!directory) throw new Error('Project credentials are unavailable outside a project lifecycle hook')
|
runtimeEngine: projectConfig.runtimeEngine ?? 'container'
|
||||||
await saveSiteCredentials(directory, credentials)
|
})
|
||||||
}
|
: 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
|
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)
|
const config = await getProjectConfig(directory)
|
||||||
for (const moduleId of config.modules) {
|
for (const moduleId of config.modules) {
|
||||||
if (CORE_MODULE_IDS.has(moduleId)) continue
|
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 {
|
try {
|
||||||
await runModuleLifecycleHook(moduleId, 'projectCreate', { directory, projectName, settings, operationId, sender })
|
await runModuleLifecycleHook(moduleId, 'projectCreate', {
|
||||||
|
directory,
|
||||||
|
projectName,
|
||||||
|
settings,
|
||||||
|
operationId,
|
||||||
|
sender
|
||||||
|
})
|
||||||
sendExit(sender, operationId, 0)
|
sendExit(sender, operationId, 0)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendExit(sender, operationId, 1)
|
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)
|
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)
|
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 {
|
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`)
|
if (!handled) throw new Error(`Module '${moduleId}' does not implement projectTool`)
|
||||||
sendExit(sender, operationId, 0)
|
sendExit(sender, operationId, 0)
|
||||||
} catch (error) {
|
} 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 { 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 = {
|
const project = {
|
||||||
name: 'demo',
|
name: 'demo',
|
||||||
@@ -7,6 +20,7 @@ const project = {
|
|||||||
docroot: 'public',
|
docroot: 'public',
|
||||||
ports: { http: 41001, php: 41002, database: 41003, node: 41004 }
|
ports: { http: 41001, php: 41002, database: 41003, node: 41004 }
|
||||||
}
|
}
|
||||||
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
describe('native project configuration', () => {
|
describe('native project configuration', () => {
|
||||||
it('isolates PHP-FPM on its allocated loopback port', () =>
|
it('isolates PHP-FPM on its allocated loopback port', () =>
|
||||||
@@ -25,3 +39,89 @@ describe('native project configuration', () => {
|
|||||||
expect(config).toContain('/.aurora/native/data/mariadb')
|
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
|
root: string
|
||||||
docroot: string
|
docroot: string
|
||||||
ports: AuroraNativePorts
|
ports: AuroraNativePorts
|
||||||
|
installedRuntimeRoot?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function installedRoot(project: NativeProjectDefinition): string {
|
||||||
|
return project.installedRuntimeRoot ?? runtimeRoot()
|
||||||
}
|
}
|
||||||
|
|
||||||
function nativeDirectory(root: string): string {
|
function nativeDirectory(root: string): string {
|
||||||
@@ -53,6 +58,11 @@ error_log "${quoteNginx(join(directory, 'logs', 'nginx.log'))}" info;
|
|||||||
events { worker_connections 256; }
|
events { worker_connections 256; }
|
||||||
http {
|
http {
|
||||||
access_log "${quoteNginx(join(directory, 'logs', 'nginx-access.log'))}";
|
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 {
|
server {
|
||||||
listen 127.0.0.1:${project.ports.http};
|
listen 127.0.0.1:${project.ports.http};
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
@@ -65,9 +75,17 @@ http {
|
|||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
|
||||||
fastcgi_param REQUEST_METHOD $request_method;
|
fastcgi_param REQUEST_METHOD $request_method;
|
||||||
|
fastcgi_param REQUEST_URI $request_uri;
|
||||||
fastcgi_param QUERY_STRING $query_string;
|
fastcgi_param QUERY_STRING $query_string;
|
||||||
fastcgi_param CONTENT_TYPE $content_type;
|
fastcgi_param CONTENT_TYPE $content_type;
|
||||||
fastcgi_param CONTENT_LENGTH $content_length;
|
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> {
|
export async function provisionNativeProject(project: NativeProjectDefinition): Promise<void> {
|
||||||
const directory = nativeDirectory(project.root)
|
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 mkdir(join(directory, child), { recursive: true })
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(join(directory, 'config', 'php-fpm.conf'), renderPhpFpmConfig(project)),
|
writeFile(join(directory, 'config', 'php-fpm.conf'), renderPhpFpmConfig(project)),
|
||||||
writeFile(join(directory, 'config', 'nginx.conf'), renderNginxConfig(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 {
|
try {
|
||||||
await access(join(directory, 'data', 'mariadb', 'mysql'))
|
await access(join(directory, 'data', 'mariadb', 'mysql'))
|
||||||
} catch {
|
} catch {
|
||||||
await execFileAsync(
|
await execFileAsync(
|
||||||
join(runtimeRoot(), 'bin', 'mariadb-install-db'),
|
join(installedRoot(project), 'bin', 'mariadb-install-db'),
|
||||||
[
|
[
|
||||||
'--no-defaults',
|
'--no-defaults',
|
||||||
`--datadir=${join(directory, 'data', 'mariadb')}`,
|
`--datadir=${join(directory, 'data', 'mariadb')}`,
|
||||||
@@ -129,19 +161,19 @@ export function nativeServiceSpecs(project: NativeProjectDefinition): NativeServ
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
...common('database'),
|
...common('database'),
|
||||||
command: join(runtimeRoot(), 'bin', 'mariadbd'),
|
command: join(installedRoot(project), 'bin', 'mariadbd'),
|
||||||
args: [`--defaults-file=${join(directory, 'config', 'mariadb.cnf')}`],
|
args: [`--defaults-file=${join(directory, 'config', 'mariadb.cnf')}`],
|
||||||
ready: { port: project.ports.database, timeoutMs: 30000 }
|
ready: { port: project.ports.database, timeoutMs: 30000 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...common('php'),
|
...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')],
|
args: ['--nodaemonize', '--fpm-config', join(directory, 'config', 'php-fpm.conf')],
|
||||||
ready: { port: project.ports.php }
|
ready: { port: project.ports.php }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...common('web'),
|
...common('web'),
|
||||||
command: join(runtimeRoot(), 'bin', 'nginx'),
|
command: join(installedRoot(project), 'bin', 'nginx'),
|
||||||
args: ['-c', join(directory, 'config', 'nginx.conf'), '-p', `${directory}/`],
|
args: ['-c', join(directory, 'config', 'nginx.conf'), '-p', `${directory}/`],
|
||||||
ready: { port: project.ports.http }
|
ready: { port: project.ports.http }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
const [redis, setRedis] = useState(false)
|
const [redis, setRedis] = useState(false)
|
||||||
const [mailpit, setMailpit] = useState(false)
|
const [mailpit, setMailpit] = useState(false)
|
||||||
const [xdebug, setXdebug] = useState(false)
|
const [xdebug, setXdebug] = useState(false)
|
||||||
const [runtimeEngine] = useState<'container' | 'native'>('container')
|
const [runtimeEngine, setRuntimeEngine] = useState<'container' | 'native'>('container')
|
||||||
|
|
||||||
const createProject = useCreateProject()
|
const createProject = useCreateProject()
|
||||||
const selectProject = useAppStore((s) => s.selectProject)
|
const selectProject = useAppStore((s) => s.selectProject)
|
||||||
@@ -60,13 +60,32 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
const { data: runtimeStatus } = useRuntimeStatus()
|
const { data: runtimeStatus } = useRuntimeStatus()
|
||||||
const applicationModules = moduleRegistry.filter((module) => module.category === 'application')
|
const applicationModules = moduleRegistry.filter((module) => module.category === 'application')
|
||||||
const selectedModule = applicationModules.find((module) => module.id === projectType)
|
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 trimmedName = projectName.trim()
|
||||||
const nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName)
|
const nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName)
|
||||||
const canContinue = directory !== null && nameValid && selectedModule !== undefined
|
const canContinue = directory !== null && nameValid && selectedModule !== undefined
|
||||||
const canSubmit = canContinue && setupValid && !isSubmitting
|
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> {
|
async function handlePickDirectory(): Promise<void> {
|
||||||
const picked = await window.api.create.pickDirectory()
|
const picked = await window.api.create.pickDirectory()
|
||||||
if (!picked) return
|
if (!picked) return
|
||||||
@@ -82,7 +101,24 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
const name = projectName.trim()
|
const name = projectName.trim()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
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 {
|
} catch {
|
||||||
setIsSubmitting(false)
|
setIsSubmitting(false)
|
||||||
return
|
return
|
||||||
@@ -180,138 +216,285 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
|||||||
|
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{step === 'site' ? (
|
{step === 'site' ? (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>Project folder</label>
|
<label className={labelClass}>Project folder</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePickDirectory}
|
onClick={handlePickDirectory}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
|
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
|
||||||
directory
|
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-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'
|
: '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>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.04]">
|
<div>
|
||||||
<div className="mb-3">
|
<label className={labelClass}>Project type</label>
|
||||||
<p className="text-sm font-semibold">Development stack</p>
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<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>
|
{applicationModules
|
||||||
</div>
|
.map((module) => ({
|
||||||
<div className="mb-4 grid gap-2 sm:grid-cols-2">
|
value: module.id,
|
||||||
<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>
|
label: module.name,
|
||||||
<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>
|
defaults: module.defaults,
|
||||||
</div>
|
creation: module.creation
|
||||||
<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>
|
.map((t) => {
|
||||||
<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>
|
const Icon = TYPE_ICONS[t.value] ?? Boxes
|
||||||
<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>
|
const isSelected = projectType === t.value
|
||||||
<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">
|
return (
|
||||||
{[['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>)}
|
<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>
|
||||||
</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]">
|
||||||
<label className={labelClass}>Docroot (optional)</label>
|
<div className="mb-3">
|
||||||
<input
|
<p className="text-sm font-semibold">Development stack</p>
|
||||||
type="text"
|
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
value={docroot}
|
Aurora core owns the runtime; the application is a module layered on top.
|
||||||
onChange={(e) => setDocroot(e.target.value)}
|
</p>
|
||||||
placeholder="e.g. web, public — leave blank for project root"
|
</div>
|
||||||
className={fieldClass}
|
<div className="mb-4 grid gap-2 sm:grid-cols-2">
|
||||||
/>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</>
|
onClick={() => selectRuntimeEngine('container')}
|
||||||
) : selectedModule ? (
|
className={
|
||||||
<ExternalModuleSetup ref={setupRef} module={selectedModule} projectName={projectName.trim()} onValidityChange={setSetupValid} />
|
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'
|
||||||
<GenericSetup
|
: 'rounded-xl border border-neutral-200 bg-white p-3 text-left dark:border-white/10 dark:bg-white/[0.03]'
|
||||||
ref={setupRef}
|
}
|
||||||
projectName={projectName.trim()}
|
>
|
||||||
onValidityChange={setSetupValid}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user