Initial Aurora Dockside Alpha 23
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
out/
|
||||
dist/
|
||||
build/
|
||||
release/
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Aurora runtime/generated data
|
||||
.aurora/
|
||||
certificates/
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Archives / installers
|
||||
*.zip
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.dmg
|
||||
*.AppImage
|
||||
*.exe
|
||||
*.deb
|
||||
*.rpm
|
||||
|
||||
# Local AI / development session files
|
||||
.claude/
|
||||
CONVERSATION-LOG.md
|
||||
conversation log.md
|
||||
@@ -0,0 +1,6 @@
|
||||
out
|
||||
dist
|
||||
pnpm-lock.yaml
|
||||
LICENSE.md
|
||||
tsconfig.json
|
||||
tsconfig.*.json
|
||||
@@ -0,0 +1,4 @@
|
||||
singleQuote: true
|
||||
semi: false
|
||||
printWidth: 100
|
||||
trailingComma: none
|
||||
@@ -0,0 +1,49 @@
|
||||
# Aurora Dockside 2.0
|
||||
|
||||
Aurora Dockside is becoming a modular Docker-based local development platform for PHP and Node.js projects. Dockside is the Electron GUI; Aurora Engine owns project configuration, Docker Compose generation, runtimes, and modules.
|
||||
|
||||
## Alpha 2 module architecture
|
||||
|
||||
Each project stores its state in `.aurora/` and can be extended at runtime from the **Module Library**. Module manifests live in `resources/modules/*.json` and describe their category, settings, dependencies, conflicts, optional Docker service, and application defaults. Dockside renders module configuration controls from those manifests rather than hard-coding a UI for every module.
|
||||
|
||||
Bundled modules:
|
||||
|
||||
- Applications: WordPress, Laravel, Drupal
|
||||
- Services: Redis
|
||||
- Developer tools: Mailpit, Adminer
|
||||
|
||||
The PHP service is now built from `.aurora/Dockerfile.php` with common extensions and Composer. Installing/removing a service module regenerates Compose and reconciles the running project with `docker compose up -d --remove-orphans`. Application modules are mutually protected by manifest conflicts and can scaffold application source into an existing blank Aurora project.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run typecheck
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Docker Engine with Docker Compose v2 is required.
|
||||
|
||||
## Project files
|
||||
|
||||
```text
|
||||
project/
|
||||
.aurora/
|
||||
config.json
|
||||
compose.yaml
|
||||
nginx.conf
|
||||
Dockerfile.php
|
||||
```
|
||||
|
||||
This is an alpha. Database snapshots/import/export and automatic HTTPS/router domains are still being rebuilt for the native Aurora Engine.
|
||||
|
||||
## Alpha 3 engine milestone
|
||||
|
||||
Aurora now owns database portability instead of delegating it to DDEV. MariaDB and PostgreSQL projects support compressed snapshots, restore, SQL/SQL.GZ import, and export through Docker Compose. Database containers include health checks and the web service waits for the database to become healthy before starting. Aurora also shuts down registered project stacks when Dockside exits.
|
||||
|
||||
Snapshots are stored per-project under `.aurora/snapshots/` and can be managed from the existing Database panel.
|
||||
|
||||
|
||||
## Alpha 6 router
|
||||
|
||||
Aurora now starts a shared Traefik router on localhost ports 80 and 443 and attaches projects to the external `aurora-router` Docker network. New projects are exposed at both `http://<project>.aurora.localhost` and `https://<project>.aurora.localhost`; subdomain hosts are routed to the same project for WordPress multisite testing. HTTPS currently uses Traefik's generated local certificate, so browsers may show a trust warning until Aurora CA installation is added. The random localhost port remains published as a diagnostic fallback, but is no longer the primary project URL.
|
||||
@@ -0,0 +1,33 @@
|
||||
appId: com.aurora-dockside.app
|
||||
productName: Aurora Dockside
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
executableName: aurora-dockside
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
mac:
|
||||
entitlementsInherit: build/entitlements.mac.plist
|
||||
notarize: false
|
||||
dmg:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
category: Utility
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
npmRebuild: false
|
||||
@@ -0,0 +1,18 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
main: {},
|
||||
preload: {},
|
||||
renderer: {
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('src/shared')
|
||||
}
|
||||
},
|
||||
plugins: [react(), tailwindcss()]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import tseslint from '@electron-toolkit/eslint-config-ts'
|
||||
import eslintConfigPrettier from '@electron-toolkit/eslint-config-prettier'
|
||||
import eslintPluginReact from 'eslint-plugin-react'
|
||||
import eslintPluginReactHooks from 'eslint-plugin-react-hooks'
|
||||
import eslintPluginReactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ['**/node_modules', '**/dist', '**/out'] },
|
||||
tseslint.configs.recommended,
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
eslintPluginReact.configs.flat['jsx-runtime'],
|
||||
{
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': eslintPluginReactHooks,
|
||||
'react-refresh': eslintPluginReactRefresh
|
||||
},
|
||||
rules: {
|
||||
...eslintPluginReactHooks.configs.recommended.rules,
|
||||
...eslintPluginReactRefresh.configs.vite.rules
|
||||
}
|
||||
},
|
||||
eslintConfigPrettier
|
||||
)
|
||||
Generated
+11626
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "aurora-dockside",
|
||||
"version": "2.0.0-alpha.23",
|
||||
"description": "A modern, modular Docker development platform for building, running, and managing web applications locally.",
|
||||
"main": "./out/main/index.js",
|
||||
"homepage": "https://github.com/KrisAsvestas/auroradockside",
|
||||
"author": {
|
||||
"name": "Kris Asvestas"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint --cache .",
|
||||
"lint:fix": "eslint --cache --fix .",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.28.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"jsdom": "^30.0.1",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/KrisAsvestas/auroradockside.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/KrisAsvestas/auroradockside/issues"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 356 KiB |
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id":"adminer","name":"Adminer","version":"1.0.0","category":"tool","description":"Lightweight browser database administration.","icon":"database","dependencies":[],"conflicts":[],"settings":[],
|
||||
"compose":{"service":"adminer","image":"adminer:latest","ports":[8080],"dependsOn":["db"]}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"id":"drupal","name":"Drupal","version":"1.0.0","category":"application","description":"Drupal CMS using the recommended Composer project layout.","icon":"layers","dependencies":[],"conflicts":["wordpress","laravel"],"defaults":{"docroot":"web"},"settings":[]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id":"laravel","name":"Laravel","version":"1.0.0","category":"application","description":"Laravel application with Composer and Vite-ready Node runtime.","icon":"code","dependencies":[],"conflicts":["wordpress","drupal"],"defaults":{"docroot":"public"},
|
||||
"settings":[{"id":"starter","label":"Starter kit","type":"select","default":"none","options":["none","breeze"]}]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id":"mailpit","name":"Mailpit","version":"1.0.0","category":"tool","description":"Local SMTP inbox and browser-based mail viewer.","icon":"mail","dependencies":[],"conflicts":[],"settings":[],
|
||||
"compose":{"service":"mailpit","image":"axllent/mailpit:latest","ports":[8025,1025]}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id":"redis","name":"Redis","version":"1.0.0","category":"service","description":"Redis 8 cache and data store.","icon":"database","dependencies":[],"conflicts":[],
|
||||
"settings":[{"id":"persistence","label":"Enable persistence","type":"boolean","default":true}],
|
||||
"compose":{"service":"redis","image":"redis:8-alpine","ports":[6379]}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"id":"wordpress","name":"WordPress","version":"1.0.0","category":"application","description":"WordPress CMS with WP-CLI tooling.","icon":"globe","dependencies":[],"conflicts":["laravel","drupal"],"defaults":{"docroot":""},
|
||||
"settings":[{"id":"wp_debug","label":"WP_DEBUG","type":"boolean","default":true},{"id":"multisite","label":"Enable Multisite","type":"boolean","default":false}]
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { app } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { mkdir, readFile, writeFile, access, rm, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { AuroraProjectDetail, AuroraProjectSummary, AuroraInstalledModule, AuroraModuleManifest, AuroraStackOptions } from '../shared/types'
|
||||
import { getModuleManifest, getModuleRegistry } from './moduleRegistry'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const EXTRA_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/opt/local/bin']
|
||||
export const AURORA_ENV = { ...process.env, PATH: [...EXTRA_PATH_DIRS, process.env.PATH].join(':') }
|
||||
|
||||
type AuroraConfig = {
|
||||
name: string
|
||||
type: string
|
||||
docroot: string
|
||||
php: string
|
||||
node: string
|
||||
webserver: 'nginx'
|
||||
database: 'mariadb' | 'postgres'
|
||||
databaseVersion: string
|
||||
modules: string[]
|
||||
moduleSettings?: Record<string, Record<string, string | number | boolean>>
|
||||
primaryProtocol?: 'http' | 'https'
|
||||
wordpressMultisite?: 'none' | 'subdirectory' | 'subdomain'
|
||||
xdebug?: boolean
|
||||
}
|
||||
|
||||
type Registry = { projects: Record<string, string> }
|
||||
const configDir = (root: string): string => join(root, '.aurora')
|
||||
const configPath = (root: string): string => join(configDir(root), 'config.json')
|
||||
const composePath = (root: string): string => join(configDir(root), 'compose.yaml')
|
||||
const phpDockerfilePath = (root: string): string => join(configDir(root), 'Dockerfile.php')
|
||||
const registryPath = (): string => join(app.getPath('userData'), 'projects.json')
|
||||
const routerDir = (): string => join(app.getPath('userData'), 'router')
|
||||
const routerConfigPath = (): string => join(routerDir(), 'dynamic.yaml')
|
||||
const certDir = (): string => join(app.getPath('userData'), 'certificates')
|
||||
const caDir = (): string => join(certDir(), 'ca')
|
||||
const projectsCertDir = (): string => join(certDir(), 'projects')
|
||||
const caKeyPath = (): string => join(caDir(), 'aurora-root-ca.key')
|
||||
const caCertPath = (): string => join(caDir(), 'aurora-root-ca.crt')
|
||||
const projectCertPath = (name: string): string => join(projectsCertDir(), `${safeName(name)}.crt`)
|
||||
const projectKeyPath = (name: string): string => join(projectsCertDir(), `${safeName(name)}.key`)
|
||||
const safeName = (name: string): string => name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
|
||||
const projectHost = (name: string): string => `${safeName(name)}.aurora.localhost`
|
||||
export const projectUrls = (name: string) => ({ http: `http://${projectHost(name)}`, https: `https://${projectHost(name)}` })
|
||||
|
||||
async function loadRegistry(): Promise<Registry> {
|
||||
try { return JSON.parse(await readFile(registryPath(), 'utf8')) as Registry } catch { return { projects: {} } }
|
||||
}
|
||||
async function saveRegistry(registry: Registry): Promise<void> {
|
||||
await mkdir(app.getPath('userData'), { recursive: true })
|
||||
await writeFile(registryPath(), JSON.stringify(registry, null, 2))
|
||||
}
|
||||
async function readConfig(root: string): Promise<AuroraConfig> {
|
||||
return JSON.parse(await readFile(configPath(root), 'utf8')) as AuroraConfig
|
||||
}
|
||||
async function writeConfig(root: string, config: AuroraConfig): Promise<void> {
|
||||
await mkdir(configDir(root), { recursive: true })
|
||||
await writeFile(configPath(root), JSON.stringify(config, null, 2))
|
||||
await writeFile(composePath(root), await renderCompose(config))
|
||||
}
|
||||
|
||||
function indent(lines: string, spaces = 4): string {
|
||||
const pad = ' '.repeat(spaces)
|
||||
return lines.split('\n').map((line) => line ? pad + line : line).join('\n')
|
||||
}
|
||||
|
||||
async function renderModuleService(module: AuroraModuleManifest, config: AuroraConfig): Promise<string> {
|
||||
if (!module.compose) return ''
|
||||
const { service, image, ports = [], dependsOn = [] } = module.compose
|
||||
const settings = config.moduleSettings?.[module.id] ?? {}
|
||||
const lines = [`${service}:`, ` image: ${image}`]
|
||||
if (module.id === 'redis' && settings.persistence !== false) {
|
||||
lines.push(' volumes:', ' - redis_data:/data')
|
||||
}
|
||||
if (ports.length) {
|
||||
lines.push(' ports:')
|
||||
for (const port of ports) lines.push(` - "127.0.0.1::${port}"`)
|
||||
}
|
||||
if (dependsOn.length) lines.push(' depends_on:', ...dependsOn.map((dep) => ` - ${dep}`))
|
||||
if (module.id === 'mailpit') lines.push(' networks:', ' default:', ' aurora-router:', ' aliases:', ` - aurora-${safeName(config.name)}-mailpit`)
|
||||
return indent(lines.join('\n'), 2)
|
||||
}
|
||||
|
||||
async function renderCompose(c: AuroraConfig): Promise<string> {
|
||||
const db = c.database === 'postgres'
|
||||
? ` db:\n image: postgres:${c.databaseVersion}\n environment:\n POSTGRES_DB: db\n POSTGRES_USER: db\n POSTGRES_PASSWORD: db\n volumes:\n - db_data:/var/lib/postgresql/data\n healthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U db -d db\"]\n interval: 3s\n timeout: 3s\n retries: 20`
|
||||
: ` db:\n image: mariadb:${c.databaseVersion}\n environment:\n MARIADB_DATABASE: db\n MARIADB_USER: db\n MARIADB_PASSWORD: db\n MARIADB_ROOT_PASSWORD: root\n volumes:\n - db_data:/var/lib/mysql\n healthcheck:\n test: [\"CMD\", \"healthcheck.sh\", \"--connect\", \"--innodb_initialized\"]\n interval: 3s\n timeout: 3s\n retries: 20`
|
||||
const manifests = await Promise.all(c.modules.filter((id) => id !== 'adminer').map((id) => getModuleManifest(id)))
|
||||
const extras = (await Promise.all(manifests.map((m) => renderModuleService(m, c)))).filter(Boolean)
|
||||
const volumes = [' db_data:']
|
||||
if (c.modules.includes('redis') && c.moduleSettings?.redis?.persistence !== false) volumes.push(' redis_data:')
|
||||
const adminer = c.modules.includes('adminer') ? ` adminer:\n image: adminer:5\n environment:\n ADMINER_DEFAULT_SERVER: db\n networks:\n default:\n aurora-router:\n aliases:\n - aurora-${safeName(c.name)}-adminer\n depends_on:\n db:\n condition: service_healthy` : ''
|
||||
return `name: aurora-${safeName(c.name)}\nservices:\n web:\n image: nginx:1.29-alpine\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro\n ports:\n - "127.0.0.1::80"\n networks:\n default:\n aurora-router:\n aliases:\n - aurora-${safeName(c.name)}-web\n depends_on:\n php:\n condition: service_started\n db:\n condition: service_healthy\n php:\n build:\n context: .\n dockerfile: Dockerfile.php\n args:\n PHP_VERSION: ${c.php}\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n node:\n image: node:${c.node}-alpine\n working_dir: /var/www/html\n volumes:\n - ../:/var/www/html\n command: ["sh", "-c", "sleep infinity"]\n${db}${adminer ? `\n${adminer}` : ''}${extras.length ? `\n${extras.join('\n')}` : ''}\nvolumes:\n${volumes.join('\n')}\nnetworks:\n aurora-router:\n external: true\n name: aurora-router\n`
|
||||
}
|
||||
async function writePhpDockerfile(root: string, xdebug = false): Promise<void> {
|
||||
await writeFile(phpDockerfilePath(root), `ARG PHP_VERSION=8.4
|
||||
FROM php:${'${PHP_VERSION}'}-fpm-alpine
|
||||
RUN apk add --no-cache icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev oniguruma-dev postgresql-dev \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install -j$(nproc) mysqli pdo_mysql pdo_pgsql intl zip gd mbstring opcache
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||
${xdebug ? 'RUN apk add --no-cache $PHPIZE_DEPS linux-headers && pecl install xdebug && docker-php-ext-enable xdebug' : ''}
|
||||
`)
|
||||
}
|
||||
|
||||
async function writeNginx(root: string, docroot: string): Promise<void> {
|
||||
const webroot = docroot ? `/var/www/html/${docroot}` : '/var/www/html'
|
||||
await writeFile(join(configDir(root), 'nginx.conf'), `map $http_x_forwarded_proto $aurora_https {
|
||||
default off;
|
||||
https on;
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root ${webroot};
|
||||
index index.php index.html;
|
||||
location / { try_files $uri $uri/ /index.php?$query_string; }
|
||||
location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS $aurora_https; fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto; fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host; fastcgi_pass php:9000; }
|
||||
}
|
||||
`)
|
||||
}
|
||||
|
||||
export async function createProject(root: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>): Promise<void> {
|
||||
await mkdir(root, { recursive: true })
|
||||
const normalizedType = type || 'generic'
|
||||
const defaultDocroot = docroot || (normalizedType === 'laravel' ? 'public' : normalizedType === 'drupal' ? 'web' : '')
|
||||
const modules = normalizedType === 'generic' || normalizedType === 'php' ? [] : [normalizedType]
|
||||
if (stack?.adminer !== false) modules.push('adminer')
|
||||
if (stack?.redis) modules.push('redis')
|
||||
if (stack?.mailpit) modules.push('mailpit')
|
||||
const config: AuroraConfig = { name, type: normalizedType, docroot: defaultDocroot, php: stack?.phpVersion || '8.4', node: stack?.nodeVersion || '24', webserver: 'nginx', database: stack?.database || 'mariadb', databaseVersion: stack?.databaseVersion || '11.8', modules, moduleSettings: {}, primaryProtocol: 'https', xdebug: stack?.xdebug === true }
|
||||
await writeConfig(root, config); await writeNginx(root, defaultDocroot); await writePhpDockerfile(root, config.xdebug)
|
||||
const reg = await loadRegistry(); reg.projects[name] = root; await saveRegistry(reg)
|
||||
if (normalizedType === 'generic' || normalizedType === 'php') await writeFile(join(root, 'index.php'), `<?php echo '<h1>${name}</h1><p>Aurora Dockside is running.</p>';`)
|
||||
}
|
||||
export async function unregisterProject(name: string, deleteFiles: boolean): Promise<void> {
|
||||
const reg = await loadRegistry(); const root = reg.projects[name]; delete reg.projects[name]; await saveRegistry(reg)
|
||||
if (deleteFiles && root) await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
async function composeJson(root: string): Promise<any[]> {
|
||||
try { const { stdout } = await execFileAsync('docker', ['compose', '-f', composePath(root), 'ps', '--format', 'json'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 }); return stdout.trim().split('\n').filter(Boolean).map(x => JSON.parse(x)) } catch { return [] }
|
||||
}
|
||||
export async function listProjects(): Promise<AuroraProjectSummary[]> {
|
||||
const reg = await loadRegistry(); const out: AuroraProjectSummary[] = []
|
||||
for (const [name, root] of Object.entries(reg.projects)) {
|
||||
try { await access(configPath(root)); const c = await readConfig(root); const ps = await composeJson(root); const running = ps.some(p => p.State === 'running'); const urls = projectUrls(c.name); const primary = c.primaryProtocol === 'http' ? urls.http : urls.https
|
||||
out.push({ name, status: running?'running':'stopped', status_desc: running?'Running':'Stopped', type:c.type, approot:root, shortroot:root, docroot:c.docroot, primary_url:primary, httpurl:urls.http, httpsurl:urls.https, mutagen_enabled:false })
|
||||
} catch { /* stale registry entry */ }
|
||||
} return out
|
||||
}
|
||||
export async function describeProject(name: string): Promise<AuroraProjectDetail> {
|
||||
const reg = await loadRegistry(); const root = reg.projects[name]; if (!root) throw new Error(`Aurora project '${name}' not found`)
|
||||
const c = await readConfig(root); const ps = await composeJson(root); const running = ps.some(p=>p.State==='running'); const currentRouterStatus = await routerStatus(); const urlSet=projectUrls(c.name); const primary=c.primaryProtocol === 'http' ? urlSet.http : urlSet.https
|
||||
const services: Record<string, any> = {}; for (const p of ps) services[p.Service]={short_name:p.Service,full_name:p.Name,status:p.State,image:p.Image,exposed_ports:'',host_ports:'',host_ports_mapping:[]}
|
||||
return { name,status:running?'running':'stopped',status_desc:running?'Running':'Stopped',type:c.type,approot:root,shortroot:root,docroot:c.docroot,primary_url:primary,httpurl:urlSet.http,httpsurl:urlSet.https,mutagen_enabled:false,database_type:c.database,database_version:c.databaseVersion,dbinfo:{database_type:c.database,database_version:c.databaseVersion,dbPort:c.database==='postgres'?'5432':'3306',dbname:'db',host:'db',password:'db',published_port:0,username:'db'},hostname:projectHost(c.name),hostnames:[projectHost(c.name)],httpURLs:[urlSet.http],httpsURLs:[urlSet.https],urls:[urlSet.http,urlSet.https],php_version:c.php,nodejs_version:c.node,webserver_type:'nginx',router:'file',router_status:currentRouterStatus,certificate_status:await certificateStatus(c.name),ca_trust_status:await caTrustStatus(),firefox_trust_status:await firefoxTrustStatus(),chromium_trust_status:await chromiumTrustStatus(),wordpress_multisite:c.type==='wordpress'?(c.wordpressMultisite??'none'):undefined,wordpress_network_admin_url:c.type==='wordpress'&&c.wordpressMultisite&&c.wordpressMultisite!=='none'?`${primary.replace(/\/$/,'')}/wp-admin/network/`:undefined,adminer_url:c.modules.includes('adminer')?`https://adminer.${projectHost(c.name)}`:undefined,services,xdebug_enabled:c.xdebug===true }
|
||||
}
|
||||
export async function updateEnvironment(root:string, updates:{phpVersion?:string;nodeVersion?:string;database?:string;xdebugEnabled?:boolean;primaryProtocol?:'http'|'https'}):Promise<void>{
|
||||
const c=await readConfig(root)
|
||||
if(updates.phpVersion)c.php=updates.phpVersion
|
||||
if(updates.nodeVersion)c.node=updates.nodeVersion
|
||||
if(typeof updates.xdebugEnabled === 'boolean') c.xdebug=updates.xdebugEnabled
|
||||
if(updates.database){const [kind,version]=updates.database.split(':'); if(kind==='mariadb'||kind==='postgres'){c.database=kind;c.databaseVersion=version||c.databaseVersion}}
|
||||
if(updates.primaryProtocol === 'http' || updates.primaryProtocol === 'https') c.primaryProtocol = updates.primaryProtocol
|
||||
await writeConfig(root,c); await writeNginx(root,c.docroot); await writePhpDockerfile(root, c.xdebug === true)
|
||||
}
|
||||
|
||||
export async function getProjectConfig(root: string): Promise<AuroraConfig> { return readConfig(root) }
|
||||
|
||||
export async function setWordpressMultisite(root: string, mode: 'none' | 'subdirectory' | 'subdomain'): Promise<void> {
|
||||
const config = await readConfig(root)
|
||||
config.wordpressMultisite = mode
|
||||
await writeConfig(root, config)
|
||||
}
|
||||
|
||||
export async function routerStatus(): Promise<'running' | 'provider-error' | 'stopped'> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}}', 'aurora-router'], { env: AURORA_ENV })
|
||||
if (stdout.trim() !== 'true') return 'stopped'
|
||||
const { stdout: logs = '', stderr: logErrors = '' } = await execFileAsync('docker', ['logs', '--tail', '40', 'aurora-router'], { env: AURORA_ENV, maxBuffer: 2 * 1024 * 1024 })
|
||||
const recentLogs = `${logs}\n${logErrors}`
|
||||
if (recentLogs.includes('Error while building configuration') || recentLogs.includes('field not found, node:')) return 'provider-error'
|
||||
return 'running'
|
||||
} catch { return 'stopped' }
|
||||
}
|
||||
|
||||
export async function routerRunning(): Promise<boolean> {
|
||||
return (await routerStatus()) === 'running'
|
||||
}
|
||||
|
||||
export async function listModules(): Promise<AuroraModuleManifest[]> {
|
||||
return getModuleRegistry()
|
||||
}
|
||||
|
||||
export async function listInstalledModules(name: string): Promise<AuroraInstalledModule[]> {
|
||||
const reg = await loadRegistry()
|
||||
const root = reg.projects[name]
|
||||
if (!root) throw new Error(`Aurora project '${name}' not found`)
|
||||
const config = await readConfig(root)
|
||||
const manifests = await Promise.all(config.modules.map((id) => getModuleManifest(id)))
|
||||
return manifests.map((module) => ({ id: module.id, name: module.name, version: module.version, category: module.category }))
|
||||
}
|
||||
|
||||
export async function setModule(
|
||||
name: string,
|
||||
moduleId: string,
|
||||
install: boolean,
|
||||
settings: Record<string, string | number | boolean> = {}
|
||||
): Promise<void> {
|
||||
const reg = await loadRegistry()
|
||||
const root = reg.projects[name]
|
||||
if (!root) throw new Error(`Aurora project '${name}' not found`)
|
||||
const config = await readConfig(root)
|
||||
const module = await getModuleManifest(moduleId)
|
||||
config.moduleSettings ??= {}
|
||||
|
||||
if (install) {
|
||||
const conflict = module.conflicts.find((id) => config.modules.includes(id))
|
||||
if (conflict) throw new Error(`${module.name} conflicts with the installed '${conflict}' application module.`)
|
||||
for (const dependency of module.dependencies) {
|
||||
if (!config.modules.includes(dependency)) config.modules.push(dependency)
|
||||
}
|
||||
if (!config.modules.includes(moduleId)) config.modules.push(moduleId)
|
||||
config.moduleSettings[moduleId] = Object.fromEntries(module.settings.map((item) => [item.id, item.default]))
|
||||
Object.assign(config.moduleSettings[moduleId], settings)
|
||||
if (module.category === 'application') {
|
||||
config.type = moduleId
|
||||
if (module.defaults?.docroot !== undefined) config.docroot = module.defaults.docroot
|
||||
}
|
||||
} else {
|
||||
const dependents = (await getModuleRegistry()).filter((item) => item.dependencies.includes(moduleId) && config.modules.includes(item.id))
|
||||
if (dependents.length) throw new Error(`Cannot remove ${module.name}; required by ${dependents.map((item) => item.name).join(', ')}.`)
|
||||
config.modules = config.modules.filter((id) => id !== moduleId)
|
||||
delete config.moduleSettings[moduleId]
|
||||
if (config.type === moduleId) config.type = 'generic'
|
||||
}
|
||||
await writeConfig(root, config)
|
||||
await writeNginx(root, config.docroot)
|
||||
await writePhpDockerfile(root, config.xdebug === true)
|
||||
}
|
||||
|
||||
export async function scaffoldApplicationModule(name: string, moduleId: string): Promise<void> {
|
||||
const root = await getProjectRoot(name)
|
||||
const module = await getModuleManifest(moduleId)
|
||||
if (module.category !== 'application') return
|
||||
if (moduleId === 'wordpress') {
|
||||
await execFileAsync('docker', ['run', '--rm', '-v', `${root}:/app`, '-w', '/app', 'wordpress:cli', 'core', 'download', '--skip-content', '--force', '--allow-root'], { env: AURORA_ENV, maxBuffer: 16 * 1024 * 1024 })
|
||||
return
|
||||
}
|
||||
const packageName = moduleId === 'laravel' ? 'laravel/laravel' : moduleId === 'drupal' ? 'drupal/recommended-project' : null
|
||||
if (!packageName) return
|
||||
await execFileAsync('docker', ['run', '--rm', '-v', `${root}:/app`, 'composer:2', 'sh', '-lc', `rm -rf /tmp/aurora-app && composer create-project ${packageName} /tmp/aurora-app --no-interaction && cp -a /tmp/aurora-app/. /app/`], { env: AURORA_ENV, maxBuffer: 32 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
export async function getProjectRoot(name:string):Promise<string>{const r=await loadRegistry();if(!r.projects[name])throw new Error(`Project ${name} not found`);return r.projects[name]}
|
||||
|
||||
export async function getProjectConfigByRoot(root: string): Promise<{ database: 'mariadb' | 'postgres'; databaseVersion: string; name: string }> {
|
||||
const config = await readConfig(root)
|
||||
return { database: config.database, databaseVersion: config.databaseVersion, name: config.name }
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function ensureCertificateAuthority(): Promise<void> {
|
||||
await mkdir(caDir(), { recursive: true })
|
||||
try { await access(caKeyPath()); await access(caCertPath()); return } catch { /* create below */ }
|
||||
await execFileAsync('openssl', ['req','-x509','-newkey','rsa:3072','-sha256','-days','3650','-nodes','-keyout',caKeyPath(),'-out',caCertPath(),'-subj','/CN=Aurora Dockside Local Development CA','-addext','basicConstraints=critical,CA:TRUE','-addext','keyUsage=critical,keyCertSign,cRLSign'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
}
|
||||
|
||||
async function ensureProjectCertificate(name: string): Promise<void> {
|
||||
await ensureCertificateAuthority()
|
||||
await mkdir(projectsCertDir(), { recursive: true })
|
||||
const cert = projectCertPath(name); const key = projectKeyPath(name)
|
||||
try { await access(cert); await access(key); return } catch { /* create below */ }
|
||||
const host = projectHost(name)
|
||||
const csr = join(projectsCertDir(), `${safeName(name)}.csr`)
|
||||
await execFileAsync('openssl', ['req','-new','-newkey','rsa:2048','-nodes','-keyout',key,'-out',csr,'-subj',`/CN=${host}`,'-addext',`subjectAltName=DNS:${host},DNS:*.${host}`], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
await execFileAsync('openssl', ['x509','-req','-in',csr,'-CA',caCertPath(),'-CAkey',caKeyPath(),'-CAcreateserial','-out',cert,'-days','825','-sha256','-copy_extensions','copy'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
await rm(csr, { force: true })
|
||||
}
|
||||
|
||||
export async function certificateStatus(name: string): Promise<'generated' | 'missing'> {
|
||||
try { await access(projectCertPath(name)); await access(projectKeyPath(name)); return 'generated' } catch { return 'missing' }
|
||||
}
|
||||
|
||||
export async function caTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unknown'> {
|
||||
if (process.platform !== 'linux') return 'unknown'
|
||||
try { await access('/usr/local/share/ca-certificates/aurora-dockside-local-ca.crt'); return 'trusted' } catch { return 'not-trusted' }
|
||||
}
|
||||
|
||||
const firefoxRoots = (): string[] => {
|
||||
const home = process.env.HOME || app.getPath('home')
|
||||
return [join(home,'.mozilla','firefox'), join(home,'snap','firefox','common','.mozilla','firefox')]
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try { await access(path); return true } catch { return false }
|
||||
}
|
||||
|
||||
async function firefoxProfiles(): Promise<string[]> {
|
||||
const profiles: string[] = []
|
||||
for (const root of firefoxRoots()) {
|
||||
// Prefer the active/default profile declared by Firefox itself.
|
||||
try {
|
||||
const ini = await readFile(join(root, 'profiles.ini'), 'utf8')
|
||||
const sections = ini.split(/^\s*\[/m).map((section, index) => index === 0 ? section : '[' + section)
|
||||
for (const section of sections) {
|
||||
if (!/^\[Profile\d+\]/m.test(section) || !/^Default=1\s*$/m.test(section)) continue
|
||||
const match = section.match(/^Path=(.+)\s*$/m)
|
||||
if (!match) continue
|
||||
const profile = join(root, match[1].trim())
|
||||
if (await pathExists(join(profile, 'cert9.db'))) profiles.push(profile)
|
||||
}
|
||||
} catch { /* no profiles.ini */ }
|
||||
|
||||
// Fall back to every NSS profile, but never add duplicates.
|
||||
try {
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const dir = join(root, entry.name)
|
||||
if (!profiles.includes(dir) && await pathExists(join(dir,'cert9.db'))) profiles.push(dir)
|
||||
}
|
||||
} catch { /* Firefox root does not exist */ }
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
async function hasCertutil(): Promise<boolean> {
|
||||
try { await execFileAsync('certutil',['-L','-d','sql:/dev/null'],{env:AURORA_ENV,maxBuffer:1024*1024}); return true } catch (e:any) {
|
||||
return e?.code !== 'ENOENT'
|
||||
}
|
||||
}
|
||||
|
||||
async function nssHasTrustedAuroraCA(db: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('certutil',['-L','-d',`sql:${db}`],{env:AURORA_ENV,maxBuffer:1024*1024})
|
||||
const line = stdout.split(/\r?\n/).find((value) => value.includes('Aurora Dockside Local Development CA'))
|
||||
return Boolean(line && /\bC,,\s*$/.test(line.trim()))
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
export async function firefoxTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unavailable' | 'unknown'> {
|
||||
if (process.platform !== 'linux') return 'unknown'
|
||||
if (!(await hasCertutil())) return 'unavailable'
|
||||
const profiles = await firefoxProfiles()
|
||||
if (!profiles.length) return 'unknown'
|
||||
for (const profile of profiles) if (!(await nssHasTrustedAuroraCA(profile))) return 'not-trusted'
|
||||
return 'trusted'
|
||||
}
|
||||
|
||||
const chromiumNssDb = (): string => join(process.env.HOME || app.getPath('home'), '.pki', 'nssdb')
|
||||
|
||||
async function chromiumTrustTargetExists(): Promise<boolean> {
|
||||
const db = chromiumNssDb()
|
||||
if (await pathExists(join(db, 'cert9.db'))) return true
|
||||
for (const command of ['google-chrome','google-chrome-stable','chromium','chromium-browser','brave-browser','microsoft-edge','vivaldi']) {
|
||||
try { await execFileAsync('which',[command],{env:AURORA_ENV,maxBuffer:1024*1024}); return true } catch { /* try next */ }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function chromiumTrustStatus(): Promise<'trusted' | 'not-trusted' | 'unavailable' | 'unknown'> {
|
||||
if (process.platform !== 'linux') return 'unknown'
|
||||
if (!(await chromiumTrustTargetExists())) return 'unknown'
|
||||
if (!(await hasCertutil())) return 'unavailable'
|
||||
return await nssHasTrustedAuroraCA(chromiumNssDb()) ? 'trusted' : 'not-trusted'
|
||||
}
|
||||
|
||||
async function installIntoNssDb(db: string): Promise<void> {
|
||||
await mkdir(db, { recursive: true })
|
||||
if (!(await pathExists(join(db,'cert9.db')))) {
|
||||
await execFileAsync('certutil',['-N','--empty-password','-d',`sql:${db}`],{env:AURORA_ENV,maxBuffer:1024*1024})
|
||||
}
|
||||
try { await execFileAsync('certutil',['-D','-d',`sql:${db}`,'-n','Aurora Dockside Local Development CA'],{env:AURORA_ENV,maxBuffer:1024*1024}) } catch { /* absent is fine */ }
|
||||
await execFileAsync('certutil',['-A','-d',`sql:${db}`,'-n','Aurora Dockside Local Development CA','-t','C,,','-i',caCertPath()],{env:AURORA_ENV,maxBuffer:1024*1024})
|
||||
if (!(await nssHasTrustedAuroraCA(db))) throw new Error(`Aurora CA import verification failed for NSS database: ${db}`)
|
||||
}
|
||||
|
||||
export async function trustAuroraCA(): Promise<void> {
|
||||
await ensureCertificateAuthority()
|
||||
if (process.platform !== 'linux') throw new Error('Automatic CA trust is currently implemented for Linux only.')
|
||||
|
||||
const script = `install -m 0644 "${caCertPath().replace(/"/g, '\\"')}" /usr/local/share/ca-certificates/aurora-dockside-local-ca.crt && update-ca-certificates`
|
||||
await execFileAsync('pkexec', ['sh','-c',script], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
|
||||
if (!(await hasCertutil())) {
|
||||
await execFileAsync('pkexec',['sh','-c','apt-get update && apt-get install -y libnss3-tools'],{env:AURORA_ENV,maxBuffer:32*1024*1024})
|
||||
}
|
||||
|
||||
// These are the exact stores verified on Ubuntu: Snap/native Firefox profiles
|
||||
// and the shared Chromium NSS database at ~/.pki/nssdb.
|
||||
const profiles = await firefoxProfiles()
|
||||
for (const profile of profiles) await installIntoNssDb(profile)
|
||||
|
||||
if (await chromiumTrustTargetExists()) await installIntoNssDb(chromiumNssDb())
|
||||
|
||||
const firefox = await firefoxTrustStatus()
|
||||
const chromium = await chromiumTrustStatus()
|
||||
if (profiles.length && firefox !== 'trusted') throw new Error('Aurora CA could not be verified in the active Firefox NSS trust store.')
|
||||
if (await chromiumTrustTargetExists() && chromium !== 'trusted') throw new Error('Aurora CA could not be verified in ~/.pki/nssdb for Chromium-family browsers.')
|
||||
}
|
||||
|
||||
async function writeRouterConfig(): Promise<void> {
|
||||
const registry = await loadRegistry()
|
||||
const routers: string[] = []
|
||||
const services: string[] = []
|
||||
for (const [name, root] of Object.entries(registry.projects)) {
|
||||
try {
|
||||
const config = await readConfig(root)
|
||||
await ensureProjectCertificate(config.name || name)
|
||||
const safe = safeName(config.name || name)
|
||||
const host = projectHost(config.name || name)
|
||||
const rule = config.type === 'wordpress' && config.wordpressMultisite === 'subdomain'
|
||||
? `Host(\`${host}\`) || HostRegexp(\`^[a-z0-9-]+\\.${host.replace(/\./g, '\\.')}$\`)`
|
||||
: `Host(\`${host}\`)`
|
||||
routers.push(
|
||||
` aurora-${safe}-http:\n rule: '${rule}'\n entryPoints: [web]\n service: aurora-${safe}`,
|
||||
` aurora-${safe}-https:\n rule: '${rule}'\n entryPoints: [websecure]\n service: aurora-${safe}\n tls: {}`
|
||||
)
|
||||
services.push(` aurora-${safe}:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-web:80`)
|
||||
if (config.modules.includes('adminer')) routers.push(
|
||||
` aurora-${safe}-adminer-http:\n rule: 'Host(\`adminer.${host}\`)'\n entryPoints: [web]\n service: aurora-${safe}-adminer`,
|
||||
` aurora-${safe}-adminer-https:\n rule: 'Host(\`adminer.${host}\`)'\n entryPoints: [websecure]\n service: aurora-${safe}-adminer\n tls: {}`
|
||||
)
|
||||
if (config.modules.includes('adminer')) services.push(` aurora-${safe}-adminer:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-adminer:8080`)
|
||||
if (config.modules.includes('mailpit')) {
|
||||
routers.push(
|
||||
` aurora-${safe}-mailpit-http:\n rule: 'Host(\`mail.${host}\`)'\n entryPoints: [web]\n service: aurora-${safe}-mailpit`,
|
||||
` aurora-${safe}-mailpit-https:\n rule: 'Host(\`mail.${host}\`)'\n entryPoints: [websecure]\n service: aurora-${safe}-mailpit\n tls: {}`
|
||||
)
|
||||
services.push(` aurora-${safe}-mailpit:\n loadBalancer:\n servers:\n - url: http://aurora-${safe}-mailpit:8025`)
|
||||
}
|
||||
} catch { /* ignore stale registry entries */ }
|
||||
}
|
||||
const tlsCerts: string[] = []
|
||||
for (const [name, root] of Object.entries(registry.projects)) {
|
||||
try { const config = await readConfig(root); const safe = safeName(config.name || name); tlsCerts.push(` - certFile: /etc/traefik/certs/${safe}.crt\n keyFile: /etc/traefik/certs/${safe}.key`) } catch { /* stale */ }
|
||||
}
|
||||
const dynamic = `http:\n routers:\n${routers.length ? routers.join('\n') : ' {}'}\n services:\n${services.length ? services.join('\n') : ' {}'}\n${tlsCerts.length ? `tls:\n certificates:\n${tlsCerts.join('\n')}\n` : ''}`
|
||||
await mkdir(routerDir(), { recursive: true })
|
||||
await writeFile(routerConfigPath(), dynamic)
|
||||
}
|
||||
|
||||
export async function ensureRouter(): Promise<void> {
|
||||
try { await execFileAsync('docker', ['network', 'inspect', 'aurora-router'], { env: AURORA_ENV }) }
|
||||
catch { await execFileAsync('docker', ['network', 'create', 'aurora-router'], { env: AURORA_ENV }) }
|
||||
|
||||
await writeRouterConfig()
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['inspect', '-f', '{{.State.Running}} {{json .Config.Cmd}}', 'aurora-router'], { env: AURORA_ENV })
|
||||
const fileProvider = stdout.includes('--providers.file.directory=/etc/traefik/dynamic')
|
||||
if (stdout.trimStart().startsWith('true') && fileProvider) return
|
||||
await execFileAsync('docker', ['rm', '-f', 'aurora-router'], { env: AURORA_ENV }).catch(() => undefined)
|
||||
} catch { /* router does not exist yet */ }
|
||||
|
||||
await execFileAsync('docker', [
|
||||
'run', '-d', '--name', 'aurora-router', '--restart', 'unless-stopped',
|
||||
'--network', 'aurora-router',
|
||||
'-p', '127.0.0.1:80:80', '-p', '127.0.0.1:443:443',
|
||||
'-v', `${routerDir()}:/etc/traefik/dynamic:ro`,
|
||||
'-v', `${projectsCertDir()}:/etc/traefik/certs:ro`,
|
||||
'traefik:v3.5',
|
||||
'--providers.file.directory=/etc/traefik/dynamic', '--providers.file.watch=true',
|
||||
'--entrypoints.web.address=:80', '--entrypoints.websecure.address=:443',
|
||||
'--api.dashboard=false', '--log.level=INFO'
|
||||
], { env: AURORA_ENV, maxBuffer: 8 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
export async function powerOffProjects(): Promise<void> {
|
||||
const registry = await loadRegistry()
|
||||
await Promise.allSettled(Object.values(registry.projects).map(async (root) => {
|
||||
try {
|
||||
await access(composePath(root))
|
||||
await execFileAsync('docker', ['compose', '-f', composePath(root), 'down'], { env: AURORA_ENV, cwd: root })
|
||||
} catch { /* stale project or Docker unavailable */ }
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { spawn, type ChildProcess } from 'child_process'
|
||||
import type { WebContents } from 'electron'
|
||||
import { AURORA_ENV, powerOffProjects } from './auroraEngine'
|
||||
const running = new Map<string, ChildProcess>(); const cancelledIds = new Set<string>()
|
||||
const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g
|
||||
const stripAnsi=(s:string)=>s.replace(ANSI_PATTERN,'')
|
||||
export function runCommandStreamed(operationId:string, command:string, args:string[], sender:WebContents, options:{cwd?:string}={}):Promise<void>{return new Promise((resolve,reject)=>{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const tail:string[]=[];const forward=(stream:'stdout'|'stderr')=>(data:Buffer)=>{const chunk=stripAnsi(data.toString());tail.push(chunk);if(tail.length>20)tail.shift();if(!sender.isDestroyed())sender.send('terminal:data',{operationId,stream,chunk})};child.stdout.on('data',forward('stdout'));child.stderr.on('data',forward('stderr'));child.on('error',e=>{running.delete(operationId);sender.send('terminal:exit',{operationId,exitCode:null,cancelled:false});reject(e)});child.on('close',code=>{running.delete(operationId);const cancelled=cancelledIds.delete(operationId);if(!sender.isDestroyed())sender.send('terminal:exit',{operationId,exitCode:code,cancelled});if(cancelled)reject(new Error('Command cancelled'));else if(code===0)resolve();else reject(new Error(tail.join('').trim()||`${command} exited with code ${code}`))})})}
|
||||
export function cancelCommand(id:string):boolean{const c=running.get(id);if(!c)return false;cancelledIds.add(id);c.kill();return true}
|
||||
export function startLogStream(operationId:string, command:string,args:string[],sender:WebContents,options:{cwd?:string}={}):void{const child=spawn(command,args,{env:AURORA_ENV,cwd:options.cwd,stdio:['ignore','pipe','pipe']});running.set(operationId,child);const f=(stream:'stdout'|'stderr')=>(d:Buffer)=>{if(!sender.isDestroyed())sender.send('logs:data',{operationId,stream,chunk:stripAnsi(d.toString())})};child.stdout.on('data',f('stdout'));child.stderr.on('data',f('stderr'));const done=()=>{running.delete(operationId);if(!sender.isDestroyed())sender.send('logs:exit',{operationId})};child.on('close',done);child.on('error',done)}
|
||||
export function killAllRunningCommands():void{for(const c of running.values())c.kill();running.clear()}
|
||||
export async function powerOffAllProjects():Promise<void>{await powerOffProjects()}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { app, shell, BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { registerProjectsIpc } from './ipc/projects'
|
||||
import { registerTerminalIpc } from './ipc/terminal'
|
||||
import { registerDatabaseIpc } from './ipc/database'
|
||||
import { registerModulesIpc } from './ipc/modules'
|
||||
import { registerLogsIpc } from './ipc/logs'
|
||||
import { registerCreateIpc } from './ipc/create'
|
||||
import { registerWindowIpc } from './ipc/window'
|
||||
import { registerSecretsIpc } from './ipc/secrets'
|
||||
import { registerWordpressIpc } from './ipc/wordpress'
|
||||
import { killAllRunningCommands, powerOffAllProjects } from './commandRunner'
|
||||
|
||||
function createWindow(): void {
|
||||
// Create the browser window.
|
||||
const mainWindow = new BrowserWindow({
|
||||
title: 'Aurora Dockside',
|
||||
width: 1100,
|
||||
height: 720,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
...(process.platform === 'linux' ? { icon } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
sandbox: false
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('ready-to-show', () => {
|
||||
mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// HMR for renderer base on electron-vite cli.
|
||||
// Load the remote URL for development or the local html file for production.
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished
|
||||
// initialization and is ready to create browser windows.
|
||||
// Some APIs can only be used after this event occurs.
|
||||
app.whenReady().then(() => {
|
||||
// Set app user model id for windows
|
||||
electronApp.setAppUserModelId('com.aurora-dockside.app')
|
||||
|
||||
// Default open or close DevTools by F12 in development
|
||||
// and ignore CommandOrControl + R in production.
|
||||
// see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
registerProjectsIpc()
|
||||
registerTerminalIpc()
|
||||
registerDatabaseIpc()
|
||||
registerModulesIpc()
|
||||
registerLogsIpc()
|
||||
registerCreateIpc()
|
||||
registerWindowIpc()
|
||||
registerSecretsIpc()
|
||||
registerWordpressIpc()
|
||||
|
||||
createWindow()
|
||||
|
||||
app.on('activate', function () {
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
// for applications and their menu bar to stay active until the user quits
|
||||
// explicitly with Cmd + Q.
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// Kill any still-running Aurora processes (e.g. an open `Aurora logs -f` stream),
|
||||
// then power off every running project so sites don't keep running in the
|
||||
// background after the app exits. Quit is deferred until poweroff finishes
|
||||
// (or times out), so this intercepts the first before-quit and re-fires
|
||||
// app.quit() itself once cleanup is done — the isQuitting guard stops that
|
||||
// from looping back into this handler.
|
||||
let isQuitting = false
|
||||
app.on('before-quit', (event) => {
|
||||
if (isQuitting) return
|
||||
event.preventDefault()
|
||||
isQuitting = true
|
||||
killAllRunningCommands()
|
||||
void powerOffAllProjects().finally(() => app.quit())
|
||||
})
|
||||
|
||||
// In this file you can include the rest of your app's specific main process
|
||||
// code. You can also put them in separate files and require them here.
|
||||
@@ -0,0 +1,214 @@
|
||||
import { dialog, ipcMain } from 'electron'
|
||||
import { createProject, getProjectConfigByRoot } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { AURORA_ENV, ensureRouter, projectUrls, setWordpressMultisite } from '../auroraEngine'
|
||||
import { saveSiteCredentials } from './secrets'
|
||||
import type { AuroraStackOptions } from '../../shared/types'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const safeName = (name: string): string =>
|
||||
name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
|
||||
|
||||
async function hostUserArgs(): Promise<string[]> {
|
||||
if (process.platform === 'win32') return []
|
||||
try {
|
||||
const [{ stdout: uid }, { stdout: gid }] = await Promise.all([
|
||||
execFileAsync('id', ['-u'], { env: AURORA_ENV }),
|
||||
execFileAsync('id', ['-g'], { env: AURORA_ENV })
|
||||
])
|
||||
return ['--user', `${uid.trim()}:${gid.trim()}`]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function wordpressBaseArgs(directory: string, network = false): Promise<string[]> {
|
||||
const userArgs = await hostUserArgs()
|
||||
const config = await getProjectConfigByRoot(directory)
|
||||
return [
|
||||
'run', '--rm',
|
||||
...userArgs,
|
||||
'-e', 'HOME=/tmp',
|
||||
'-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
|
||||
...(network ? ['--network', `aurora-${safeName(config.name)}_default`] : []),
|
||||
'-v', `${directory}:/app`,
|
||||
'-w', '/app',
|
||||
'--entrypoint', 'php',
|
||||
'wordpress:cli',
|
||||
'-d', 'memory_limit=512M',
|
||||
'/usr/local/bin/wp'
|
||||
]
|
||||
}
|
||||
|
||||
async function currentProjectUrl(directory: string): Promise<string> {
|
||||
const config = await getProjectConfigByRoot(directory)
|
||||
await ensureRouter()
|
||||
// Aurora provisions WordPress with HTTPS as its canonical URL. WP-CLI writes
|
||||
// the URL directly and does not need to make a browser-trusted TLS request.
|
||||
return projectUrls(config.name).https
|
||||
}
|
||||
|
||||
|
||||
export function registerCreateIpc(): void {
|
||||
ipcMain.handle('create:pickDirectory', async () => {
|
||||
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
|
||||
return r.canceled ? null : r.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('create:configure', async (_e, _id: string, directory: string, name: string, type: string, docroot: string, stack?: Partial<AuroraStackOptions>) =>
|
||||
createProject(directory, name, type, docroot, stack)
|
||||
)
|
||||
|
||||
ipcMain.handle('create:downloadWordpress', async (e, id: string, directory: string, locale: string) => {
|
||||
const args = await wordpressBaseArgs(directory)
|
||||
return runCommandStreamed(
|
||||
id,
|
||||
'docker',
|
||||
[...args, 'core', 'download', `--locale=${locale || 'en_US'}`, '--force'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'create:setupWordpress',
|
||||
async (
|
||||
e,
|
||||
id: string,
|
||||
directory: string,
|
||||
_siteUrl: string,
|
||||
title: string,
|
||||
user: string,
|
||||
password: string,
|
||||
email: string,
|
||||
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||
) => {
|
||||
const base = await wordpressBaseArgs(directory, true)
|
||||
const siteUrl = await currentProjectUrl(directory)
|
||||
|
||||
// wp-config.php is generated against Docker's internal database hostname.
|
||||
await runCommandStreamed(
|
||||
`${id}-config`,
|
||||
'docker',
|
||||
[...base, 'config', 'create', '--dbname=db', '--dbuser=db', '--dbpass=db', '--dbhost=db:3306', '--skip-check', '--force'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Always establish a known-good single-site installation first. Multisite
|
||||
// is a conversion step, not an alternate bootstrap path. This gives us a
|
||||
// populated database to verify before attempting network conversion.
|
||||
await runCommandStreamed(
|
||||
`${id}-install`,
|
||||
'docker',
|
||||
[
|
||||
...base,
|
||||
'core', 'install',
|
||||
`--url=${siteUrl}`,
|
||||
`--title=${title}`,
|
||||
`--admin_user=${user}`,
|
||||
`--admin_password=${password}`,
|
||||
`--admin_email=${email}`,
|
||||
'--skip-email'
|
||||
],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Hard gate #1: never continue from an empty/partial WordPress database.
|
||||
await runCommandStreamed(
|
||||
`${id}-verify-install`,
|
||||
'docker',
|
||||
[...base, 'core', 'is-installed'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
if (multisite !== 'none') {
|
||||
const multisiteArgs = multisite === 'subdomain' ? ['--subdomains'] : []
|
||||
await runCommandStreamed(
|
||||
`${id}-multisite-convert`,
|
||||
'docker',
|
||||
[
|
||||
...base,
|
||||
'core', 'multisite-convert',
|
||||
`--title=${title}`,
|
||||
...multisiteArgs
|
||||
],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Hard gate #2: a multisite project is not ready unless WP-CLI can see
|
||||
// the network installation. Any non-zero exit propagates to Dockside.
|
||||
await runCommandStreamed(
|
||||
`${id}-verify-network`,
|
||||
'docker',
|
||||
[...base, 'core', 'is-installed', '--network'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Verify the network table itself as a second independent sanity check.
|
||||
await runCommandStreamed(
|
||||
`${id}-verify-network-db`,
|
||||
'docker',
|
||||
[...base, 'db', 'query', "SHOW TABLES LIKE 'wp_blogs';", '--skip-column-names'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
}
|
||||
|
||||
await setWordpressMultisite(directory, multisite)
|
||||
await ensureRouter()
|
||||
|
||||
// Development-friendly defaults. Failure here should fail creation so the
|
||||
// user never gets a project Aurora claims is fully provisioned when it is not.
|
||||
await runCommandStreamed(
|
||||
`${id}-defaults`,
|
||||
'docker',
|
||||
[
|
||||
...base,
|
||||
'rewrite', 'structure', '/%postname%/', '--hard'
|
||||
],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
await runCommandStreamed(
|
||||
`${id}-debug`,
|
||||
'docker',
|
||||
[...base, 'config', 'set', 'WP_DEBUG', 'true', '--raw'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
await runCommandStreamed(
|
||||
`${id}-env`,
|
||||
'docker',
|
||||
[...base, 'config', 'set', 'WP_ENVIRONMENT_TYPE', 'local'],
|
||||
e.sender,
|
||||
{ cwd: directory }
|
||||
)
|
||||
|
||||
// Store the original development login locally after every provisioning
|
||||
// gate has succeeded. WordPress stores only a password hash, so Aurora
|
||||
// must retain the original password if it is to display it later.
|
||||
await saveSiteCredentials(directory, {
|
||||
platform: 'wordpress',
|
||||
adminUrl: `${siteUrl.replace(/\/$/, '')}/wp-admin/`,
|
||||
username: user,
|
||||
password,
|
||||
email
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('create:downloadDrupal', async (e, id: string, directory: string) =>
|
||||
runCommandStreamed(id, 'docker', ['run', '--rm', '-v', `${directory}:/app`, '-w', '/app', 'composer:2', 'create-project', 'drupal/recommended-project', '.', '--no-interaction'], e.sender, { cwd: directory })
|
||||
)
|
||||
ipcMain.handle('create:requireDrush', async (e, id: string, directory: string) =>
|
||||
runCommandStreamed(id, 'docker', ['run', '--rm', '-v', `${directory}:/app`, '-w', '/app', 'composer:2', 'require', 'drush/drush', '--no-interaction'], e.sender, { cwd: directory })
|
||||
)
|
||||
ipcMain.handle('create:setupDrupal', async () => {})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { dialog, ipcMain } from 'electron'
|
||||
import { createReadStream, createWriteStream } from 'fs'
|
||||
import { mkdir, readdir, rm, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { spawn } from 'child_process'
|
||||
import { createGunzip, createGzip } from 'zlib'
|
||||
import { AURORA_ENV, getProjectConfigByRoot } from '../auroraEngine'
|
||||
import type { AuroraSnapshot } from '../../shared/types'
|
||||
|
||||
const snapshotsDir = (root: string): string => join(root, '.aurora', 'snapshots')
|
||||
const composeFile = (root: string): string => join(root, '.aurora', 'compose.yaml')
|
||||
|
||||
function safeSnapshotName(value?: string): string {
|
||||
const fallback = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const safe = (value?.trim() || fallback).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
return safe || fallback
|
||||
}
|
||||
|
||||
function dbCommand(type: 'mariadb' | 'postgres', mode: 'dump' | 'restore'): { command: string; args: string[] } {
|
||||
if (type === 'postgres') {
|
||||
return mode === 'dump'
|
||||
? { command: 'pg_dump', args: ['-U', 'db', '-d', 'db', '--clean', '--if-exists'] }
|
||||
: { command: 'psql', args: ['-U', 'db', '-d', 'db', '-v', 'ON_ERROR_STOP=1'] }
|
||||
}
|
||||
return mode === 'dump'
|
||||
? { command: 'mariadb-dump', args: ['-udb', '-pdb', '--single-transaction', '--routines', '--triggers', 'db'] }
|
||||
: { command: 'mariadb', args: ['-udb', '-pdb', 'db'] }
|
||||
}
|
||||
|
||||
function runDatabasePipe(root: string, type: 'mariadb' | 'postgres', mode: 'dump' | 'restore', filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = dbCommand(type, mode)
|
||||
const child = spawn('docker', ['compose', '-f', composeFile(root), 'exec', '-T', 'db', db.command, ...db.args], {
|
||||
cwd: root, env: AURORA_ENV, stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
let stderr = ''
|
||||
child.stderr.on('data', (chunk) => { stderr += chunk.toString() })
|
||||
const gz = filePath.endsWith('.gz')
|
||||
let outputFinished = Promise.resolve()
|
||||
if (mode === 'dump') {
|
||||
const output = createWriteStream(filePath)
|
||||
const source = gz ? child.stdout.pipe(createGzip()) : child.stdout
|
||||
source.pipe(output)
|
||||
outputFinished = new Promise<void>((done, fail) => { output.on('finish', done); output.on('error', fail) })
|
||||
} else {
|
||||
const input = createReadStream(filePath)
|
||||
const source = gz ? input.pipe(createGunzip()) : input
|
||||
source.pipe(child.stdin)
|
||||
input.on('error', reject)
|
||||
}
|
||||
child.on('error', reject)
|
||||
child.on('close', async (code) => {
|
||||
if (code !== 0) return reject(new Error(stderr.trim() || `Database command exited with code ${code}`))
|
||||
try { await outputFinished; resolve() } catch (error) { reject(error) }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function listSnapshots(root: string): Promise<AuroraSnapshot[]> {
|
||||
const dir = snapshotsDir(root)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql.gz'))
|
||||
const rows = await Promise.all(files.map(async (name) => ({ Name: name.replace(/\.sql\.gz$/, ''), Created: (await stat(join(dir, name))).mtime.toISOString() })))
|
||||
return rows.sort((a, b) => b.Created.localeCompare(a.Created))
|
||||
}
|
||||
|
||||
export function registerDatabaseIpc():void {
|
||||
ipcMain.handle('database:listSnapshots', (_event, _name: string, root: string) => listSnapshots(root))
|
||||
ipcMain.handle('database:createSnapshot', async (_event, _operationId: string, root: string, name?: string) => {
|
||||
const config = await getProjectConfigByRoot(root); await mkdir(snapshotsDir(root), { recursive: true })
|
||||
await runDatabasePipe(root, config.database, 'dump', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`))
|
||||
})
|
||||
ipcMain.handle('database:restoreSnapshot', async (_event, _operationId: string, root: string, name: string) => {
|
||||
const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`))
|
||||
})
|
||||
ipcMain.handle('database:deleteSnapshot', async (_event, _operationId: string, root: string, name: string) => rm(join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`), { force: true }))
|
||||
ipcMain.handle('database:importFile', async (_event, _operationId: string, root: string, filePath: string) => { const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', filePath) })
|
||||
ipcMain.handle('database:exportFile', async (_event, _operationId: string, root: string, filePath: string) => { const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'dump', filePath) })
|
||||
ipcMain.handle('database:pickImportFile', async () => { const r=await dialog.showOpenDialog({properties:['openFile'],filters:[{name:'SQL dumps',extensions:['sql','gz']}]}); return r.canceled?null:r.filePaths[0] })
|
||||
ipcMain.handle('database:pickExportPath', async (_e,name:string) => { const r=await dialog.showSaveDialog({defaultPath: basename(name).endsWith('.gz') ? name : `${name}.sql.gz`, filters:[{name:'Compressed SQL dump',extensions:['gz']},{name:'SQL dump',extensions:['sql']}]}); return r.canceled?null:r.filePath })
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { getProjectRoot } from '../auroraEngine'
|
||||
import { startLogStream } from '../commandRunner'
|
||||
export function registerLogsIpc():void{ipcMain.handle('logs:start',async(event,id:string,name:string,service:string)=>{const root=await getProjectRoot(name);startLogStream(id,'docker',['compose','-f',`${root}/.aurora/compose.yaml`,'logs','-f','--tail','200',service],event.sender,{cwd:root})})}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { getProjectRoot, listInstalledModules, listModules, scaffoldApplicationModule, setModule } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
|
||||
export function registerModulesIpc(): void {
|
||||
ipcMain.handle('modules:listRegistry', () => listModules())
|
||||
ipcMain.handle('modules:listInstalled', (_event, name: string) => listInstalledModules(name))
|
||||
ipcMain.handle(
|
||||
'modules:install',
|
||||
async (
|
||||
event,
|
||||
operationId: string,
|
||||
name: string,
|
||||
moduleId: string,
|
||||
settings: Record<string, string | number | boolean>
|
||||
) => {
|
||||
await setModule(name, moduleId, true, settings)
|
||||
await scaffoldApplicationModule(name, moduleId)
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(
|
||||
operationId,
|
||||
'docker',
|
||||
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
|
||||
event.sender,
|
||||
{ cwd: root }
|
||||
)
|
||||
}
|
||||
)
|
||||
ipcMain.handle(
|
||||
'modules:remove',
|
||||
async (event, operationId: string, name: string, moduleId: string) => {
|
||||
await setModule(name, moduleId, false)
|
||||
const root = await getProjectRoot(name)
|
||||
return runCommandStreamed(
|
||||
operationId,
|
||||
'docker',
|
||||
['compose', '-f', `${root}/.aurora/compose.yaml`, 'up', '-d', '--remove-orphans'],
|
||||
event.sender,
|
||||
{ cwd: root }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import { listProjects, describeProject, getProjectRoot, unregisterProject, updateEnvironment, ensureRouter, getProjectConfig, projectUrls, AURORA_ENV, trustAuroraCA } from '../auroraEngine'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
const execFileAsync = promisify(execFile)
|
||||
const composeArgs=(root:string,...args:string[])=>['compose','-f',`${root}/.aurora/compose.yaml`,...args]
|
||||
const allowedServices = new Set(['web','php','db','node','adminer','redis','mailpit'])
|
||||
export function registerProjectsIpc():void{
|
||||
ipcMain.handle('projects:list',()=>listProjects()); ipcMain.handle('projects:describe',(_e,name:string)=>describeProject(name))
|
||||
ipcMain.handle('projects:start',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);await updateEnvironment(root,{});await ensureRouter();return runCommandStreamed(id,'docker',composeArgs(root,'up','-d','--build','--remove-orphans'),e.sender,{cwd:root})})
|
||||
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',async(e,id:string,name:string)=>{const root=await getProjectRoot(name);await updateEnvironment(root,{});await ensureRouter();return runCommandStreamed(id,'docker',composeArgs(root,'up','-d','--build','--force-recreate','--remove-orphans'),e.sender,{cwd:root})})
|
||||
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'?[['x-terminal-emulator',['--working-directory',root]],['gnome-terminal',['--working-directory',root]],['konsole',['--workdir',root]]]:[];for(const [cmd,args] of candidates){try{const child=spawn(cmd,args,{detached:true,stdio:'ignore'});child.unref();return}catch{}}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 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:any)=>{
|
||||
await updateEnvironment(root,updates)
|
||||
if (updates.primaryProtocol) {
|
||||
const config = await getProjectConfig(root)
|
||||
if (config.type === 'wordpress') {
|
||||
const url = projectUrls(config.name)[updates.primaryProtocol === 'http' ? 'http' : 'https']
|
||||
const network = `aurora-${config.name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'}_default`
|
||||
// Keep WordPress' canonical home/siteurl aligned with Dockside's primary protocol.
|
||||
// WP-CLI talks to MariaDB over the project network; it does not need to trust local TLS.
|
||||
await execFileAsync('docker', ['run','--rm','--network',network,'-e','HOME=/tmp','-v',`${root}:/app`,'-w','/app','wordpress:cli','option','update','home',url,'--allow-root'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
await execFileAsync('docker', ['run','--rm','--network',network,'-e','HOME=/tmp','-v',`${root}:/app`,'-w','/app','wordpress:cli','option','update','siteurl',url,'--allow-root'], { env: AURORA_ENV, maxBuffer: 8*1024*1024 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { chmod, readFile, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { AuroraSiteCredentials } from '../../shared/types'
|
||||
|
||||
const secretsPath = (root: string): string => join(root, '.aurora', 'secrets.json')
|
||||
|
||||
export async function saveSiteCredentials(root: string, credentials: AuroraSiteCredentials): Promise<void> {
|
||||
const path = secretsPath(root)
|
||||
await writeFile(path, JSON.stringify({ site: credentials }, null, 2) + '\n', { mode: 0o600 })
|
||||
if (process.platform !== 'win32') await chmod(path, 0o600)
|
||||
}
|
||||
|
||||
async function readSiteCredentials(root: string): Promise<AuroraSiteCredentials | null> {
|
||||
try {
|
||||
const data = JSON.parse(await readFile(secretsPath(root), 'utf8')) as { site?: AuroraSiteCredentials }
|
||||
return data.site ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function registerSecretsIpc(): void {
|
||||
ipcMain.handle('secrets:getSiteCredentials', async (_event, root: string) => readSiteCredentials(root))
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { cancelCommand } from '../commandRunner'
|
||||
|
||||
export function registerTerminalIpc(): void {
|
||||
ipcMain.handle('terminal:cancel', (_event, operationId: string) => cancelCommand(operationId))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ipcMain } from 'electron'
|
||||
|
||||
const MIN_ZOOM = -3
|
||||
const MAX_ZOOM = 5
|
||||
|
||||
export function registerWindowIpc(): void {
|
||||
ipcMain.handle('window:zoomIn', (event) => {
|
||||
const level = Math.min(MAX_ZOOM, event.sender.getZoomLevel() + 0.5)
|
||||
event.sender.setZoomLevel(level)
|
||||
return level
|
||||
})
|
||||
|
||||
ipcMain.handle('window:zoomOut', (event) => {
|
||||
const level = Math.max(MIN_ZOOM, event.sender.getZoomLevel() - 0.5)
|
||||
event.sender.setZoomLevel(level)
|
||||
return level
|
||||
})
|
||||
|
||||
ipcMain.handle('window:zoomReset', (event) => {
|
||||
event.sender.setZoomLevel(0)
|
||||
return 0
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { runCommandStreamed } from '../commandRunner'
|
||||
import { getProjectConfig, getProjectRoot } from '../auroraEngine'
|
||||
|
||||
const safeName = (name: string): string =>
|
||||
name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'project'
|
||||
|
||||
function wpDockerArgs(name: string, root: string, wpArgs: string[]): string[] {
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined
|
||||
const gid = typeof process.getgid === 'function' ? process.getgid() : undefined
|
||||
const userArgs = uid !== undefined && gid !== undefined ? ['--user', `${uid}:${gid}`] : []
|
||||
|
||||
return [
|
||||
'run', '--rm',
|
||||
...userArgs,
|
||||
'--network', `aurora-${safeName(name)}_default`,
|
||||
'-e', 'HOME=/tmp',
|
||||
'-e', 'WP_CLI_CACHE_DIR=/tmp/wp-cli-cache',
|
||||
'-v', `${root}:/app`,
|
||||
'-w', '/app',
|
||||
'wordpress:cli',
|
||||
...wpArgs
|
||||
]
|
||||
}
|
||||
|
||||
export function registerWordpressIpc(): void {
|
||||
ipcMain.handle('wordpress:run', async (event, operationId: string, name: string, action: string, payload?: string) => {
|
||||
const root = await getProjectRoot(name)
|
||||
const config = await getProjectConfig(root)
|
||||
if (config.type !== 'wordpress') throw new Error(`Project '${name}' is not a WordPress project`)
|
||||
|
||||
let args: string[]
|
||||
switch (action) {
|
||||
case 'cache-flush': args = ['cache', 'flush']; break
|
||||
case 'rewrite-flush': args = ['rewrite', 'flush']; break
|
||||
case 'maintenance-on': args = ['maintenance-mode', 'activate']; break
|
||||
case 'maintenance-off': args = ['maintenance-mode', 'deactivate']; break
|
||||
case 'debug-on': args = ['config', 'set', 'WP_DEBUG', 'true', '--raw']; break
|
||||
case 'debug-off': args = ['config', 'set', 'WP_DEBUG', 'false', '--raw']; break
|
||||
case 'core-version': args = ['core', 'version']; break
|
||||
case 'plugin-list': args = ['plugin', 'list']; break
|
||||
case 'theme-list': args = ['theme', 'list']; break
|
||||
case 'search-replace': {
|
||||
const [from, to] = (payload ?? '').split('\n')
|
||||
if (!from || !to) throw new Error('Search and replacement values are required')
|
||||
args = ['search-replace', from, to, '--all-tables-with-prefix', '--precise', '--report-changed-only']
|
||||
if (config.wordpressMultisite && config.wordpressMultisite !== 'none') args.push('--network')
|
||||
break
|
||||
}
|
||||
default: throw new Error(`Unknown WordPress action '${action}'`)
|
||||
}
|
||||
|
||||
return runCommandStreamed(operationId, 'docker', wpDockerArgs(name, root, args), event.sender, { cwd: root })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { app } from 'electron'
|
||||
import { readFile, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import type { AuroraModuleManifest } from '../shared/types'
|
||||
|
||||
let cache: AuroraModuleManifest[] | null = null
|
||||
|
||||
function moduleDirectory(): string {
|
||||
return join(app.getAppPath(), 'resources', 'modules')
|
||||
}
|
||||
|
||||
export async function getModuleRegistry(): Promise<AuroraModuleManifest[]> {
|
||||
if (cache) return cache
|
||||
const dir = moduleDirectory()
|
||||
const files = (await readdir(dir)).filter((file) => file.endsWith('.json')).sort()
|
||||
cache = await Promise.all(files.map(async (file) => JSON.parse(await readFile(join(dir, file), 'utf8')) as AuroraModuleManifest))
|
||||
return cache
|
||||
}
|
||||
|
||||
export async function getModuleManifest(id: string): Promise<AuroraModuleManifest> {
|
||||
const module = (await getModuleRegistry()).find((item) => item.id === id)
|
||||
if (!module) throw new Error(`Unknown Aurora module '${id}'`)
|
||||
return module
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
||||
import type { Api } from './index'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: Api
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import type {
|
||||
AuroraInstalledModule,
|
||||
AuroraModuleManifest,
|
||||
AuroraProjectDetail,
|
||||
AuroraProjectSummary,
|
||||
AuroraSnapshot,
|
||||
AuroraSiteCredentials,
|
||||
AuroraStackOptions,
|
||||
EnvironmentUpdate,
|
||||
LogDataEvent,
|
||||
LogExitEvent,
|
||||
TerminalDataEvent,
|
||||
TerminalExitEvent
|
||||
} from '../shared/types'
|
||||
|
||||
// Custom APIs for renderer
|
||||
const api = {
|
||||
projects: {
|
||||
list: (): Promise<AuroraProjectSummary[]> => ipcRenderer.invoke('projects:list'),
|
||||
describe: (name: string): Promise<AuroraProjectDetail> =>
|
||||
ipcRenderer.invoke('projects:describe', name),
|
||||
start: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:start', operationId, name),
|
||||
stop: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:stop', operationId, name),
|
||||
restart: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:restart', operationId, name),
|
||||
delete: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
approot: string,
|
||||
deleteFiles: boolean
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:delete', operationId, name, approot, deleteFiles),
|
||||
updateEnvironment: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
approot: string,
|
||||
updates: EnvironmentUpdate
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:updateEnvironment', operationId, name, approot, updates),
|
||||
trustCA: (): Promise<void> => ipcRenderer.invoke('projects:trustCA'),
|
||||
restartService: (operationId: string, name: string, service: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:restartService', operationId, name, service),
|
||||
phpInfo: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:phpInfo', operationId, name),
|
||||
openTerminal: (name: string): Promise<void> => ipcRenderer.invoke('projects:openTerminal', name)
|
||||
},
|
||||
wordpress: {
|
||||
run: (operationId: string, name: string, action: string, payload?: string): Promise<void> =>
|
||||
ipcRenderer.invoke('wordpress:run', operationId, name, action, payload)
|
||||
},
|
||||
terminal: {
|
||||
cancel: (operationId: string): Promise<boolean> =>
|
||||
ipcRenderer.invoke('terminal:cancel', operationId),
|
||||
onData: (callback: (event: TerminalDataEvent) => void): (() => void) => {
|
||||
const listener = (_event: IpcRendererEvent, data: TerminalDataEvent): void => callback(data)
|
||||
ipcRenderer.on('terminal:data', listener)
|
||||
return () => ipcRenderer.removeListener('terminal:data', listener)
|
||||
},
|
||||
onExit: (callback: (event: TerminalExitEvent) => void): (() => void) => {
|
||||
const listener = (_event: IpcRendererEvent, data: TerminalExitEvent): void => callback(data)
|
||||
ipcRenderer.on('terminal:exit', listener)
|
||||
return () => ipcRenderer.removeListener('terminal:exit', listener)
|
||||
}
|
||||
},
|
||||
database: {
|
||||
listSnapshots: (name: string, approot: string): Promise<AuroraSnapshot[]> =>
|
||||
ipcRenderer.invoke('database:listSnapshots', name, approot),
|
||||
createSnapshot: (operationId: string, approot: string, snapshotName?: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:createSnapshot', operationId, approot, snapshotName),
|
||||
restoreSnapshot: (operationId: string, approot: string, snapshotName: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:restoreSnapshot', operationId, approot, snapshotName),
|
||||
deleteSnapshot: (operationId: string, approot: string, snapshotName: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:deleteSnapshot', operationId, approot, snapshotName),
|
||||
importFile: (operationId: string, approot: string, filePath: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:importFile', operationId, approot, filePath),
|
||||
exportFile: (operationId: string, approot: string, filePath: string): Promise<void> =>
|
||||
ipcRenderer.invoke('database:exportFile', operationId, approot, filePath),
|
||||
pickImportFile: (): Promise<string | null> => ipcRenderer.invoke('database:pickImportFile'),
|
||||
pickExportPath: (defaultFileName: string): Promise<string | null> =>
|
||||
ipcRenderer.invoke('database:pickExportPath', defaultFileName)
|
||||
},
|
||||
modules: {
|
||||
listRegistry: (): Promise<AuroraModuleManifest[]> => ipcRenderer.invoke('modules:listRegistry'),
|
||||
listInstalled: (name: string): Promise<AuroraInstalledModule[]> =>
|
||||
ipcRenderer.invoke('modules:listInstalled', name),
|
||||
install: (
|
||||
operationId: string,
|
||||
name: string,
|
||||
moduleId: string,
|
||||
settings: Record<string, string | number | boolean>
|
||||
): Promise<void> => ipcRenderer.invoke('modules:install', operationId, name, moduleId, settings),
|
||||
remove: (operationId: string, name: string, moduleId: string): Promise<void> =>
|
||||
ipcRenderer.invoke('modules:remove', operationId, name, moduleId)
|
||||
},
|
||||
logs: {
|
||||
start: (operationId: string, name: string, service: string): Promise<void> =>
|
||||
ipcRenderer.invoke('logs:start', operationId, name, service),
|
||||
onData: (callback: (event: LogDataEvent) => void): (() => void) => {
|
||||
const listener = (_event: IpcRendererEvent, data: LogDataEvent): void => callback(data)
|
||||
ipcRenderer.on('logs:data', listener)
|
||||
return () => ipcRenderer.removeListener('logs:data', listener)
|
||||
},
|
||||
onExit: (callback: (event: LogExitEvent) => void): (() => void) => {
|
||||
const listener = (_event: IpcRendererEvent, data: LogExitEvent): void => callback(data)
|
||||
ipcRenderer.on('logs:exit', listener)
|
||||
return () => ipcRenderer.removeListener('logs:exit', listener)
|
||||
}
|
||||
},
|
||||
create: {
|
||||
pickDirectory: (): Promise<string | null> => ipcRenderer.invoke('create:pickDirectory'),
|
||||
configure: (
|
||||
operationId: string,
|
||||
directory: string,
|
||||
projectName: string,
|
||||
projectType: string,
|
||||
docroot: string,
|
||||
stack?: Partial<AuroraStackOptions>
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke(
|
||||
'create:configure',
|
||||
operationId,
|
||||
directory,
|
||||
projectName,
|
||||
projectType,
|
||||
docroot,
|
||||
stack
|
||||
),
|
||||
downloadWordpress: (operationId: string, directory: string, locale: string): Promise<void> =>
|
||||
ipcRenderer.invoke('create:downloadWordpress', operationId, directory, locale),
|
||||
setupWordpress: (
|
||||
operationId: string,
|
||||
directory: string,
|
||||
siteUrl: string,
|
||||
title: string,
|
||||
adminUser: string,
|
||||
adminPassword: string,
|
||||
adminEmail: string,
|
||||
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke(
|
||||
'create:setupWordpress',
|
||||
operationId,
|
||||
directory,
|
||||
siteUrl,
|
||||
title,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
multisite
|
||||
),
|
||||
downloadDrupal: (operationId: string, directory: string): Promise<void> =>
|
||||
ipcRenderer.invoke('create:downloadDrupal', operationId, directory),
|
||||
requireDrush: (operationId: string, directory: string): Promise<void> =>
|
||||
ipcRenderer.invoke('create:requireDrush', operationId, directory),
|
||||
setupDrupal: (
|
||||
operationId: string,
|
||||
directory: string,
|
||||
siteName: string,
|
||||
adminUser: string,
|
||||
adminPassword: string,
|
||||
adminEmail: string,
|
||||
profile: string
|
||||
): Promise<void> =>
|
||||
ipcRenderer.invoke(
|
||||
'create:setupDrupal',
|
||||
operationId,
|
||||
directory,
|
||||
siteName,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
profile
|
||||
)
|
||||
},
|
||||
secrets: {
|
||||
getSiteCredentials: (approot: string): Promise<AuroraSiteCredentials | null> =>
|
||||
ipcRenderer.invoke('secrets:getSiteCredentials', approot)
|
||||
},
|
||||
zoom: {
|
||||
in: (): Promise<number> => ipcRenderer.invoke('window:zoomIn'),
|
||||
out: (): Promise<number> => ipcRenderer.invoke('window:zoomOut'),
|
||||
reset: (): Promise<number> => ipcRenderer.invoke('window:zoomReset')
|
||||
}
|
||||
}
|
||||
|
||||
export type Api = typeof api
|
||||
|
||||
// Use `contextBridge` APIs to expose Electron APIs to
|
||||
// renderer only if context isolation is enabled, otherwise
|
||||
// just add to the DOM global.
|
||||
if (process.contextIsolated) {
|
||||
try {
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('api', api)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore (define in dts)
|
||||
window.electron = electronAPI
|
||||
// @ts-ignore (define in dts)
|
||||
window.api = api
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Aurora Dockside</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
|
||||
/>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
|
||||
vi.stubGlobal('api', {
|
||||
projects: {
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
describe: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
restart: vi.fn()
|
||||
},
|
||||
terminal: {
|
||||
cancel: vi.fn(),
|
||||
onData: vi.fn().mockReturnValue(() => {}),
|
||||
onExit: vi.fn().mockReturnValue(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
function renderApp(): ReturnType<typeof render> {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the app title', () => {
|
||||
renderApp()
|
||||
expect(screen.getByText('Aurora Dockside')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty state when there are no Aurora projects', async () => {
|
||||
renderApp()
|
||||
await waitFor(() => expect(screen.getByText(/No Aurora projects found/)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Anchor, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react'
|
||||
import { ProjectDetail } from './components/projects/ProjectDetail'
|
||||
import { ProjectList } from './components/projects/ProjectList'
|
||||
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
||||
import { StatusBar } from './components/layout/StatusBar'
|
||||
import { Toaster } from './components/ui/Toaster'
|
||||
import { CreateProjectModal } from './components/create/CreateProjectModal'
|
||||
import { SettingsModal } from './components/settings/SettingsModal'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
||||
import { useAppliedTheme } from './hooks/useAppliedTheme'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import docksideIcon from './assets/dockside-icon.png'
|
||||
|
||||
function App(): React.JSX.Element {
|
||||
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
|
||||
useTerminalEvents()
|
||||
useAppliedTheme()
|
||||
useKeyboardShortcuts({
|
||||
onNewProject: useCallback(() => setIsCreateOpen(true), []),
|
||||
onOpenSettings: useCallback(() => setIsSettingsOpen(true), [])
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col bg-[linear-gradient(135deg,rgba(8,145,178,0.12)_0%,transparent_36%),linear-gradient(180deg,#f8fafc_0%,#eef6f5_52%,#e7eef5_100%)] text-neutral-900 dark:bg-[linear-gradient(135deg,rgba(45,212,191,0.10)_0%,transparent_36%),linear-gradient(180deg,#070a0f_0%,#0f172a_54%,#092f34_100%)] dark:text-neutral-100">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<aside className="flex w-80 flex-shrink-0 flex-col border-r border-white/70 bg-white/[0.78] shadow-[8px_0_30px_rgba(15,23,42,0.06)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.72] dark:shadow-black/25">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200/70 px-4 py-3 dark:border-white/10">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<img
|
||||
src={docksideIcon}
|
||||
alt=""
|
||||
className="size-10 rounded-xl shadow-sm shadow-cyan-900/20"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-sm font-semibold tracking-wide">Aurora Dockside</h1>
|
||||
<p className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Aurora modular dev platform
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
title="New Project"
|
||||
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-cyan-50 hover:text-cyan-700 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-300"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSettingsOpen(true)}
|
||||
title="Settings"
|
||||
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ProjectList />
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{selectedProjectName ? (
|
||||
<ProjectDetail name={selectedProjectName} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="relative grid w-full max-w-3xl overflow-hidden rounded-2xl border border-white/70 bg-white/[0.84] shadow-[0_24px_80px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.70] dark:shadow-black/30">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(8,145,178,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(8,145,178,0.08)_1px,transparent_1px)] bg-[size:34px_34px] dark:bg-[linear-gradient(rgba(45,212,191,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.08)_1px,transparent_1px)]" />
|
||||
<div className="relative grid gap-7 p-8">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-cyan-200 bg-cyan-50 px-3 py-1 text-xs font-medium text-cyan-800 dark:border-cyan-400/20 dark:bg-cyan-400/10 dark:text-cyan-200">
|
||||
<Sparkles size={13} />
|
||||
Local environments, neatly handled
|
||||
</div>
|
||||
<h2 className="max-w-xl text-3xl font-semibold leading-tight text-neutral-950 dark:text-white">
|
||||
Build local development stacks your way.
|
||||
</h2>
|
||||
<p className="mt-3 max-w-xl text-sm leading-6 text-neutral-600 dark:text-neutral-300">
|
||||
Create Docker-powered PHP and Node.js projects, then add applications, services, and tools as modules.
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
src={docksideIcon}
|
||||
alt=""
|
||||
className="hidden size-24 flex-shrink-0 rounded-3xl shadow-lg shadow-cyan-900/20 sm:block"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||
<FolderOpen size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||
<p className="text-sm font-semibold">Project overview</p>
|
||||
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
Status, stack details, paths, and services.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||
<TerminalSquare size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||
<p className="text-sm font-semibold">Live operations</p>
|
||||
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
Start, stop, restart, and inspect logs.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
|
||||
<Anchor size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
|
||||
<p className="text-sm font-semibold">Database control</p>
|
||||
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
|
||||
Snapshots, imports, exports, and add-ons.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<TerminalPanel />
|
||||
<StatusBar />
|
||||
<Toaster />
|
||||
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
|
||||
{isSettingsOpen && <SettingsModal onClose={() => setIsSettingsOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
@@ -0,0 +1,61 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
user-select: none;
|
||||
background: #f8fafc;
|
||||
font-feature-settings:
|
||||
'liga' 1,
|
||||
'calt' 1;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
ui-monospace,
|
||||
SFMono-Regular,
|
||||
SF Mono,
|
||||
Menlo,
|
||||
Consolas,
|
||||
Liberation Mono,
|
||||
monospace;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button:not(:disabled),
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible {
|
||||
outline: 2px solid #06b6d4;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(100, 116, 139, 0.35);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(100, 116, 139, 0.55);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Boxes,
|
||||
Check,
|
||||
FileCode2,
|
||||
FolderOpen,
|
||||
Globe2,
|
||||
Layers3,
|
||||
Package,
|
||||
Sparkles,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { useCreateProject } from '../../hooks/useCreateProject'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
import { getTypeLabel, PROJECT_TYPES } from './types/registry'
|
||||
import { DrupalSetup } from './types/DrupalSetup'
|
||||
import { GenericSetup } from './types/GenericSetup'
|
||||
import { WordpressSetup } from './types/WordpressSetup'
|
||||
import type { TypeSetupHandle } from './types/shared'
|
||||
import { isValidProjectName, slugifyProjectName } from './projectName'
|
||||
import docksideIcon from '../../assets/dockside-icon.png'
|
||||
|
||||
type Step = 'site' | 'setup'
|
||||
|
||||
const fieldClass =
|
||||
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||
|
||||
const labelClass =
|
||||
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||
|
||||
const TYPE_ICONS: Record<string, typeof Globe2> = {
|
||||
'': Sparkles,
|
||||
php: FileCode2,
|
||||
wordpress: Globe2,
|
||||
drupal: Layers3,
|
||||
laravel: FileCode2,
|
||||
backdrop: Layers3,
|
||||
craftcms: Package,
|
||||
magento2: Package,
|
||||
shopware6: Package,
|
||||
symfony: Boxes,
|
||||
typo3: Layers3
|
||||
}
|
||||
|
||||
export function CreateProjectModal({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||
const [step, setStep] = useState<Step>('site')
|
||||
const [directory, setDirectory] = useState<string | null>(null)
|
||||
const [projectName, setProjectName] = useState('')
|
||||
const [projectType, setProjectType] = useState('')
|
||||
const [docroot, setDocroot] = useState('')
|
||||
const [setupValid, setSetupValid] = useState(true)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [phpVersion, setPhpVersion] = useState('8.4')
|
||||
const [nodeVersion, setNodeVersion] = useState('24')
|
||||
const [database, setDatabase] = useState<'mariadb' | 'postgres'>('mariadb')
|
||||
const [databaseVersion, setDatabaseVersion] = useState('11.8')
|
||||
const [adminer, setAdminer] = useState(true)
|
||||
const [redis, setRedis] = useState(false)
|
||||
const [mailpit, setMailpit] = useState(false)
|
||||
const [xdebug, setXdebug] = useState(false)
|
||||
|
||||
const createProject = useCreateProject()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
const setupRef = useRef<TypeSetupHandle>(null)
|
||||
|
||||
const trimmedName = projectName.trim()
|
||||
const nameValid = trimmedName.length > 0 && isValidProjectName(trimmedName)
|
||||
const canContinue = directory !== null && nameValid
|
||||
const canSubmit = canContinue && setupValid && !isSubmitting
|
||||
|
||||
async function handlePickDirectory(): Promise<void> {
|
||||
const picked = await window.api.create.pickDirectory()
|
||||
if (!picked) return
|
||||
setDirectory(picked)
|
||||
if (!projectName) {
|
||||
const name = picked.split('/').filter(Boolean).pop() ?? ''
|
||||
setProjectName(slugifyProjectName(name))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!directory || !canSubmit) return
|
||||
const name = projectName.trim()
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot, stack: { phpVersion, nodeVersion, database, databaseVersion, adminer, redis, mailpit, xdebug } })
|
||||
} catch {
|
||||
setIsSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Post-create (starting the project, downloading/installing WordPress,
|
||||
// etc.) can run long. Close the modal as soon as the quick `Aurora
|
||||
// config` step succeeds instead of blocking the whole wizard on it —
|
||||
// the terminal panel already tracks and surfaces this operation's
|
||||
// progress and any failure independently of the modal.
|
||||
const runPostCreate = setupRef.current?.runPostCreate
|
||||
selectProject(name)
|
||||
onClose()
|
||||
runPostCreate?.({ directory, projectName: name }).catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-3 backdrop-blur-sm sm:p-4">
|
||||
<div
|
||||
className="grid w-full max-w-4xl overflow-hidden rounded-2xl border border-white/70 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950 md:grid-cols-[0.82fr_1.18fr]"
|
||||
style={{ height: 'min(760px, calc(100vh - 24px))', maxHeight: 'calc(100vh - 24px)' }}
|
||||
>
|
||||
<aside className="relative hidden overflow-hidden bg-neutral-950 p-6 text-white md:block">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.11)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.11)_1px,transparent_1px)] bg-[size:34px_34px]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-cyan-500/20 to-transparent" />
|
||||
<div className="relative flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<img
|
||||
src={docksideIcon}
|
||||
alt=""
|
||||
className="mb-5 size-16 rounded-2xl shadow-lg shadow-cyan-950/30"
|
||||
/>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-200">
|
||||
Project launch
|
||||
</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold leading-tight">
|
||||
Create a local site that feels ready to work.
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Destination
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold">
|
||||
{directory ? directory.split('/').filter(Boolean).pop() : 'Choose a folder'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Project
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold">
|
||||
{projectName.trim() || 'Name pending'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="min-h-0 min-w-0 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]">
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-neutral-200/80 bg-white/95 px-5 py-4 backdrop-blur dark:border-white/10 dark:bg-neutral-950/95">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">
|
||||
{step === 'site' ? 'New Project' : `Set up ${getTypeLabel(projectType)}`}
|
||||
</h2>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{(['site', 'setup'] as const).map((item, index) => (
|
||||
<div
|
||||
key={item}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs font-medium',
|
||||
step === item
|
||||
? 'border-cyan-200 bg-cyan-50 text-cyan-800 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-cyan-200'
|
||||
: 'border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-neutral-400'
|
||||
)}
|
||||
>
|
||||
<span className="grid size-4 place-items-center rounded-full bg-current text-[10px]">
|
||||
<span className="text-white dark:text-neutral-950">{index + 1}</span>
|
||||
</span>
|
||||
{item === 'site' ? 'Project' : 'Setup'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-white/10 dark:hover:text-neutral-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
{PROJECT_TYPES.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)
|
||||
// drupal/recommended-project's installer-paths
|
||||
// expect a `web` docroot — default it in so a
|
||||
// fresh Drupal project doesn't end up serving
|
||||
// from an unexpected root (only if the user
|
||||
// hasn't already typed a docroot themselves).
|
||||
if (t.value === 'drupal' && !docroot.trim()) setDocroot('web')
|
||||
if (t.value === 'wordpress' && database === 'postgres') { setDatabase('mariadb'); setDatabaseVersion('11.8') }
|
||||
}}
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</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="grid gap-3 sm:grid-cols-2">
|
||||
<div><label className={labelClass}>PHP</label><select className={fieldClass} value={phpVersion} onChange={(e)=>setPhpVersion(e.target.value)}>{['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}>Database</label><select className={fieldClass} value={`${database}:${databaseVersion}`} onChange={(e)=>{const [kind,version]=e.target.value.split(':');setDatabase(kind as 'mariadb'|'postgres');setDatabaseVersion(version)}}><option value="mariadb:11.8">MariaDB 11.8</option><option value="mariadb:10.11">MariaDB 10.11</option>{projectType !== 'wordpress' && <><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>
|
||||
</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>
|
||||
</>
|
||||
) : projectType === 'wordpress' ? (
|
||||
<WordpressSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : projectType === 'drupal' ? (
|
||||
<DrupalSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
) : (
|
||||
<GenericSetup
|
||||
ref={setupRef}
|
||||
projectName={projectName.trim()}
|
||||
onValidityChange={setSetupValid}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-20 flex items-center justify-between gap-3 border-t border-neutral-200/80 bg-neutral-50/95 p-4 backdrop-blur dark:border-white/10 dark:bg-neutral-950/95">
|
||||
<button
|
||||
type="button"
|
||||
onClick={step === 'site' ? onClose : () => setStep('site')}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-white hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
{step === 'site' ? null : <ArrowLeft size={14} />}
|
||||
{step === 'site' ? 'Cancel' : 'Go back'}
|
||||
</button>
|
||||
|
||||
{step === 'site' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canContinue}
|
||||
onClick={() => setStep('setup')}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Continue
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{isSubmitting ? 'Creating…' : 'Add Site'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isValidProjectName, slugifyProjectName } from './projectName'
|
||||
|
||||
describe('isValidProjectName', () => {
|
||||
it('accepts hostname-safe names', () => {
|
||||
expect(isValidProjectName('my-project')).toBe(true)
|
||||
expect(isValidProjectName('project1')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects names with spaces', () => {
|
||||
expect(isValidProjectName('Aurora Admin')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects names starting or ending with a hyphen', () => {
|
||||
expect(isValidProjectName('-project')).toBe(false)
|
||||
expect(isValidProjectName('project-')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects empty names', () => {
|
||||
expect(isValidProjectName('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('slugifyProjectName', () => {
|
||||
it('lowercases and replaces spaces with hyphens', () => {
|
||||
expect(slugifyProjectName('Aurora Admin')).toBe('aurora-admin')
|
||||
})
|
||||
|
||||
it('collapses runs of non-alphanumeric characters', () => {
|
||||
expect(slugifyProjectName('My Cool!! Project')).toBe('my-cool-project')
|
||||
})
|
||||
|
||||
it('trims leading and trailing hyphens', () => {
|
||||
expect(slugifyProjectName(' -Weird Name- ')).toBe('weird-name')
|
||||
})
|
||||
|
||||
it('produces a name that passes validation', () => {
|
||||
const slug = slugifyProjectName('Aurora Admin')
|
||||
expect(isValidProjectName(slug)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
// Aurora project names must be valid hostname labels: alphanumeric and
|
||||
// hyphens only, can't start/end with a hyphen.
|
||||
const PROJECT_NAME_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/
|
||||
|
||||
export function isValidProjectName(name: string): boolean {
|
||||
return PROJECT_NAME_PATTERN.test(name)
|
||||
}
|
||||
|
||||
export function slugifyProjectName(raw: string): string {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, KeyRound, Layers3, Mail, Type, UserRound } from 'lucide-react'
|
||||
import { useDownloadDrupal, useRequireDrush, useSetupDrupal } from '../../../hooks/useCreateProject'
|
||||
import { useStartProject } from '../../../hooks/useAurora'
|
||||
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||
|
||||
const PROFILES = [
|
||||
{ value: 'standard', label: 'Standard' },
|
||||
{ value: 'minimal', label: 'Minimal' },
|
||||
{ value: 'demo_umami', label: 'Umami demo' }
|
||||
]
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||
const labelClass =
|
||||
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||
|
||||
export const DrupalSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function DrupalSetup(
|
||||
{ projectName, onValidityChange },
|
||||
ref
|
||||
) {
|
||||
const [siteName, setSiteName] = useState('')
|
||||
const [adminUser, setAdminUser] = useState('admin')
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [adminEmail, setAdminEmail] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [profile, setProfile] = useState('standard')
|
||||
|
||||
const startProject = useStartProject()
|
||||
const downloadDrupal = useDownloadDrupal()
|
||||
const requireDrush = useRequireDrush()
|
||||
const setupDrupal = useSetupDrupal()
|
||||
|
||||
const isValid =
|
||||
adminUser.trim().length > 0 && adminPassword.trim().length > 0 && adminEmail.trim().length > 0
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange(isValid)
|
||||
}, [isValid, onValidityChange])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
runPostCreate: async ({ directory, projectName: name }: TypeSetupContext) => {
|
||||
// drush needs the containers running, so this ignores any "start
|
||||
// after creating" preference — an unstarted Drupal project would just
|
||||
// be the same half-built state this whole flow exists to avoid.
|
||||
await startProject.mutateAsync(name)
|
||||
await downloadDrupal.mutateAsync({ directory })
|
||||
await requireDrush.mutateAsync({ directory })
|
||||
await setupDrupal.mutateAsync({
|
||||
directory,
|
||||
siteName: siteName.trim() || name,
|
||||
adminUser: adminUser.trim(),
|
||||
adminPassword: adminPassword.trim(),
|
||||
adminEmail: adminEmail.trim(),
|
||||
profile
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||
<Layers3 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">Drupal install</p>
|
||||
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||
Core downloads via Composer and the project starts for setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Site name</label>
|
||||
<div className="relative">
|
||||
<Type
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={siteName}
|
||||
onChange={(e) => setSiteName(e.target.value)}
|
||||
placeholder={projectName || 'My Drupal Site'}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin username</label>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={adminUser}
|
||||
onChange={(e) => setAdminUser(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin password</label>
|
||||
<div className="relative">
|
||||
<KeyRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Admin email</label>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="flex items-center justify-between rounded-xl border border-neutral-200 bg-white/70 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 transition hover:border-cyan-200 hover:bg-cyan-50/50 hover:text-cyan-800 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-200"
|
||||
>
|
||||
<span>Advanced options</span>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="grid gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div>
|
||||
<label className={labelClass}>Install profile</label>
|
||||
<select
|
||||
value={profile}
|
||||
onChange={(e) => setProfile(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{PROFILES.map((p) => (
|
||||
<option key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { PlayCircle } from 'lucide-react'
|
||||
import { useStartProject } from '../../../hooks/useAurora'
|
||||
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||
|
||||
// Fallback setup panel for any project type without a dedicated one yet
|
||||
// (see registry.ts). Just scaffolds via `Aurora config` and optionally starts
|
||||
// the project — no type-specific installer.
|
||||
export const GenericSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function GenericSetup(
|
||||
{ onValidityChange },
|
||||
ref
|
||||
) {
|
||||
const [startAfterCreate, setStartAfterCreate] = useState(true)
|
||||
const startProject = useStartProject()
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange(true)
|
||||
}, [onValidityChange])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
runPostCreate: async ({ projectName }: TypeSetupContext) => {
|
||||
if (startAfterCreate) {
|
||||
await startProject.mutateAsync(projectName)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||
<PlayCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
|
||||
Ready after Aurora config
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||
This type uses Aurora defaults, then you can finish the app-specific install in the
|
||||
project.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white/70 p-4 text-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<span>
|
||||
<span className="block font-semibold">Start after creating</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Launch the environment as soon as config is written.
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={startAfterCreate}
|
||||
onChange={(e) => setStartAfterCreate(e.target.checked)}
|
||||
className="size-4 accent-cyan-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Globe2, KeyRound, Mail, Type, UserRound } from 'lucide-react'
|
||||
import { useDownloadWordpress, useSetupWordpress } from '../../../hooks/useCreateProject'
|
||||
import { useStartProject } from '../../../hooks/useAurora'
|
||||
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ value: 'en_US', label: 'English (United States)' },
|
||||
{ value: 'en_GB', label: 'English (UK)' },
|
||||
{ value: 'de_DE', label: 'German' },
|
||||
{ value: 'es_ES', label: 'Spanish (Spain)' },
|
||||
{ value: 'fr_FR', label: 'French (France)' },
|
||||
{ value: 'it_IT', label: 'Italian' },
|
||||
{ value: 'pt_BR', label: 'Portuguese (Brazil)' },
|
||||
{ value: 'nl_NL', label: 'Dutch' },
|
||||
{ value: 'ja', label: 'Japanese' }
|
||||
]
|
||||
|
||||
type Multisite = 'none' | 'subdirectory' | 'subdomain'
|
||||
|
||||
const MULTISITE_OPTIONS: { value: Multisite; label: string }[] = [
|
||||
{ value: 'none', label: 'No' },
|
||||
{ value: 'subdirectory', label: 'Yes – Subdirectory' },
|
||||
{ value: 'subdomain', label: 'Yes – Subdomain' }
|
||||
]
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
|
||||
const labelClass =
|
||||
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
|
||||
|
||||
export const WordpressSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function WordpressSetup(
|
||||
{ projectName, onValidityChange },
|
||||
ref
|
||||
) {
|
||||
const [siteTitle, setSiteTitle] = useState('')
|
||||
const [adminUser, setAdminUser] = useState('admin')
|
||||
const [adminPassword, setAdminPassword] = useState('')
|
||||
const [adminEmail, setAdminEmail] = useState('')
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [language, setLanguage] = useState('en_US')
|
||||
const [multisite, setMultisite] = useState<Multisite>('none')
|
||||
|
||||
const startProject = useStartProject()
|
||||
const downloadWordpress = useDownloadWordpress()
|
||||
const setupWordpress = useSetupWordpress()
|
||||
|
||||
const isValid =
|
||||
adminUser.trim().length > 0 && adminPassword.trim().length > 0 && adminEmail.trim().length > 0
|
||||
|
||||
useEffect(() => {
|
||||
onValidityChange(isValid)
|
||||
}, [isValid, onValidityChange])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
runPostCreate: async ({ directory, projectName: name }: TypeSetupContext) => {
|
||||
// wp-cli needs the containers running, so this ignores any "start
|
||||
// after creating" preference — an unstarted WordPress project would
|
||||
// just be the same half-built state this whole flow exists to avoid.
|
||||
await startProject.mutateAsync(name)
|
||||
await downloadWordpress.mutateAsync({ directory, locale: language })
|
||||
await setupWordpress.mutateAsync({
|
||||
directory,
|
||||
siteUrl: `https://${name}.Aurora.site`,
|
||||
title: siteTitle.trim() || name,
|
||||
adminUser: adminUser.trim(),
|
||||
adminPassword: adminPassword.trim(),
|
||||
adminEmail: adminEmail.trim(),
|
||||
multisite
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
|
||||
<Globe2 size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
|
||||
WordPress install
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
|
||||
Core downloads automatically and the project starts for setup.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Site title</label>
|
||||
<div className="relative">
|
||||
<Type
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={siteTitle}
|
||||
onChange={(e) => setSiteTitle(e.target.value)}
|
||||
placeholder={projectName || 'My WordPress Site'}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin username</label>
|
||||
<div className="relative">
|
||||
<UserRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={adminUser}
|
||||
onChange={(e) => setAdminUser(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Admin password</label>
|
||||
<div className="relative">
|
||||
<KeyRound
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={adminPassword}
|
||||
onChange={(e) => setAdminPassword(e.target.value)}
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className={labelClass}>Admin email</label>
|
||||
<div className="relative">
|
||||
<Mail
|
||||
size={15}
|
||||
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
value={adminEmail}
|
||||
onChange={(e) => setAdminEmail(e.target.value)}
|
||||
placeholder="[email protected]"
|
||||
className={`${inputClass} pl-9`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="flex items-center justify-between rounded-xl border border-neutral-200 bg-white/70 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 transition hover:border-cyan-200 hover:bg-cyan-50/50 hover:text-cyan-800 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-200"
|
||||
>
|
||||
<span>Advanced options</span>
|
||||
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="grid gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.03]">
|
||||
<div>
|
||||
<label className={labelClass}>Select language</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className={inputClass}
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Is this a WordPress Multisite?</label>
|
||||
<select
|
||||
value={multisite}
|
||||
onChange={(e) => setMultisite(e.target.value as Multisite)}
|
||||
className={inputClass}
|
||||
>
|
||||
{MULTISITE_OPTIONS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
export const PROJECT_TYPES = [
|
||||
{ value: '', label: 'Blank PHP + Node' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
{ value: 'laravel', label: 'Laravel' },
|
||||
{ value: 'drupal', label: 'Drupal' }
|
||||
]
|
||||
|
||||
export function getTypeLabel(projectType: string): string {
|
||||
return PROJECT_TYPES.find((type) => type.value === projectType)?.label ?? 'project'
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
|
||||
|
||||
export interface TypeSetupContext {
|
||||
directory: string
|
||||
projectName: string
|
||||
}
|
||||
|
||||
// Each project type's setup panel exposes this so the wizard can trigger
|
||||
// whatever post-`Aurora config` work that type needs (downloading app core,
|
||||
// running an installer, seeding a database, ...) without knowing the details.
|
||||
export interface TypeSetupHandle {
|
||||
runPostCreate: (ctx: TypeSetupContext) => Promise<void>
|
||||
}
|
||||
|
||||
export interface TypeSetupProps {
|
||||
projectName: string
|
||||
onValidityChange: (valid: boolean) => void
|
||||
}
|
||||
|
||||
export type TypeSetupComponent = ForwardRefExoticComponent<
|
||||
TypeSetupProps & RefAttributes<TypeSetupHandle>
|
||||
>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Loader2, X } from 'lucide-react'
|
||||
import { useStatusStore } from '../../stores/statusStore'
|
||||
import { useTerminalStore } from '../../stores/terminalStore'
|
||||
|
||||
export function StatusBar(): React.JSX.Element {
|
||||
const operationId = useStatusStore((s) => s.operationId)
|
||||
const label = useStatusStore((s) => s.label)
|
||||
const setActiveOperation = useTerminalStore((s) => s.setActiveOperation)
|
||||
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
|
||||
|
||||
return (
|
||||
<footer className="flex h-8 flex-shrink-0 items-center justify-between border-t border-white/70 bg-white/75 px-3 text-xs text-neutral-500 backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/75 dark:text-neutral-400">
|
||||
{operationId && label ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveOperation(operationId)
|
||||
setPanelOpen(true)
|
||||
}}
|
||||
className="flex items-center gap-1.5 transition hover:text-neutral-900 dark:hover:text-neutral-100"
|
||||
>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{label}…
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.terminal.cancel(operationId)}
|
||||
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-400/10"
|
||||
>
|
||||
<X size={12} />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span>Ready</span>
|
||||
)}
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useLogStream } from '../../hooks/useLogStream'
|
||||
|
||||
function LogPane({
|
||||
name,
|
||||
service,
|
||||
filter
|
||||
}: {
|
||||
name: string
|
||||
service: string
|
||||
filter: string
|
||||
}): React.JSX.Element {
|
||||
const { lines, isStreaming } = useLogStream(name, service)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const filteredLines = useMemo(() => {
|
||||
if (!filter.trim()) return lines
|
||||
const q = filter.toLowerCase()
|
||||
return lines.filter((line) => line.text.toLowerCase().includes(q))
|
||||
}, [lines, filter])
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
||||
}, [filteredLines.length])
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex items-center gap-1 text-xs',
|
||||
isStreaming ? 'text-emerald-600 dark:text-emerald-400' : 'text-neutral-400'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
isStreaming ? 'bg-emerald-500' : 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
{isStreaming ? 'streaming' : 'stopped'}
|
||||
</span>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto bg-neutral-950 px-4 py-3 font-mono text-xs text-neutral-200"
|
||||
>
|
||||
{filteredLines.length === 0 ? (
|
||||
<p className="text-neutral-500">
|
||||
{lines.length === 0 ? 'Waiting for log output…' : 'No lines match the filter.'}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
{filteredLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={clsx('whitespace-pre-wrap', line.stream === 'stderr' && 'text-red-400')}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogViewer({
|
||||
name,
|
||||
services,
|
||||
onClose
|
||||
}: {
|
||||
name: string
|
||||
services: string[]
|
||||
onClose: () => void
|
||||
}): React.JSX.Element {
|
||||
const [service, setService] = useState(services[0] ?? 'web')
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
|
||||
<div className="flex h-full w-full max-w-3xl flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-sm font-semibold">Logs — {name}</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<select
|
||||
value={service}
|
||||
onChange={(e) => setService(e.target.value)}
|
||||
className="rounded-md border border-neutral-300 px-2 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
||||
>
|
||||
{services.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter logs…"
|
||||
className="flex-1 rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LogPane key={service} name={name} service={service} filter={filter} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Boxes, ChevronLeft, X } from 'lucide-react'
|
||||
import type { AuroraModuleManifest } from '@shared/types'
|
||||
import { useInstalledModules, useInstallModule, useModuleRegistry } from '../../hooks/useModules'
|
||||
|
||||
const CATEGORY_LABELS = { application: 'Applications', service: 'Services', tool: 'Developer Tools' } as const
|
||||
|
||||
function ModuleSettings({ module, values, onChange }: { module: AuroraModuleManifest; values: Record<string, string | number | boolean>; onChange: (values: Record<string, string | number | boolean>) => void }): React.JSX.Element {
|
||||
return <div className="grid gap-4">
|
||||
{module.settings.map((setting) => <label key={setting.id} className="grid gap-1.5 text-sm">
|
||||
<span className="font-medium">{setting.label}</span>
|
||||
{setting.type === 'boolean' ? (
|
||||
<input type="checkbox" checked={Boolean(values[setting.id])} onChange={(e) => onChange({ ...values, [setting.id]: e.target.checked })} className="size-4" />
|
||||
) : setting.type === 'select' ? (
|
||||
<select value={String(values[setting.id] ?? '')} onChange={(e) => onChange({ ...values, [setting.id]: e.target.value })} className="rounded-md border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-950">
|
||||
{setting.options?.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<input value={String(values[setting.id] ?? '')} onChange={(e) => onChange({ ...values, [setting.id]: setting.type === 'number' ? Number(e.target.value) : e.target.value })} className="rounded-md border border-neutral-300 bg-white px-3 py-2 dark:border-neutral-700 dark:bg-neutral-950" />
|
||||
)}
|
||||
</label>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ModuleBrowserModal({ name, onClose }: { name: string; onClose: () => void }): React.JSX.Element {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selected, setSelected] = useState<AuroraModuleManifest | null>(null)
|
||||
const [settings, setSettings] = useState<Record<string, string | number | boolean>>({})
|
||||
const { data: registry = [], isLoading } = useModuleRegistry()
|
||||
const { data: installed = [] } = useInstalledModules(name)
|
||||
const install = useInstallModule(name)
|
||||
const installedIds = useMemo(() => new Set(installed.map((item) => item.id)), [installed])
|
||||
const filtered = useMemo(() => registry.filter((item) => !query.trim() || `${item.name} ${item.description}`.toLowerCase().includes(query.toLowerCase())), [registry, query])
|
||||
|
||||
function choose(module: AuroraModuleManifest): void {
|
||||
setSelected(module)
|
||||
setSettings(Object.fromEntries(module.settings.map((setting) => [setting.id, setting.default])))
|
||||
}
|
||||
|
||||
async function installSelected(): Promise<void> {
|
||||
if (!selected) return
|
||||
await install.mutateAsync({ moduleId: selected.id, settings })
|
||||
setSelected(null)
|
||||
}
|
||||
|
||||
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-8 backdrop-blur-sm">
|
||||
<div className="flex h-full w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-white/10 bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<header className="flex items-center justify-between border-b border-neutral-200 px-5 py-4 dark:border-neutral-800">
|
||||
<div className="flex items-center gap-3">
|
||||
{selected && <button onClick={() => setSelected(null)} className="rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800"><ChevronLeft size={18}/></button>}
|
||||
<div><h2 className="font-semibold">{selected ? selected.name : 'Aurora Module Library'}</h2><p className="text-xs text-neutral-500">{selected ? selected.description : 'Extend this project without rebuilding Dockside.'}</p></div>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-md p-1 hover:bg-neutral-100 dark:hover:bg-neutral-800"><X size={18}/></button>
|
||||
</header>
|
||||
{selected ? <div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mx-auto max-w-xl rounded-xl border border-neutral-200 p-5 dark:border-neutral-800">
|
||||
<div className="mb-5 flex items-center gap-3"><span className="grid size-11 place-items-center rounded-xl bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300"><Boxes size={20}/></span><div><div className="font-semibold">{selected.name}</div><div className="text-xs uppercase tracking-wide text-neutral-500">{selected.category} · v{selected.version}</div></div></div>
|
||||
{selected.settings.length ? <ModuleSettings module={selected} values={settings} onChange={setSettings}/> : <p className="text-sm text-neutral-500">This module has no configuration options.</p>}
|
||||
{selected.conflicts.length > 0 && <p className="mt-4 rounded-lg bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-400/10 dark:text-amber-200">Application conflict protection: {selected.conflicts.join(', ')}</p>}
|
||||
<button disabled={install.isPending || installedIds.has(selected.id)} onClick={() => void installSelected()} className="mt-6 w-full rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50">{installedIds.has(selected.id) ? 'Already installed' : install.isPending ? 'Installing…' : `Install ${selected.name}`}</button>
|
||||
</div>
|
||||
</div> : <>
|
||||
<div className="border-b border-neutral-200 p-4 dark:border-neutral-800"><input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search modules…" className="w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-950"/></div>
|
||||
<div className="flex-1 overflow-y-auto p-5">{isLoading ? <p className="text-sm text-neutral-500">Loading module registry…</p> : (['application','service','tool'] as const).map((category) => {
|
||||
const items = filtered.filter((item) => item.category === category); if (!items.length) return null
|
||||
return <section key={category} className="mb-6"><h3 className="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">{CATEGORY_LABELS[category]}</h3><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">{items.map((module) => <button key={module.id} onClick={() => choose(module)} className="rounded-xl border border-neutral-200 p-4 text-left transition hover:border-cyan-300 hover:bg-cyan-50/50 dark:border-neutral-800 dark:hover:border-cyan-400/30 dark:hover:bg-cyan-400/5"><div className="flex items-center justify-between"><span className="font-semibold">{module.name}</span><span className="text-[10px] uppercase text-neutral-400">{installedIds.has(module.id) ? 'Installed' : `v${module.version}`}</span></div><p className="mt-2 text-xs leading-relaxed text-neutral-500">{module.description}</p></button>)}</div></section>
|
||||
})}</div>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react'
|
||||
import { Camera, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
|
||||
import {
|
||||
useCreateSnapshot,
|
||||
useDeleteSnapshot,
|
||||
useExportDatabase,
|
||||
useImportDatabase,
|
||||
useRestoreSnapshot,
|
||||
useSnapshots
|
||||
} from '../../hooks/useDatabase'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString()
|
||||
}
|
||||
|
||||
export function DatabaseSection({
|
||||
name,
|
||||
approot
|
||||
}: {
|
||||
name: string
|
||||
approot: string
|
||||
}): React.JSX.Element {
|
||||
const { data: snapshots, isLoading } = useSnapshots(name, approot)
|
||||
const createSnapshot = useCreateSnapshot(name, approot)
|
||||
const restoreSnapshot = useRestoreSnapshot(name, approot)
|
||||
const deleteSnapshot = useDeleteSnapshot(name, approot)
|
||||
const importDatabase = useImportDatabase(name, approot)
|
||||
const exportDatabase = useExportDatabase(name, approot)
|
||||
|
||||
// Electron doesn't support window.prompt() (it silently returns null with
|
||||
// no dialog), so snapshot naming needs an inline input instead.
|
||||
const [isNaming, setIsNaming] = useState(false)
|
||||
const [snapshotNameDraft, setSnapshotNameDraft] = useState('')
|
||||
|
||||
const isBusy =
|
||||
createSnapshot.isPending ||
|
||||
restoreSnapshot.isPending ||
|
||||
deleteSnapshot.isPending ||
|
||||
importDatabase.isPending ||
|
||||
exportDatabase.isPending
|
||||
|
||||
function submitSnapshotName(): void {
|
||||
const trimmed = snapshotNameDraft.trim()
|
||||
createSnapshot.mutate(trimmed || undefined)
|
||||
setIsNaming(false)
|
||||
setSnapshotNameDraft('')
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
Database
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isNaming ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Snapshot name (optional)"
|
||||
value={snapshotNameDraft}
|
||||
onChange={(e) => setSnapshotNameDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submitSnapshotName()
|
||||
if (e.key === 'Escape') setIsNaming(false)
|
||||
}}
|
||||
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs dark:border-white/10 dark:bg-neutral-950"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitSnapshotName}
|
||||
className="rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white transition hover:bg-cyan-500"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsNaming(false)}
|
||||
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 transition hover:bg-neutral-100 dark:hover:bg-white/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => importDatabase.mutate()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
|
||||
>
|
||||
<Upload size={12} /> Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => exportDatabase.mutate()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
|
||||
>
|
||||
<Download size={12} /> Export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsNaming(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white shadow-sm shadow-cyan-900/[0.15] transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Camera size={12} /> Snapshot
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||
Loading snapshots…
|
||||
</p>
|
||||
) : !snapshots || snapshots.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||
No snapshots yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-neutral-200/80 bg-white dark:border-white/10 dark:bg-neutral-950/70">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-white/[0.04] dark:text-neutral-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Created</th>
|
||||
<th className="px-3 py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr
|
||||
key={snapshot.Name}
|
||||
className="border-t border-neutral-200/80 transition hover:bg-cyan-50/40 dark:border-white/10 dark:hover:bg-cyan-400/5"
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{snapshot.Name}</td>
|
||||
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
|
||||
{formatDate(snapshot.Created)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => restoreSnapshot.mutate(snapshot.Name)}
|
||||
title="Restore"
|
||||
className="rounded p-1 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete snapshot "${snapshot.Name}"?`)) {
|
||||
deleteSnapshot.mutate(snapshot.Name)
|
||||
}
|
||||
}}
|
||||
title="Delete"
|
||||
className="rounded p-1 text-red-500 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-400/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
export function DeleteProjectModal({
|
||||
projectName,
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
projectName: string
|
||||
onCancel: () => void
|
||||
onConfirm: (deleteFiles: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const [deleteFiles, setDeleteFiles] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-8 backdrop-blur-sm">
|
||||
<div className="grid w-full max-w-md gap-4 rounded-2xl border border-white/70 bg-white p-5 shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400">
|
||||
<AlertTriangle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Delete "{projectName}"?
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Aurora will take a database snapshot first, then remove the project's containers
|
||||
and Aurora registration.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-3 text-sm dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
onChange={(e) => setDeleteFiles(e.target.checked)}
|
||||
className="mt-0.5 size-4 accent-red-600"
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
Also delete project files from disk
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Permanently removes the project folder. This cannot be undone by the database
|
||||
snapshot.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfirm(deleteFiles)}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-red-900/20 transition hover:bg-red-500"
|
||||
>
|
||||
{deleteFiles ? 'Delete project + files' : 'Delete project'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Database, ExternalLink, Info, RotateCw, TerminalSquare, Wrench } from 'lucide-react'
|
||||
import type { AuroraProjectDetail } from '@shared/types'
|
||||
import { useTerminalStore } from '../../stores/terminalStore'
|
||||
import { useStatusStore } from '../../stores/statusStore'
|
||||
|
||||
export function DeveloperServices({ project }: { project: AuroraProjectDetail }): React.JSX.Element {
|
||||
const button = 'inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-700 shadow-sm transition hover:border-cyan-300 hover:text-cyan-700 dark:border-white/10 dark:bg-white/[0.05] dark:text-neutral-200 dark:hover:border-cyan-400/40 dark:hover:text-cyan-300'
|
||||
async function streamed(label: string, fn: (id: string) => Promise<void>): Promise<void> {
|
||||
const id=crypto.randomUUID(); useTerminalStore.getState().startOperation(id,label); useStatusStore.getState().begin(id,label); await fn(id)
|
||||
}
|
||||
return <section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-4"><h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Wrench size={14} className="text-cyan-600 dark:text-cyan-300"/> Project developer services</h3><p className="mt-1 text-xs text-neutral-500">Database administration, PHP diagnostics, terminal access and service controls.</p></div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.adminer_url && <a className={button} href={project.adminer_url} target="_blank" rel="noreferrer"><Database size={13}/> Adminer <ExternalLink size={11}/></a>}
|
||||
<button className={button} onClick={() => void streamed(`PHP info · ${project.name}`,(id)=>window.api.projects.phpInfo(id,project.name))}><Info size={13}/> PHP info</button>
|
||||
<button className={button} onClick={() => void window.api.projects.openTerminal(project.name)}><TerminalSquare size={13}/> Project terminal</button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{Object.values(project.services).map(service => <div key={service.short_name} className="flex items-center justify-between rounded-lg border border-neutral-200/80 bg-white/70 px-3 py-2 dark:border-white/10 dark:bg-white/[0.03]"><div className="min-w-0"><div className="truncate text-xs font-semibold">{service.short_name}</div><div className="truncate text-[10px] text-neutral-500">{service.status}</div></div><button title={`Restart ${service.short_name}`} className="rounded p-1.5 hover:bg-black/5 dark:hover:bg-white/10" onClick={() => void streamed(`Restart ${service.short_name} · ${project.name}`,(id)=>window.api.projects.restartService(id,project.name,service.short_name))}><RotateCw size={13}/></button></div>)}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { Boxes, Trash2 } from 'lucide-react'
|
||||
import { useInstalledModules, useRemoveModule } from '../../hooks/useModules'
|
||||
import { ModuleBrowserModal } from '../modules/ModuleBrowserModal'
|
||||
|
||||
export function ModulesSection({ name }: { name: string }): React.JSX.Element {
|
||||
const { data: installed = [], isLoading } = useInstalledModules(name)
|
||||
const remove = useRemoveModule(name)
|
||||
const [open, setOpen] = useState(false)
|
||||
return <section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-3 flex items-center justify-between"><div><h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Modules</h3><p className="mt-1 text-xs text-neutral-400">Applications, services and developer tools.</p></div><button onClick={() => setOpen(true)} className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium dark:border-white/10 dark:bg-white/[0.05]"><Boxes size={12}/> Module Library</button></div>
|
||||
{isLoading ? <p className="text-sm text-neutral-500">Loading modules…</p> : installed.length === 0 ? <p className="rounded-lg border border-dashed border-neutral-300 p-4 text-sm text-neutral-500 dark:border-white/10">No modules installed. The base PHP + Node environment is ready.</p> : <div className="grid gap-2 sm:grid-cols-2">{installed.map((module) => <div key={module.id} className="flex items-center justify-between rounded-lg border border-neutral-200 p-3 dark:border-white/10"><div><div className="text-sm font-semibold">{module.name}</div><div className="text-xs capitalize text-neutral-500">{module.category} · v{module.version}</div></div><button disabled={remove.isPending} onClick={() => { if (window.confirm(`Remove module "${module.name}"?`)) remove.mutate(module.id) }} className="rounded p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-400/10"><Trash2 size={14}/></button></div>)}</div>}
|
||||
{open && <ModuleBrowserModal name={name} onClose={() => setOpen(false)}/>}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { clsx } from 'clsx'
|
||||
import {
|
||||
Boxes,
|
||||
Code2,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Gauge,
|
||||
KeyRound,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Play,
|
||||
RotateCw,
|
||||
Server,
|
||||
Square,
|
||||
Trash2,
|
||||
Zap,
|
||||
Globe2,
|
||||
LockKeyhole,
|
||||
Radio
|
||||
} from 'lucide-react'
|
||||
import type { AuroraSiteCredentials, EnvironmentUpdate } from '@shared/types'
|
||||
import {
|
||||
useDeleteProject,
|
||||
useProjectDetail,
|
||||
useRestartProject,
|
||||
useStartProject,
|
||||
useStopProject,
|
||||
useUpdateEnvironment
|
||||
} from '../../hooks/useAurora'
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
import { DatabaseSection } from './DatabaseSection'
|
||||
import { ModulesSection } from './ModulesSection'
|
||||
import { WordpressTools } from './WordpressTools'
|
||||
import { DeveloperServices } from './DeveloperServices'
|
||||
import { DeleteProjectModal } from './DeleteProjectModal'
|
||||
import { LogViewer } from '../logs/LogViewer'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
|
||||
const NODE_VERSIONS = ['20', '22', '24']
|
||||
|
||||
const PHP_VERSIONS = [
|
||||
'5.6',
|
||||
'7.0',
|
||||
'7.1',
|
||||
'7.2',
|
||||
'7.3',
|
||||
'7.4',
|
||||
'8.0',
|
||||
'8.1',
|
||||
'8.2',
|
||||
'8.3',
|
||||
'8.4',
|
||||
'8.5'
|
||||
]
|
||||
|
||||
const WEBSERVER_TYPES = [
|
||||
{ value: 'nginx-fpm', label: 'nginx' },
|
||||
{ value: 'apache-fpm', label: 'Apache' },
|
||||
{ value: 'generic', label: 'Generic' }
|
||||
]
|
||||
|
||||
const DATABASE_OPTIONS = [
|
||||
{ value: 'mariadb:11.8', label: 'MariaDB 11.8' },
|
||||
{ value: 'mariadb:10.11', label: 'MariaDB 10.11' },
|
||||
{ value: 'mariadb:10.6', label: 'MariaDB 10.6' },
|
||||
{ value: 'mysql:8.4', label: 'MySQL 8.4' },
|
||||
{ value: 'mysql:8.0', label: 'MySQL 8.0' },
|
||||
{ value: 'mysql:5.7', label: 'MySQL 5.7' },
|
||||
{ value: 'postgres:17', label: 'PostgreSQL 17' },
|
||||
{ value: 'postgres:16', label: 'PostgreSQL 16' },
|
||||
{ value: 'postgres:15', label: 'PostgreSQL 15' }
|
||||
]
|
||||
|
||||
const heroFieldClass =
|
||||
'w-full rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-sm font-semibold text-white transition hover:border-cyan-300/40 hover:bg-white/10 focus:border-cyan-300/60 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
||||
const startProject = useStartProject()
|
||||
const stopProject = useStopProject()
|
||||
const restartProject = useRestartProject()
|
||||
const deleteProject = useDeleteProject()
|
||||
const updateEnvironment = useUpdateEnvironment()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
const [isLogsOpen, setIsLogsOpen] = useState(false)
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
|
||||
const [siteCredentials, setSiteCredentials] = useState<AuroraSiteCredentials | null>(null)
|
||||
const [showSitePassword, setShowSitePassword] = useState(false)
|
||||
const [isTrustingCA, setIsTrustingCA] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!project || project.type !== 'wordpress') {
|
||||
setSiteCredentials(null)
|
||||
return () => { active = false }
|
||||
}
|
||||
void window.api.secrets.getSiteCredentials(project.approot).then((credentials) => {
|
||||
if (active) setSiteCredentials(credentials)
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [project?.approot, project?.type])
|
||||
|
||||
function copyCredential(value: string): void {
|
||||
void navigator.clipboard.writeText(value)
|
||||
}
|
||||
|
||||
const isBusy =
|
||||
startProject.isPending ||
|
||||
stopProject.isPending ||
|
||||
restartProject.isPending ||
|
||||
deleteProject.isPending
|
||||
|
||||
const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending
|
||||
|
||||
function handleDeleteConfirm(deleteFiles: boolean): void {
|
||||
if (!project) return
|
||||
setIsDeleteOpen(false)
|
||||
deleteProject.mutate(
|
||||
{ name: project.name, approot: project.approot, deleteFiles },
|
||||
{ onSuccess: () => selectProject(null) }
|
||||
)
|
||||
}
|
||||
|
||||
async function applyEnvironmentChange(updates: EnvironmentUpdate): Promise<void> {
|
||||
if (!project) return
|
||||
await updateEnvironment.mutateAsync({ name: project.name, approot: project.approot, updates })
|
||||
if (project.status === 'running') {
|
||||
await restartProject.mutateAsync(project.name)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDatabaseChange(value: string): void {
|
||||
const confirmed = window.confirm(
|
||||
'Changing the database type restarts the project and may require Aurora to migrate or ' +
|
||||
'recreate the database. Consider taking a snapshot first if this project has data you ' +
|
||||
'want to keep. Continue?'
|
||||
)
|
||||
if (!confirmed) return
|
||||
void applyEnvironmentChange({ database: value })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6 text-sm text-neutral-500 dark:text-neutral-400">Loading {name}…</div>
|
||||
}
|
||||
|
||||
if (isError || !project) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-600 dark:text-red-400">
|
||||
{error instanceof Error ? error.message : `Failed to load ${name}.`}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isRunning = project.status === 'running'
|
||||
const services = Object.values(project.services)
|
||||
const runningServices = services.filter((service) => service.status === 'running').length
|
||||
|
||||
const phpVersions =
|
||||
project.php_version && !PHP_VERSIONS.includes(project.php_version)
|
||||
? [project.php_version, ...PHP_VERSIONS]
|
||||
: PHP_VERSIONS
|
||||
|
||||
const currentDatabase = `${project.dbinfo.database_type}:${project.dbinfo.database_version}`
|
||||
const allowedDatabaseOptions = project.type === 'wordpress' ? DATABASE_OPTIONS.filter((o) => o.value.startsWith('mariadb:')) : DATABASE_OPTIONS
|
||||
const databaseOptions = allowedDatabaseOptions.some((o) => o.value === currentDatabase)
|
||||
? DATABASE_OPTIONS
|
||||
: [
|
||||
{
|
||||
value: currentDatabase,
|
||||
label: `${project.dbinfo.database_type} ${project.dbinfo.database_version}`
|
||||
},
|
||||
...allowedDatabaseOptions
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-6">
|
||||
<header className="overflow-hidden rounded-2xl border border-white/70 bg-neutral-950 text-white shadow-[0_24px_70px_rgba(15,23,42,0.18)] dark:border-white/10">
|
||||
<div className="relative p-5">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.09)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.09)_1px,transparent_1px)] bg-[size:36px_36px]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-cyan-500/10 to-transparent" />
|
||||
<div className="relative flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-medium text-cyan-100">
|
||||
<Gauge size={13} />
|
||||
{runningServices} of {services.length} services running
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="truncate text-3xl font-semibold">{project.name}</h2>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<p className="mt-2 max-w-3xl truncate text-sm text-neutral-300">{project.approot}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning || isBusy}
|
||||
onClick={() => startProject.mutate(project.name)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-500 px-3 py-1.5 text-sm font-semibold text-white shadow-sm shadow-emerald-950/20 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Play size={14} /> Start
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning || isBusy}
|
||||
onClick={() => stopProject.mutate(project.name)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Square size={14} /> Stop
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning || isBusy}
|
||||
onClick={() => restartProject.mutate(project.name)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-400 px-3 py-1.5 text-sm font-semibold text-neutral-950 shadow-sm shadow-cyan-950/20 transition hover:bg-cyan-300 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RotateCw size={14} /> Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isRunning}
|
||||
onClick={() => setIsLogsOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FileText size={14} /> Logs
|
||||
</button>
|
||||
{project.type === 'wordpress' && isRunning && (
|
||||
<a
|
||||
href={`${project.primary_url}/wp-admin/`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15]"
|
||||
>
|
||||
<KeyRound size={14} /> WP Admin
|
||||
</a>
|
||||
)}
|
||||
{project.type === 'wordpress' && isRunning && project.wordpress_network_admin_url && (
|
||||
<a href={project.wordpress_network_admin_url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15]">
|
||||
<Globe2 size={14} /> Network Admin
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-red-300/20 bg-red-400/10 px-3 py-1.5 text-sm font-medium text-red-100 transition hover:bg-red-400/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<Boxes size={17} className="mb-3 text-cyan-200" />
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Project type
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-white">{project.type}</p>{project.type === 'wordpress' && <p className="mt-1 text-[11px] text-neutral-400">{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}</p>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<Code2 size={17} className="mb-3 text-cyan-200" />
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
PHP
|
||||
</p>
|
||||
<select
|
||||
value={project.php_version ?? ''}
|
||||
disabled={isEnvUpdating}
|
||||
onChange={(e) => void applyEnvironmentChange({ phpVersion: e.target.value })}
|
||||
className={heroFieldClass}
|
||||
>
|
||||
{phpVersions.map((v) => (
|
||||
<option key={v} value={v} className="text-neutral-900">
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<Code2 size={17} className="mb-3 text-cyan-200" />
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">Node.js</p>
|
||||
<select value={project.nodejs_version ?? '24'} disabled={isEnvUpdating} onChange={(e) => void applyEnvironmentChange({ nodeVersion: e.target.value })} className={heroFieldClass}>
|
||||
{NODE_VERSIONS.map((v) => <option key={v} value={v} className="text-neutral-900">{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<Server size={17} className="mb-3 text-cyan-200" />
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||
Web server
|
||||
</p>
|
||||
<select
|
||||
value={project.webserver_type ?? 'nginx-fpm'}
|
||||
disabled={isEnvUpdating}
|
||||
onChange={(e) => void applyEnvironmentChange({ webserverType: e.target.value })}
|
||||
className={heroFieldClass}
|
||||
>
|
||||
{WEBSERVER_TYPES.map((w) => (
|
||||
<option key={w.value} value={w.value} className="text-neutral-900">
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Zap size={17} className="text-cyan-200" />
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={project.xdebug_enabled}
|
||||
aria-label="Toggle Xdebug"
|
||||
disabled={isEnvUpdating}
|
||||
onClick={() =>
|
||||
void applyEnvironmentChange({ xdebugEnabled: !project.xdebug_enabled })
|
||||
}
|
||||
className={clsx(
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition disabled:cursor-not-allowed disabled:opacity-50',
|
||||
project.xdebug_enabled ? 'bg-cyan-400' : 'bg-white/15'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-block size-3.5 transform rounded-full bg-white shadow transition',
|
||||
project.xdebug_enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">Xdebug</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-white">
|
||||
{project.xdebug_enabled ? 'Enabled' : 'Off'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[1.05fr_0.95fr]">
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Globe2 size={14} className="text-cyan-600 dark:text-cyan-300" /> Site URLs</h3>
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[11px] font-semibold',
|
||||
project.router_status === 'running'
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||
: project.router_status === 'provider-error'
|
||||
? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-neutral-500/10 text-neutral-500'
|
||||
)}>
|
||||
<Radio size={11} /> Router {project.router_status === 'running' ? 'online' : project.router_status === 'provider-error' ? 'Docker provider error' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{[{label:'HTTP',url:project.httpurl,icon:<Globe2 size={14}/>},{label:'HTTPS',url:project.httpsurl,icon:<LockKeyhole size={14}/>}].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2 rounded-lg border border-neutral-200/70 bg-neutral-50/70 p-2 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<span className="flex w-16 items-center gap-1.5 text-xs font-semibold text-neutral-500">{item.icon}{item.label}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs">{item.url}</span>
|
||||
<a href={item.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 rounded-md bg-cyan-600 px-2.5 py-1.5 text-xs font-semibold text-white hover:bg-cyan-500">Open <ExternalLink size={11}/></a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 border-t border-neutral-200/70 pt-3 dark:border-white/10">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-neutral-700 dark:text-neutral-200">Primary protocol</p>
|
||||
<p className="text-[11px] text-neutral-500">Both remain available. WordPress canonical URLs follow this setting.</p>
|
||||
</div>
|
||||
<div className="inline-flex rounded-lg border border-neutral-200 bg-neutral-100 p-1 dark:border-white/10 dark:bg-white/5">
|
||||
{(['http','https'] as const).map((protocol) => { const active = project.primary_url.startsWith(`${protocol}:`); return <button key={protocol} type="button" disabled={isEnvUpdating || !isRunning} onClick={() => void applyEnvironmentChange({ primaryProtocol: protocol })} className={clsx('rounded-md px-3 py-1.5 text-xs font-semibold uppercase transition disabled:cursor-not-allowed disabled:opacity-50', active ? 'bg-white text-cyan-700 shadow-sm dark:bg-neutral-800 dark:text-cyan-300' : 'text-neutral-500 hover:text-neutral-900 dark:hover:text-white')}>{protocol}</button> })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-neutral-200/70 bg-neutral-50/70 p-3 dark:border-white/10 dark:bg-white/[0.04]">
|
||||
<div className="text-[11px]">
|
||||
<p className="font-semibold text-neutral-700 dark:text-neutral-200">Aurora HTTPS certificate</p>
|
||||
<p className="mt-0.5 text-neutral-500">Certificate: {project.certificate_status === 'generated' ? 'Generated' : 'Missing'} · System: {project.ca_trust_status === 'trusted' ? 'Trusted' : project.ca_trust_status === 'not-trusted' ? 'Not trusted' : 'Unknown'} · Firefox: {project.firefox_trust_status === 'trusted' ? 'Trusted' : project.firefox_trust_status === 'not-trusted' ? 'Not trusted' : project.firefox_trust_status === 'unavailable' ? 'Needs NSS tools' : 'Unknown'} · Chromium: {project.chromium_trust_status === 'trusted' ? 'Trusted' : project.chromium_trust_status === 'not-trusted' ? 'Not trusted' : project.chromium_trust_status === 'unavailable' ? 'Needs NSS tools' : 'Not detected'}</p>
|
||||
</div>
|
||||
{(project.ca_trust_status !== 'trusted' || project.firefox_trust_status === 'not-trusted' || project.firefox_trust_status === 'unavailable' || project.chromium_trust_status === 'not-trusted' || project.chromium_trust_status === 'unavailable') && (
|
||||
<button type="button" disabled={isTrustingCA} onClick={async () => { try { setIsTrustingCA(true); await window.api.projects.trustCA(); window.location.reload() } finally { setIsTrustingCA(false) } }} className="rounded-md bg-cyan-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-cyan-500 disabled:opacity-50">
|
||||
{isTrustingCA ? 'Installing…' : 'Install / Repair Browser Trust'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
<Database size={14} className="text-cyan-600 dark:text-cyan-300" />
|
||||
Database credentials
|
||||
</h3>
|
||||
<dl className="grid grid-cols-[minmax(110px,0.45fr)_1fr] items-center gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Type</dt>
|
||||
<dd className="font-medium">
|
||||
<select
|
||||
value={currentDatabase}
|
||||
disabled={isEnvUpdating}
|
||||
onChange={(e) => handleDatabaseChange(e.target.value)}
|
||||
className="w-full max-w-[220px] rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm transition hover:border-cyan-300 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-950"
|
||||
>
|
||||
{databaseOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Database</dt>
|
||||
<dd className="font-medium">{project.dbinfo.dbname}</dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
|
||||
<dd className="font-medium">{project.dbinfo.username}</dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
|
||||
<dd className="font-mono text-xs">{project.dbinfo.password}</dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Port</dt>
|
||||
<dd className="font-medium">{project.dbinfo.published_port}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{project.type === 'wordpress' && siteCredentials && (
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
<KeyRound size={14} className="text-cyan-600 dark:text-cyan-300" />
|
||||
Site credentials
|
||||
</h3>
|
||||
<a href={siteCredentials.adminUrl} target="_blank" rel="noreferrer" className="flex items-center gap-1.5 rounded-md bg-cyan-600 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-cyan-500">
|
||||
Open WP Admin <ExternalLink size={12} />
|
||||
</a>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[minmax(110px,0.3fr)_1fr] items-center gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Admin URL</dt>
|
||||
<dd className="flex min-w-0 items-center gap-2"><span className="truncate font-mono text-xs">{siteCredentials.adminUrl}</span><button title="Copy admin URL" onClick={() => copyCredential(siteCredentials.adminUrl)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
|
||||
<dd className="flex items-center gap-2"><span className="font-medium">{siteCredentials.username}</span><button title="Copy username" onClick={() => copyCredential(siteCredentials.username)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
|
||||
<dd className="flex items-center gap-2"><span className="min-w-[150px] font-mono text-xs">{showSitePassword ? siteCredentials.password : '••••••••••••'}</span><button title={showSitePassword ? 'Hide password' : 'Show password'} onClick={() => setShowSitePassword((v) => !v)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10">{showSitePassword ? <EyeOff size={14} /> : <Eye size={14} />}</button><button title="Copy password" onClick={() => copyCredential(siteCredentials.password)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">Email</dt>
|
||||
<dd className="flex items-center gap-2"><span className="truncate font-medium">{siteCredentials.email}</span><button title="Copy email" onClick={() => copyCredential(siteCredentials.email)} className="rounded p-1 hover:bg-black/5 dark:hover:bg-white/10"><Copy size={13} /></button></dd>
|
||||
</dl>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{project.type === 'wordpress' && isRunning && <WordpressTools project={project} />}
|
||||
{isRunning && <DeveloperServices project={project} />}
|
||||
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
|
||||
Services
|
||||
</h3>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{services.map((service) => (
|
||||
<div
|
||||
key={service.short_name}
|
||||
className="rounded-xl border border-neutral-200/80 bg-white/80 p-4 transition hover:border-cyan-200 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/30"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{service.short_name}</p>
|
||||
<p className="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{service.full_name}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={service.status} />
|
||||
</div>
|
||||
<p className="mt-4 truncate rounded-lg bg-neutral-100 px-3 py-2 font-mono text-xs text-neutral-600 dark:bg-neutral-950/70 dark:text-neutral-300">
|
||||
{service.image}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-2">
|
||||
<DatabaseSection name={project.name} approot={project.approot} />
|
||||
<ModulesSection name={project.name} />
|
||||
</div>
|
||||
|
||||
{isLogsOpen && (
|
||||
<LogViewer
|
||||
name={project.name}
|
||||
services={Object.keys(project.services)}
|
||||
onClose={() => setIsLogsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDeleteOpen && (
|
||||
<DeleteProjectModal
|
||||
projectName={project.name}
|
||||
onCancel={() => setIsDeleteOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { clsx } from 'clsx'
|
||||
import { FolderKanban, Loader2 } from 'lucide-react'
|
||||
import { useProjects } from '../../hooks/useAurora'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
import { StatusBadge } from './StatusBadge'
|
||||
|
||||
export function ProjectList(): React.JSX.Element {
|
||||
const { data: projects, isLoading, isError, error } = useProjects()
|
||||
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-4 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<Loader2 size={15} className="animate-spin text-cyan-600 dark:text-cyan-300" />
|
||||
Loading projects…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="p-4 text-sm text-red-600 dark:text-red-400">
|
||||
{error instanceof Error ? error.message : 'Failed to load Aurora projects.'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!projects || projects.length === 0) {
|
||||
return (
|
||||
<div className="m-3 rounded-lg border border-dashed border-neutral-300 bg-white/60 p-4 text-sm text-neutral-500 dark:border-neutral-700 dark:bg-white/[0.03] dark:text-neutral-400">
|
||||
<div className="mb-3 grid size-10 place-items-center rounded-lg bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300">
|
||||
<FolderKanban size={18} />
|
||||
</div>
|
||||
<p className="font-medium text-neutral-800 dark:text-neutral-200">No Aurora projects yet</p>
|
||||
<p className="mt-1 leading-5">
|
||||
Create a project with the + button.{' '}
|
||||
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1.5 p-2.5">
|
||||
{projects.map((project) => (
|
||||
<li key={project.name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectProject(project.name)}
|
||||
className={clsx(
|
||||
'group relative flex w-full flex-col gap-1 overflow-hidden rounded-lg border px-3 py-2.5 text-left transition',
|
||||
selectedProjectName === project.name
|
||||
? 'border-cyan-200 bg-cyan-50/90 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/25 dark:bg-cyan-400/10'
|
||||
: 'border-transparent hover:border-neutral-200 hover:bg-white/70 hover:shadow-sm dark:hover:border-white/10 dark:hover:bg-white/[0.04]'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'absolute inset-y-2 left-0 w-1 rounded-r-full transition-opacity',
|
||||
selectedProjectName === project.name
|
||||
? 'bg-cyan-500 opacity-100'
|
||||
: 'bg-neutral-300 opacity-0 group-hover:opacity-100 dark:bg-neutral-600'
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-semibold">{project.name}</span>
|
||||
<StatusBadge status={project.status} />
|
||||
</div>
|
||||
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{project.type} · {project.shortroot}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
running:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300',
|
||||
stopped:
|
||||
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400',
|
||||
paused:
|
||||
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-300'
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE =
|
||||
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400'
|
||||
|
||||
export function StatusBadge({ status }: { status: string }): React.JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
|
||||
STATUS_STYLES[status] ?? DEFAULT_STYLE
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'running'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'paused'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-neutral-400'
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { Bug, Hammer, List, RefreshCw, Search, ShieldAlert, TerminalSquare, Wrench } from 'lucide-react'
|
||||
import type { AuroraProjectDetail } from '@shared/types'
|
||||
import { useTerminalStore } from '../../stores/terminalStore'
|
||||
import { useStatusStore } from '../../stores/statusStore'
|
||||
|
||||
export function WordpressTools({ project }: { project: AuroraProjectDetail }): React.JSX.Element {
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [debug, setDebug] = useState<boolean | null>(null)
|
||||
const [maintenance, setMaintenance] = useState(false)
|
||||
|
||||
async function run(action: string, label: string, payload?: string): Promise<void> {
|
||||
const id = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(id, label)
|
||||
useStatusStore.getState().begin(id, label)
|
||||
setBusy(action)
|
||||
try { await window.api.wordpress.run(id, project.name, action, payload) }
|
||||
finally { setBusy(null) }
|
||||
}
|
||||
|
||||
function searchReplace(): void {
|
||||
const from = window.prompt('Search for (old URL or text):')
|
||||
if (!from) return
|
||||
const to = window.prompt('Replace with:')
|
||||
if (!to || from === to) return
|
||||
if (!window.confirm(`Replace all occurrences of:\n\n${from}\n\nwith:\n\n${to}\n\nA database snapshot is recommended first. Continue?`)) return
|
||||
void run('search-replace', `WP search-replace · ${project.name}`, `${from}\n${to}`)
|
||||
}
|
||||
|
||||
const button = 'inline-flex items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-2 text-xs font-semibold text-neutral-700 shadow-sm transition hover:border-cyan-300 hover:text-cyan-700 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:text-neutral-200 dark:hover:border-cyan-400/40 dark:hover:text-cyan-300'
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400"><Wrench size={14} className="text-cyan-600 dark:text-cyan-300" /> WordPress developer tools</h3>
|
||||
<p className="mt-1 text-xs text-neutral-500">WP-CLI commands run inside the project network and stream output to Dockside's terminal.</p>
|
||||
</div>
|
||||
<div className="text-right text-[11px] text-neutral-500">
|
||||
<div>PHP {project.php_version ?? '—'} · Node {project.nodejs_version ?? '—'}</div>
|
||||
<div>{project.wordpress_multisite === 'subdomain' ? 'Multisite · Subdomain' : project.wordpress_multisite === 'subdirectory' ? 'Multisite · Subdirectory' : 'Single site'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('cache-flush', `WP cache flush · ${project.name}`)}><RefreshCw size={13}/> Flush cache</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('rewrite-flush', `WP rewrite flush · ${project.name}`)}><Hammer size={13}/> Flush rewrites</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => { const next=!maintenance; setMaintenance(next); void run(next?'maintenance-on':'maintenance-off', `${next?'Enable':'Disable'} maintenance · ${project.name}`) }}><ShieldAlert size={13}/> {maintenance ? 'Disable' : 'Enable'} maintenance</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => { const next=debug === null ? true : !debug; setDebug(next); void run(next?'debug-on':'debug-off', `${next?'Enable':'Disable'} WP_DEBUG · ${project.name}`) }}><Bug size={13}/> {debug ? 'Disable' : 'Enable'} WP_DEBUG</button>
|
||||
<button className={button} disabled={!!busy} onClick={searchReplace}><Search size={13}/> Search & replace</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('plugin-list', `WP plugins · ${project.name}`)}><List size={13}/> Plugins</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('theme-list', `WP themes · ${project.name}`)}><List size={13}/> Themes</button>
|
||||
<button className={button} disabled={!!busy} onClick={() => void run('core-version', `WP-CLI · ${project.name}`)}><TerminalSquare size={13}/> WP version</button>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Minus, Plus, RotateCcw, X } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useThemeStore, type Theme } from '../../stores/themeStore'
|
||||
|
||||
const THEMES: { value: Theme; label: string }[] = [
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
{ value: 'system', label: 'System' }
|
||||
]
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ keys: 'Cmd/Ctrl + N', action: 'New project' },
|
||||
{ keys: 'Cmd/Ctrl + ,', action: 'Open settings' },
|
||||
{ keys: 'Cmd/Ctrl + =', action: 'Zoom in' },
|
||||
{ keys: 'Cmd/Ctrl + -', action: 'Zoom out' },
|
||||
{ keys: 'Cmd/Ctrl + 0', action: 'Reset zoom' }
|
||||
]
|
||||
|
||||
export function SettingsModal({ onClose }: { onClose: () => void }): React.JSX.Element {
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
|
||||
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
|
||||
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<h2 className="text-sm font-semibold">Settings</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Theme
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{THEMES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(t.value)}
|
||||
className={clsx(
|
||||
'flex-1 rounded-md border px-3 py-1.5 text-sm font-medium',
|
||||
theme === t.value
|
||||
? 'border-neutral-900 bg-neutral-900 text-white dark:border-neutral-100 dark:bg-neutral-100 dark:text-neutral-900'
|
||||
: 'border-neutral-300 hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800'
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Zoom
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.out()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Minus size={14} /> Out
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.in()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Plus size={14} /> In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.api.zoom.reset()}
|
||||
className="flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<RotateCcw size={14} /> Reset
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-2 text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Keyboard shortcuts
|
||||
</h3>
|
||||
<dl className="flex flex-col gap-1 text-sm">
|
||||
{SHORTCUTS.map((s) => (
|
||||
<div key={s.keys} className="flex items-center justify-between">
|
||||
<dt className="text-neutral-500 dark:text-neutral-400">{s.action}</dt>
|
||||
<dd className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs dark:bg-neutral-800">
|
||||
{s.keys}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTerminalStore } from '../../stores/terminalStore'
|
||||
|
||||
export function TerminalPanel(): React.JSX.Element | null {
|
||||
const isPanelOpen = useTerminalStore((s) => s.isPanelOpen)
|
||||
const activeOperationId = useTerminalStore((s) => s.activeOperationId)
|
||||
const operation = useTerminalStore((s) =>
|
||||
s.activeOperationId ? s.operations[s.activeOperationId] : null
|
||||
)
|
||||
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
|
||||
}, [operation?.lines.length])
|
||||
|
||||
if (!isPanelOpen || !activeOperationId || !operation) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-64 flex-shrink-0 flex-col border-t border-cyan-500/20 bg-neutral-950 shadow-[0_-16px_40px_rgba(15,23,42,0.2)] dark:border-cyan-400/20">
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/[0.03] px-3 py-1.5">
|
||||
<span className="text-xs font-medium text-neutral-300">{operation.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelOpen(false)}
|
||||
className="rounded p-1 text-neutral-400 transition hover:bg-white/10 hover:text-neutral-200"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto px-3 py-2 font-mono text-xs text-neutral-200"
|
||||
>
|
||||
<pre className="whitespace-pre-wrap">{operation.lines.join('')}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react'
|
||||
import { CheckCircle2, XCircle } from 'lucide-react'
|
||||
import { clsx } from 'clsx'
|
||||
import { useToastStore, type Toast } from '../../stores/toastStore'
|
||||
|
||||
const AUTO_DISMISS_MS = 4000
|
||||
|
||||
function ToastItem({ toast }: { toast: Toast }): React.JSX.Element {
|
||||
const removeToast = useToastStore((s) => s.removeToast)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => removeToast(toast.id), AUTO_DISMISS_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toast.id, removeToast])
|
||||
|
||||
const isSuccess = toast.variant === 'success'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center gap-2 rounded-lg border px-3 py-2 text-sm shadow-lg backdrop-blur',
|
||||
isSuccess
|
||||
? 'border-emerald-200 bg-emerald-50/95 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/95 dark:text-emerald-300'
|
||||
: 'border-red-200 bg-red-50/95 text-red-800 dark:border-red-900 dark:bg-red-950/95 dark:text-red-300'
|
||||
)}
|
||||
>
|
||||
{isSuccess ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||
{toast.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Toaster(): React.JSX.Element {
|
||||
const toasts = useToastStore((s) => s.toasts)
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<ToastItem toast={toast} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
|
||||
// Applies the resolved theme (light/dark/system) to <html class="dark">,
|
||||
// which is what the `@custom-variant dark` rule in main.css keys off. Must
|
||||
// run somewhere mounted once near the app root.
|
||||
export function useAppliedTheme(): void {
|
||||
const theme = useThemeStore((s) => s.theme)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const apply = (): void => {
|
||||
const isDark = theme === 'dark' || (theme === 'system' && media.matches)
|
||||
root.classList.toggle('dark', isDark)
|
||||
}
|
||||
|
||||
apply()
|
||||
|
||||
if (theme === 'system') {
|
||||
media.addEventListener('change', apply)
|
||||
return () => media.removeEventListener('change', apply)
|
||||
}
|
||||
return undefined
|
||||
}, [theme])
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult
|
||||
} from '@tanstack/react-query'
|
||||
import type { AuroraProjectDetail, AuroraProjectSummary, EnvironmentUpdate } from '@shared/types'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
|
||||
const PROJECTS_KEY = ['projects'] as const
|
||||
const projectDetailKey = (name: string): readonly [string, string] => ['project', name] as const
|
||||
|
||||
export function useProjects(): UseQueryResult<AuroraProjectSummary[], Error> {
|
||||
return useQuery({
|
||||
queryKey: PROJECTS_KEY,
|
||||
queryFn: () => window.api.projects.list(),
|
||||
refetchInterval: 5000
|
||||
})
|
||||
}
|
||||
|
||||
export function useProjectDetail(name: string | null): UseQueryResult<AuroraProjectDetail, Error> {
|
||||
return useQuery({
|
||||
queryKey: name ? projectDetailKey(name) : ['project', 'none'],
|
||||
queryFn: () => window.api.projects.describe(name!),
|
||||
enabled: name !== null,
|
||||
retry: false,
|
||||
refetchInterval: (query) => query.state.status === 'error' ? false : 5000
|
||||
})
|
||||
}
|
||||
|
||||
// Runs a streamed Aurora command (start/stop/restart) for a project. Generates
|
||||
// the operationId here so the terminal panel and status bar can start
|
||||
// tracking it before the IPC call even resolves.
|
||||
function useProjectAction(
|
||||
verb: string,
|
||||
action: (operationId: string, name: string) => Promise<void>
|
||||
): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const operationId = crypto.randomUUID()
|
||||
const label = `${verb} ${name}`
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
await action(operationId, name)
|
||||
},
|
||||
onSettled: (_data, _error, name) => {
|
||||
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartProject(): UseMutationResult<void, Error, string> {
|
||||
return useProjectAction('Start', (operationId, name) =>
|
||||
window.api.projects.start(operationId, name)
|
||||
)
|
||||
}
|
||||
|
||||
export function useStopProject(): UseMutationResult<void, Error, string> {
|
||||
return useProjectAction('Stop', (operationId, name) =>
|
||||
window.api.projects.stop(operationId, name)
|
||||
)
|
||||
}
|
||||
|
||||
export function useRestartProject(): UseMutationResult<void, Error, string> {
|
||||
return useProjectAction('Restart', (operationId, name) =>
|
||||
window.api.projects.restart(operationId, name)
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeleteProject(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ name: string; approot: string; deleteFiles: boolean }
|
||||
> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, approot, deleteFiles }) => {
|
||||
const operationId = crypto.randomUUID()
|
||||
const label = `Delete ${name}`
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
await window.api.projects.delete(operationId, name, approot, deleteFiles)
|
||||
},
|
||||
onSettled: (_data, _error, { name }) => {
|
||||
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Only runs `Aurora config` — the caller is expected to follow a successful
|
||||
// call with useRestartProject() if the project is currently running, same
|
||||
// split as useCreateProject's configure/start pair.
|
||||
export function useUpdateEnvironment(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ name: string; approot: string; updates: EnvironmentUpdate }
|
||||
> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, approot, updates }) => {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, `Update ${name} environment`)
|
||||
useStatusStore.getState().begin(operationId, `Update ${name} environment`)
|
||||
await window.api.projects.updateEnvironment(operationId, name, approot, updates)
|
||||
},
|
||||
onSettled: (_data, _error, { name }) => {
|
||||
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
|
||||
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { AuroraStackOptions } from '@shared/types'
|
||||
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
|
||||
export interface CreateProjectInput {
|
||||
directory: string
|
||||
projectName: string
|
||||
projectType: string
|
||||
docroot: string
|
||||
stack?: Partial<AuroraStackOptions>
|
||||
}
|
||||
|
||||
export interface WordpressSetupInput {
|
||||
directory: string
|
||||
siteUrl: string
|
||||
title: string
|
||||
adminUser: string
|
||||
adminPassword: string
|
||||
adminEmail: string
|
||||
multisite: 'none' | 'subdirectory' | 'subdomain'
|
||||
}
|
||||
|
||||
export interface DrupalSetupInput {
|
||||
directory: string
|
||||
siteName: string
|
||||
adminUser: string
|
||||
adminPassword: string
|
||||
adminEmail: string
|
||||
profile: string
|
||||
}
|
||||
|
||||
function beginOperation(label: string): string {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
return operationId
|
||||
}
|
||||
|
||||
// Only runs `Aurora config`, not `Aurora start` — the caller is expected to
|
||||
// follow a successful creation with the existing useStartProject() mutation,
|
||||
// reusing its own tracked operation/toast lifecycle rather than needing this
|
||||
// one to juggle two unrelated command phases under a single operationId.
|
||||
export function useCreateProject(): UseMutationResult<void, Error, CreateProjectInput> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory, projectName, projectType, docroot, stack }: CreateProjectInput) => {
|
||||
const operationId = beginOperation(`Create project ${projectName}`)
|
||||
await window.api.create.configure(operationId, directory, projectName, projectType, docroot, stack)
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
})
|
||||
}
|
||||
|
||||
// `Aurora config --project-type=wordpress` only scaffolds the wp-config.php
|
||||
// bridge, not WordPress core itself — see create.ts in the main process for
|
||||
// why this and useSetupWordpress are separate tracked operations run after
|
||||
// the project has been started.
|
||||
export function useDownloadWordpress(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
{ directory: string; locale: string }
|
||||
> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory, locale }) => {
|
||||
const operationId = beginOperation('Download WordPress core')
|
||||
await window.api.create.downloadWordpress(operationId, directory, locale)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSetupInput> {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
directory,
|
||||
siteUrl,
|
||||
title,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
multisite
|
||||
}) => {
|
||||
const operationId = beginOperation('Install WordPress')
|
||||
await window.api.create.setupWordpress(
|
||||
operationId,
|
||||
directory,
|
||||
siteUrl,
|
||||
title,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
multisite
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// `Aurora config --project-type=drupal*` only scaffolds Aurora's settings.php
|
||||
// bridge, not Drupal core itself — see create.ts in the main process for why
|
||||
// this, useRequireDrush, and useSetupDrupal are separate tracked operations
|
||||
// run after the project has been started.
|
||||
export function useDownloadDrupal(): UseMutationResult<void, Error, { directory: string }> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory }) => {
|
||||
const operationId = beginOperation('Download Drupal core')
|
||||
await window.api.create.downloadDrupal(operationId, directory)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useRequireDrush(): UseMutationResult<void, Error, { directory: string }> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory }) => {
|
||||
const operationId = beginOperation('Add Drush')
|
||||
await window.api.create.requireDrush(operationId, directory)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetupDrupal(): UseMutationResult<void, Error, DrupalSetupInput> {
|
||||
return useMutation({
|
||||
mutationFn: async ({ directory, siteName, adminUser, adminPassword, adminEmail, profile }) => {
|
||||
const operationId = beginOperation('Install Drupal')
|
||||
await window.api.create.setupDrupal(
|
||||
operationId,
|
||||
directory,
|
||||
siteName,
|
||||
adminUser,
|
||||
adminPassword,
|
||||
adminEmail,
|
||||
profile
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult
|
||||
} from '@tanstack/react-query'
|
||||
import type { AuroraSnapshot } from '@shared/types'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
|
||||
const snapshotsKey = (name: string): readonly [string, string] => ['snapshots', name] as const
|
||||
|
||||
export function useSnapshots(name: string, approot: string): UseQueryResult<AuroraSnapshot[], Error> {
|
||||
return useQuery({
|
||||
queryKey: snapshotsKey(name),
|
||||
queryFn: () => window.api.database.listSnapshots(name, approot)
|
||||
})
|
||||
}
|
||||
|
||||
// Same tracked-operation pattern as useProjectAction in useAurora.ts: generate
|
||||
// an operationId up front so the terminal panel/status bar pick it up before
|
||||
// the (potentially slow) Aurora command resolves.
|
||||
function beginOperation(label: string): string {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
return operationId
|
||||
}
|
||||
|
||||
export function useCreateSnapshot(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<void, Error, string | undefined> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (snapshotName?: string) => {
|
||||
const operationId = beginOperation(`Create snapshot for ${name}`)
|
||||
await window.api.database.createSnapshot(operationId, approot, snapshotName)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
|
||||
})
|
||||
}
|
||||
|
||||
export function useRestoreSnapshot(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (snapshotName: string) => {
|
||||
const operationId = beginOperation(`Restore snapshot ${snapshotName}`)
|
||||
await window.api.database.restoreSnapshot(operationId, approot, snapshotName)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteSnapshot(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (snapshotName: string) => {
|
||||
const operationId = beginOperation(`Delete snapshot ${snapshotName}`)
|
||||
await window.api.database.deleteSnapshot(operationId, approot, snapshotName)
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves to false if the user cancels the file picker, true if the import ran.
|
||||
export function useImportDatabase(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<boolean, Error, void> {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const filePath = await window.api.database.pickImportFile()
|
||||
if (!filePath) return false
|
||||
const operationId = beginOperation(`Import database into ${name}`)
|
||||
await window.api.database.importFile(operationId, approot, filePath)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Resolves to false if the user cancels the save dialog, true if the export ran.
|
||||
export function useExportDatabase(
|
||||
name: string,
|
||||
approot: string
|
||||
): UseMutationResult<boolean, Error, void> {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const filePath = await window.api.database.pickExportPath(`${name}.sql.gz`)
|
||||
if (!filePath) return false
|
||||
const operationId = beginOperation(`Export database from ${name}`)
|
||||
await window.api.database.exportFile(operationId, approot, filePath)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ShortcutHandlers {
|
||||
onNewProject: () => void
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
// Global keyboard shortcuts: Cmd/Ctrl+N (new project), Cmd/Ctrl+, (settings),
|
||||
// Cmd/Ctrl +/-/0 (zoom in/out/reset). Mount once near the app root.
|
||||
export function useKeyboardShortcuts({ onNewProject, onOpenSettings }: ShortcutHandlers): void {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent): void => {
|
||||
const mod = e.metaKey || e.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'n':
|
||||
e.preventDefault()
|
||||
onNewProject()
|
||||
break
|
||||
case ',':
|
||||
e.preventDefault()
|
||||
onOpenSettings()
|
||||
break
|
||||
case '=':
|
||||
case '+':
|
||||
e.preventDefault()
|
||||
window.api.zoom.in()
|
||||
break
|
||||
case '-':
|
||||
e.preventDefault()
|
||||
window.api.zoom.out()
|
||||
break
|
||||
case '0':
|
||||
e.preventDefault()
|
||||
window.api.zoom.reset()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [onNewProject, onOpenSettings])
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface LogLine {
|
||||
stream: 'stdout' | 'stderr'
|
||||
text: string
|
||||
}
|
||||
|
||||
interface UseLogStreamResult {
|
||||
lines: LogLine[]
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
// Owns the lifecycle of a single `Aurora logs -f` subprocess for one
|
||||
// (name, service) pair. Callers must remount this (e.g. `key={service}`)
|
||||
// when the service changes — state resets via fresh useState initializers
|
||||
// on mount rather than manual resets inside the effect, since React's
|
||||
// hooks lint flags synchronous setState-to-reset calls in an effect body.
|
||||
// Always stops the subprocess on unmount so a closed log viewer doesn't
|
||||
// leave an orphaned `Aurora logs -f` process running.
|
||||
export function useLogStream(name: string, service: string): UseLogStreamResult {
|
||||
const [lines, setLines] = useState<LogLine[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(true)
|
||||
// Data arrives as arbitrary byte chunks, not newline-delimited — a chunk
|
||||
// can span partial lines or bundle many lines together. Buffer per stream
|
||||
// so filtering/display operates on real lines instead of raw chunks.
|
||||
const buffers = useRef({ stdout: '', stderr: '' })
|
||||
|
||||
useEffect(() => {
|
||||
const operationId = crypto.randomUUID()
|
||||
buffers.current = { stdout: '', stderr: '' }
|
||||
|
||||
const unsubData = window.api.logs.onData((event) => {
|
||||
if (event.operationId !== operationId) return
|
||||
const combined = buffers.current[event.stream] + event.chunk
|
||||
const parts = combined.split('\n')
|
||||
buffers.current[event.stream] = parts.pop() ?? ''
|
||||
if (parts.length === 0) return
|
||||
setLines((prev) => [...prev, ...parts.map((text) => ({ stream: event.stream, text }))])
|
||||
})
|
||||
const unsubExit = window.api.logs.onExit((event) => {
|
||||
if (event.operationId !== operationId) return
|
||||
setIsStreaming(false)
|
||||
})
|
||||
|
||||
window.api.logs.start(operationId, name, service)
|
||||
|
||||
return () => {
|
||||
unsubData()
|
||||
unsubExit()
|
||||
window.api.terminal.cancel(operationId)
|
||||
}
|
||||
}, [name, service])
|
||||
|
||||
return { lines, isStreaming }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMutation, useQuery, useQueryClient, type UseMutationResult, type UseQueryResult } from '@tanstack/react-query'
|
||||
import type { AuroraInstalledModule, AuroraModuleManifest } from '@shared/types'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
|
||||
const installedModulesKey = (name: string): readonly [string, string, string] => ['modules', 'installed', name] as const
|
||||
|
||||
export function useModuleRegistry(): UseQueryResult<AuroraModuleManifest[], Error> {
|
||||
return useQuery({ queryKey: ['modules', 'registry'], queryFn: () => window.api.modules.listRegistry(), staleTime: Infinity })
|
||||
}
|
||||
|
||||
export function useInstalledModules(name: string): UseQueryResult<AuroraInstalledModule[], Error> {
|
||||
return useQuery({ queryKey: installedModulesKey(name), queryFn: () => window.api.modules.listInstalled(name) })
|
||||
}
|
||||
|
||||
function beginOperation(label: string): string {
|
||||
const operationId = crypto.randomUUID()
|
||||
useTerminalStore.getState().startOperation(operationId, label)
|
||||
useStatusStore.getState().begin(operationId, label)
|
||||
return operationId
|
||||
}
|
||||
|
||||
type InstallInput = { moduleId: string; settings?: Record<string, string | number | boolean> }
|
||||
|
||||
export function useInstallModule(name: string): UseMutationResult<void, Error, InstallInput> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ moduleId, settings = {} }) => {
|
||||
const operationId = beginOperation(`Install ${moduleId}`)
|
||||
await window.api.modules.install(operationId, name, moduleId, settings)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: installedModulesKey(name) })
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveModule(name: string): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (moduleId) => {
|
||||
const operationId = beginOperation(`Remove ${moduleId}`)
|
||||
await window.api.modules.remove(operationId, name, moduleId)
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: installedModulesKey(name) })
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTerminalStore } from '../stores/terminalStore'
|
||||
import { useStatusStore } from '../stores/statusStore'
|
||||
import { useToastStore } from '../stores/toastStore'
|
||||
|
||||
// Wires the main process's terminal:data / terminal:exit IPC events into the
|
||||
// terminal/status/toast stores. Mount once near the app root — every
|
||||
// long-running Aurora command (start/stop/restart, and later snapshots/addons)
|
||||
// flows through this same event stream regardless of which mutation kicked
|
||||
// it off, so this is the single place that owns "what happens when a
|
||||
// command finishes."
|
||||
export function useTerminalEvents(): void {
|
||||
useEffect(() => {
|
||||
const unsubData = window.api.terminal.onData(({ operationId, chunk }) => {
|
||||
useTerminalStore.getState().appendChunk(operationId, chunk)
|
||||
})
|
||||
|
||||
const unsubExit = window.api.terminal.onExit(({ operationId, exitCode, cancelled }) => {
|
||||
const op = useTerminalStore.getState().operations[operationId]
|
||||
const status = cancelled ? 'cancelled' : exitCode === 0 ? 'success' : 'error'
|
||||
useTerminalStore.getState().finishOperation(operationId, status, exitCode)
|
||||
|
||||
if (useStatusStore.getState().operationId === operationId) {
|
||||
useStatusStore.getState().end()
|
||||
}
|
||||
|
||||
if (op) {
|
||||
if (status === 'success') {
|
||||
useToastStore.getState().addToast('success', `${op.label} succeeded`)
|
||||
} else if (status === 'error') {
|
||||
useToastStore.getState().addToast('error', `${op.label} failed`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
unsubData()
|
||||
unsubExit()
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface AppState {
|
||||
selectedProjectName: string | null
|
||||
selectProject: (name: string | null) => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
selectedProjectName: null,
|
||||
selectProject: (name): void => set({ selectedProjectName: name })
|
||||
}))
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface StatusState {
|
||||
operationId: string | null
|
||||
label: string | null
|
||||
begin: (operationId: string, label: string) => void
|
||||
end: () => void
|
||||
}
|
||||
|
||||
export const useStatusStore = create<StatusState>((set) => ({
|
||||
operationId: null,
|
||||
label: null,
|
||||
begin: (operationId, label): void => set({ operationId, label }),
|
||||
end: (): void => set({ operationId: null, label: null })
|
||||
}))
|
||||
@@ -0,0 +1,65 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type OperationStatus = 'running' | 'success' | 'error' | 'cancelled'
|
||||
|
||||
export interface TerminalOperation {
|
||||
id: string
|
||||
label: string
|
||||
lines: string[]
|
||||
status: OperationStatus
|
||||
exitCode: number | null
|
||||
}
|
||||
|
||||
interface TerminalState {
|
||||
operations: Record<string, TerminalOperation>
|
||||
activeOperationId: string | null
|
||||
isPanelOpen: boolean
|
||||
startOperation: (id: string, label: string) => void
|
||||
appendChunk: (id: string, chunk: string) => void
|
||||
finishOperation: (
|
||||
id: string,
|
||||
status: Exclude<OperationStatus, 'running'>,
|
||||
exitCode: number | null
|
||||
) => void
|
||||
setActiveOperation: (id: string | null) => void
|
||||
setPanelOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
export const useTerminalStore = create<TerminalState>((set) => ({
|
||||
operations: {},
|
||||
activeOperationId: null,
|
||||
isPanelOpen: false,
|
||||
startOperation: (id, label): void =>
|
||||
set((state) => ({
|
||||
operations: {
|
||||
...state.operations,
|
||||
[id]: { id, label, lines: [], status: 'running', exitCode: null }
|
||||
},
|
||||
activeOperationId: id,
|
||||
isPanelOpen: true
|
||||
})),
|
||||
appendChunk: (id, chunk): void =>
|
||||
set((state) => {
|
||||
const op = state.operations[id]
|
||||
if (!op) return state
|
||||
return {
|
||||
operations: {
|
||||
...state.operations,
|
||||
[id]: { ...op, lines: [...op.lines, chunk] }
|
||||
}
|
||||
}
|
||||
}),
|
||||
finishOperation: (id, status, exitCode): void =>
|
||||
set((state) => {
|
||||
const op = state.operations[id]
|
||||
if (!op) return state
|
||||
return {
|
||||
operations: {
|
||||
...state.operations,
|
||||
[id]: { ...op, status, exitCode }
|
||||
}
|
||||
}
|
||||
}),
|
||||
setActiveOperation: (id): void => set({ activeOperationId: id }),
|
||||
setPanelOpen: (open): void => set({ isPanelOpen: open })
|
||||
}))
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist<ThemeState>(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
setTheme: (theme) => {
|
||||
set({ theme })
|
||||
}
|
||||
}),
|
||||
{ name: 'aurora-dockside-theme' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type ToastVariant = 'success' | 'error'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
variant: ToastVariant
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ToastState {
|
||||
toasts: Toast[]
|
||||
addToast: (variant: ToastVariant, message: string) => void
|
||||
removeToast: (id: string) => void
|
||||
}
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
addToast: (variant, message): void =>
|
||||
set((state) => ({
|
||||
toasts: [...state.toasts, { id: crypto.randomUUID(), variant, message }]
|
||||
})),
|
||||
removeToast: (id): void => set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) }))
|
||||
}))
|
||||
@@ -0,0 +1,197 @@
|
||||
export type ProjectStatus = 'running' | 'stopped' | 'paused' | 'starting' | 'stopping' | string
|
||||
|
||||
export interface AuroraProjectSummary {
|
||||
name: string
|
||||
status: ProjectStatus
|
||||
status_desc: string
|
||||
type: string
|
||||
approot: string
|
||||
shortroot: string
|
||||
docroot: string
|
||||
primary_url: string
|
||||
httpurl: string
|
||||
httpsurl: string
|
||||
mutagen_enabled: boolean
|
||||
mutagen_status?: string
|
||||
}
|
||||
|
||||
export interface AuroraServiceHostPortMapping {
|
||||
exposed_port: string
|
||||
host_port: string
|
||||
}
|
||||
|
||||
export interface AuroraService {
|
||||
short_name: string
|
||||
full_name: string
|
||||
status: string
|
||||
image: string
|
||||
exposed_ports: string
|
||||
host_ports: string
|
||||
host_ports_mapping: AuroraServiceHostPortMapping[]
|
||||
http_url?: string
|
||||
https_url?: string
|
||||
host_http_url?: string
|
||||
host_https_url?: string
|
||||
virtual_host?: string
|
||||
'describe-info'?: string
|
||||
'describe-url-port'?: string
|
||||
}
|
||||
|
||||
export interface AuroraDbInfo {
|
||||
database_type: string
|
||||
database_version: string
|
||||
dbPort: string
|
||||
dbname: string
|
||||
host: string
|
||||
password: string
|
||||
published_port: number
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface AuroraProjectDetail extends AuroraProjectSummary {
|
||||
database_type: string
|
||||
database_version: string
|
||||
dbinfo: AuroraDbInfo
|
||||
hostname: string
|
||||
hostnames: string[]
|
||||
httpURLs: string[]
|
||||
httpsURLs: string[]
|
||||
urls: string[]
|
||||
php_version?: string
|
||||
nodejs_version?: string
|
||||
webserver_type?: string
|
||||
performance_mode?: string
|
||||
router: string
|
||||
router_status?: string
|
||||
certificate_status?: 'generated' | 'missing'
|
||||
ca_trust_status?: 'trusted' | 'not-trusted' | 'unknown'
|
||||
firefox_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
|
||||
chromium_trust_status?: 'trusted' | 'not-trusted' | 'unavailable' | 'unknown'
|
||||
wordpress_multisite?: 'none' | 'subdirectory' | 'subdomain'
|
||||
wordpress_network_admin_url?: string
|
||||
adminer_url?: string
|
||||
services: Record<string, AuroraService>
|
||||
xdebug_enabled: boolean
|
||||
}
|
||||
|
||||
export interface AuroraSiteCredentials {
|
||||
platform: string
|
||||
adminUrl: string
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface AuroraStackOptions {
|
||||
phpVersion: string
|
||||
nodeVersion: string
|
||||
database: 'mariadb' | 'postgres'
|
||||
databaseVersion: string
|
||||
adminer: boolean
|
||||
redis: boolean
|
||||
mailpit: boolean
|
||||
xdebug: boolean
|
||||
}
|
||||
|
||||
export interface EnvironmentUpdate {
|
||||
phpVersion?: string
|
||||
nodeVersion?: string
|
||||
webserverType?: string
|
||||
database?: string
|
||||
xdebugEnabled?: boolean
|
||||
primaryProtocol?: 'http' | 'https'
|
||||
}
|
||||
|
||||
export interface AuroraSnapshot {
|
||||
Name: string
|
||||
Created: string
|
||||
}
|
||||
|
||||
|
||||
export type AuroraModuleCategory = 'application' | 'service' | 'tool'
|
||||
export type AuroraModuleSettingType = 'boolean' | 'select' | 'text' | 'number'
|
||||
|
||||
export interface AuroraModuleSetting {
|
||||
id: string
|
||||
label: string
|
||||
type: AuroraModuleSettingType
|
||||
default: string | number | boolean
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
export interface AuroraModuleManifest {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
category: AuroraModuleCategory
|
||||
description: string
|
||||
icon?: string
|
||||
dependencies: string[]
|
||||
conflicts: string[]
|
||||
defaults?: { docroot?: string }
|
||||
settings: AuroraModuleSetting[]
|
||||
compose?: {
|
||||
service: string
|
||||
image: string
|
||||
ports?: number[]
|
||||
dependsOn?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuroraInstalledModule {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
category: AuroraModuleCategory
|
||||
}
|
||||
|
||||
export interface AuroraAddonRegistryEntry {
|
||||
title: string
|
||||
github_url: string
|
||||
description: string
|
||||
user: string
|
||||
repo: string
|
||||
repo_id: number
|
||||
default_branch: string
|
||||
tag_name: string | null
|
||||
engine_version_constraint: string
|
||||
dependencies: string[] | null
|
||||
type: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
workflow_status: string
|
||||
stars: number
|
||||
}
|
||||
|
||||
export interface AuroraInstalledAddon {
|
||||
Name: string
|
||||
Repository: string
|
||||
Version: string
|
||||
Dependencies: string[] | null
|
||||
InstallDate: string
|
||||
ProjectFiles: string[] | null
|
||||
GlobalFiles: string[] | null
|
||||
RemovalActions: string[] | null
|
||||
}
|
||||
|
||||
export interface TerminalDataEvent {
|
||||
operationId: string
|
||||
stream: 'stdout' | 'stderr'
|
||||
chunk: string
|
||||
}
|
||||
|
||||
export interface TerminalExitEvent {
|
||||
operationId: string
|
||||
exitCode: number | null
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export interface LogDataEvent {
|
||||
operationId: string
|
||||
stream: 'stdout' | 'stderr'
|
||||
chunk: string
|
||||
}
|
||||
|
||||
export interface LogExitEvent {
|
||||
operationId: string
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
// jsdom doesn't implement matchMedia; useAppliedTheme() relies on it to
|
||||
// resolve the "system" theme.
|
||||
if (!window.matchMedia) {
|
||||
window.matchMedia = (query: string): MediaQueryList =>
|
||||
({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => false
|
||||
}) as MediaQueryList
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
|
||||
"include": [
|
||||
"src/renderer/src/env.d.ts",
|
||||
"src/renderer/src/**/*",
|
||||
"src/renderer/src/**/*.tsx",
|
||||
"src/preload/*.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"@testing-library/jest-dom"
|
||||
],
|
||||
"paths": {
|
||||
"@renderer/*": [
|
||||
"src/renderer/src/*"
|
||||
],
|
||||
"@shared/*": [
|
||||
"src/shared/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@renderer': resolve('src/renderer/src'),
|
||||
'@shared': resolve('src/shared')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'html'],
|
||||
include: ['src/renderer/src/**/*.{ts,tsx}']
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user