diff --git a/.gitignore b/.gitignore
index 1d0f0533f2..bad56a804c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,16 @@ dev/yasd_init.php
.phpunit.result.cache
Makefile
appwrite.config.json
-/.zed/
\ No newline at end of file
+/.zed/
+playwright-report
+test-results
+
+# Web installer test files
+docker-compose.web-installer.yml
+.env.web-installer
+
+# backups when testing upgrade mode.
+docker-compose.web-installer.yml.**.backup
+
+# screenshots
+tests/playwright/screenshots
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index e848b6f0b5..2ae3bee045 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -37,6 +37,8 @@ COPY ./app /usr/src/code/app
COPY ./public /usr/src/code/public
COPY ./bin /usr/local/bin
COPY ./src /usr/src/code/src
+COPY ./dev /usr/src/code/dev
+COPY ./mongo-entrypoint.sh /usr/src/code/mongo-entrypoint.sh
# Set Volumes
RUN mkdir -p /storage/uploads && \
@@ -116,5 +118,6 @@ RUN if [ "$DEBUG" = "true" ]; then \
fi
EXPOSE 80
+EXPOSE 8080
CMD [ "php", "app/http.php" ]
diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml
index 16af33ca3a..aa3905efab 100644
--- a/app/views/install/compose.phtml
+++ b/app/views/install/compose.phtml
@@ -12,6 +12,8 @@ $version = $this->getParam('version', '');
$organization = $this->getParam('organization', '');
$image = $this->getParam('image', '');
$enableAssistant = $this->getParam('enableAssistant', false);
+$dbService = $this->getParam('database');
+$hostPath = rtrim($this->getParam('hostPath', ''), '/');
?>services:
traefik:
image: traefik:2.11
@@ -61,6 +63,9 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- traefik.http.routers.appwrite_api_https.service=appwrite_api
- traefik.http.routers.appwrite_api_https.tls=true
volumes:
+
+ - :/usr/src/code:rw
+
- appwrite-uploads:/storage/uploads:rw
- appwrite-imports:/storage/imports:rw
- appwrite-cache:/storage/cache:rw
@@ -70,7 +75,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite-sites:/storage/sites:rw
- appwrite-builds:/storage/builds:rw
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
# - clamav
environment:
@@ -223,7 +228,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
networks:
- appwrite
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -253,7 +258,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -279,7 +284,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -307,7 +312,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
volumes:
- appwrite-uploads:/storage/uploads:rw
- appwrite-cache:/storage/cache:rw
@@ -370,7 +375,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -396,7 +401,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
volumes:
- appwrite-functions:/storage/functions:rw
- appwrite-sites:/storage/sites:rw
@@ -464,7 +469,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
volumes:
- appwrite-config:/storage/config:rw
- appwrite-certificates:/storage/certificates:rw
@@ -501,7 +506,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
- openruntimes-executor
environment:
- _APP_ENV
@@ -626,7 +631,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
volumes:
- appwrite-imports:/storage/imports:rw
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -700,7 +705,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -729,7 +734,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -757,7 +762,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
depends_on:
- redis
- - mariadb
+ - = $dbService . "\n" ?>
environment:
- _APP_ENV
- _APP_WORKER_PER_CORE
@@ -784,7 +789,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
networks:
- appwrite
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -809,7 +814,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
networks:
- appwrite
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -834,7 +839,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
networks:
- appwrite
depends_on:
- - mariadb
+ - = $dbService . "\n" ?>
- redis
environment:
- _APP_ENV
@@ -922,6 +927,7 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- OPR_EXECUTOR_STORAGE_WASABI_REGION=$_APP_STORAGE_WASABI_REGION
- OPR_EXECUTOR_STORAGE_WASABI_BUCKET=$_APP_STORAGE_WASABI_BUCKET
+
mariadb:
image: mariadb:10.11 # fix issues when upgrading using: mysql_upgrade -u root -p
container_name: appwrite-mariadb
@@ -931,6 +937,8 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- appwrite
volumes:
- appwrite-mariadb:/var/lib/mysql:rw
+ ports:
+ - "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=${_APP_DB_ROOT_PASS}
- MYSQL_DATABASE=${_APP_DB_SCHEMA}
@@ -938,6 +946,28 @@ $enableAssistant = $this->getParam('enableAssistant', false);
- MYSQL_PASSWORD=${_APP_DB_PASS}
- MARIADB_AUTO_UPGRADE=1
command: 'mysqld --innodb-flush-method=fsync'
+
+ mongodb:
+ image: mongo:8.0.8
+ container_name: appwrite-mongodb
+ <<: *x-logging
+ restart: unless-stopped
+ networks:
+ - appwrite
+ volumes:
+ - appwrite-mongodb:/data/db:rw
+ - appwrite-mongodb-config:/data/configdb:rw
+
+ - /mongo-entrypoint.sh:/mongo-entrypoint.sh:ro
+
+ - ./mongo-entrypoint.sh:/mongo-entrypoint.sh:ro
+
+ ports:
+ - "27017:27017"
+ environment:
+ - MONGO_INITDB_DATABASE=${_APP_DB_SCHEMA}
+ entrypoint: ["/bin/bash", "/mongo-entrypoint.sh"]
+
redis:
image: redis:7.2.4-alpine
@@ -972,7 +1002,12 @@ networks:
name: runtimes
volumes:
+
appwrite-mariadb:
+
+ appwrite-mongodb:
+ appwrite-mongodb-config:
+
appwrite-redis:
appwrite-cache:
appwrite-uploads:
diff --git a/app/views/install/installer.phtml b/app/views/install/installer.phtml
new file mode 100644
index 0000000000..9d5a3a0d39
--- /dev/null
+++ b/app/views/install/installer.phtml
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ data-locked-database=""
+
+ data-default-http-port=""
+ data-default-https-port=""
+ data-default-app-domain=""
+ data-default-email-certificates=""
+ data-default-secret-key=""
+ data-default-assistant-openai-key=""
+ data-default-database=""
+
+ data-dev-mode="true"
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css
new file mode 100644
index 0000000000..4601cfcb67
--- /dev/null
+++ b/app/views/install/installer/css/styles.css
@@ -0,0 +1,1743 @@
+/* Installer Styles - Pink v2 tokens and components */
+
+:root {
+ /* Neutral colors */
+ --neutral-0: rgba(255, 255, 255, 1);
+ --neutral-25: rgba(250, 250, 251, 1);
+ --neutral-40: rgba(244, 244, 247, 1);
+ --neutral-50: rgba(237, 237, 240, 1);
+ --neutral-100: rgba(228, 228, 231, 1);
+ --neutral-200: rgba(216, 216, 219, 1);
+ --neutral-300: rgba(173, 173, 176, 1);
+ --neutral-400: rgba(151, 151, 155, 1);
+ --neutral-500: rgba(129, 129, 134, 1);
+ --neutral-700: rgba(86, 86, 92, 1);
+ --neutral-800: rgba(45, 45, 49, 1);
+ --neutral-900: rgba(25, 25, 28, 1);
+
+ /* Warning colors */
+ --web-orange-200: rgba(255, 213, 194, 1);
+ --web-orange-500: rgba(254, 124, 67, 1);
+ --web-orange-700: rgba(97, 37, 10, 1);
+
+ /* Error colors */
+ --web-red-500: rgba(255, 69, 58, 1);
+ --web-red-700: rgba(179, 18, 18, 1);
+
+ /* Brand colors */
+ --brand-pink-500: rgba(253, 54, 110, 1);
+ --brand-pink-600: rgba(202, 43, 88, 1);
+ --brand-pink-700: rgba(152, 32, 66, 1);
+
+ /* Background colors */
+ --bgcolor-neutral-default: var(--neutral-25);
+ --bgcolor-neutral-primary: var(--neutral-0);
+ --bgcolor-neutral-secondary: var(--neutral-40);
+ --bgcolor-neutral-tertiary: var(--neutral-50);
+ --bgcolor-neutral-invert-weaker: var(--neutral-500);
+ --bgcolor-neutral-invert-weak: var(--neutral-700);
+ --bgcolor-accent: var(--brand-pink-500);
+ --bgcolor-accent-secondary: var(--brand-pink-600);
+ --bgcolor-accent-tertiary: var(--brand-pink-700);
+ --bgcolor-success-weak: rgba(16, 185, 129, 0.16);
+ --bgcolor-warning-weaker: rgba(254, 124, 67, 0.04);
+ --bgcolor-warning-weak: rgba(254, 124, 67, 0.16);
+ --bgcolor-error: var(--web-red-500);
+ --bgcolor-error-weaker: rgba(255, 69, 58, 0.04);
+
+ /* Foreground colors */
+ --fgcolor-neutral-primary: var(--neutral-800);
+ --fgcolor-neutral-secondary: var(--neutral-700);
+ --fgcolor-neutral-tertiary: var(--neutral-400);
+ --fgcolor-neutral-weak: var(--neutral-200);
+ --fgcolor-accent: var(--brand-pink-500);
+ --fgcolor-on-accent: var(--neutral-0);
+ --fgcolor-on-invert: var(--neutral-25);
+ --fgcolor-on-success-weak: rgba(10, 113, 79, 1);
+ --fgcolor-warning: rgba(97, 37, 10, 1);
+ --fgcolor-on-warning-weak: var(--web-orange-700);
+ --fgcolor-error: var(--web-red-700);
+ --fgcolor-on-error: var(--neutral-0);
+
+ /* Border colors */
+ --border-neutral: var(--neutral-50);
+ --border-neutral-strong: var(--neutral-200);
+ --border-neutral-stronger: var(--neutral-500);
+ --border-focus: var(--neutral-300);
+ --border-accent: var(--brand-pink-500);
+ --border-warning-weak: rgba(254, 124, 67, 0.32);
+ --border-error: var(--web-red-500);
+ --border-error-weak: rgba(255, 69, 58, 0.32);
+
+ /* Overlay colors */
+ --overlay-neutral-hover: rgba(25, 25, 28, 0.03);
+ --overlay-neutral-pressed: rgba(25, 25, 28, 0.04);
+ --overlay-scrim: rgba(25, 25, 28, 0.8);
+
+ /* Icon sizes */
+ --icon-size-s: var(--base-16);
+
+ /* Typography */
+ --font-family-brand: 'Aeonik Pro';
+ --font-family-sansserif: 'Inter';
+ --font-family-code: 'Fira Code';
+ --sans-fallbacks: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --mono-fallbacks: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
+
+ --font-size-xs: 12px;
+ --font-size-s: 14px;
+ --font-size-m: 16px;
+ --font-size-l: 20px;
+ --label-line-height: 19.6px;
+
+ /* Motion */
+ --duration-fast: 150ms;
+ --duration-short: 160ms;
+ --duration-medium: 200ms;
+ --duration-extended: 250ms;
+ --duration-slow: 300ms;
+ --ease-standard: ease;
+ --ease-emphasized: cubic-bezier(0.32, 0.72, 0, 1);
+ --ease-in-out: ease-in-out;
+ --ease-out: ease-out;
+
+ /* Spacing */
+ --base-0: 0;
+ --base-2: 2px;
+ --base-4: 4px;
+ --base-6: 6px;
+ --base-8: 8px;
+ --base-10: 10px;
+ --base-12: 12px;
+ --base-16: 16px;
+ --base-20: 20px;
+ --base-24: 24px;
+ --base-32: 32px;
+ --base-40: 40px;
+ --base-48: 48px;
+
+ /* Component sizing */
+ --button-min-width-s: 60px;
+
+ --space-0: var(--base-0);
+ --space-1: var(--base-2);
+ --space-2: var(--base-4);
+ --space-3: var(--base-6);
+ --space-4: var(--base-8);
+ --space-5: var(--base-10);
+ --space-6: var(--base-12);
+ --space-7: var(--base-16);
+ --space-8: var(--base-20);
+ --space-9: var(--base-24);
+ --space-10: var(--base-32);
+ --space-11: var(--base-40);
+ --space-12: var(--base-48);
+
+ /* Gap scale (Pink tokens) */
+ --gap-none: var(--base-0);
+ --gap-xxxs: var(--base-2);
+ --gap-xxs: var(--base-4);
+ --gap-xs: var(--base-6);
+ --gap-s: var(--base-8);
+ --gap-m: var(--base-12);
+ --gap-l: var(--base-16);
+ --gap-xl: var(--base-20);
+ --gap-xxl: var(--base-32);
+ --gap-xxxl: var(--base-40);
+
+ /* Border radius */
+ --border-radius-s: 8px;
+ --border-radius-xs: 6px;
+ --border-radius-m: 12px;
+ --border-radius-l: 16px;
+
+ /* Border widths */
+ --border-width-s: 1px;
+ --border-width-l: 2px;
+
+ /* Installer layout vars */
+ --step-min-height: auto;
+ --divider-gap-top: var(--gap-xl);
+
+ /* Animation vars */
+ --spinner-rotation: 0deg;
+
+ /* Legacy aliases */
+ --bgColor-neutral-default: var(--bgcolor-neutral-default);
+ --bgColor-neutral-primary: var(--bgcolor-neutral-primary);
+ --bgColor-accent: var(--bgcolor-accent);
+ --fgColor-neutral-primary: var(--fgcolor-neutral-primary);
+ --fgColor-neutral-secondary: var(--fgcolor-neutral-secondary);
+ --fgColor-neutral-tertiary: var(--fgcolor-neutral-tertiary);
+ --fgColor-neutral-weak: var(--fgcolor-neutral-weak);
+ --fgColor-accent: var(--fgcolor-accent);
+ --fgColor-on-accent: var(--fgcolor-on-accent);
+}
+
+.installer-toast-stack {
+ position: fixed;
+ top: var(--base-12);
+ right: var(--base-12);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+ z-index: 60;
+ pointer-events: none;
+}
+
+.installer-toast {
+ inline-size: 24rem;
+ display: inline-flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: var(--space-6);
+ padding: var(--space-4);
+ border-radius: var(--border-radius-m);
+ border: var(--border-width-s) solid var(--border-neutral);
+ background: var(--bgcolor-neutral-primary);
+ box-shadow:
+ 0 2px 12px 0 rgba(0, 0, 0, 0.02),
+ 0 6px 8px 0 rgba(0, 0, 0, 0.02);
+ pointer-events: auto;
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ will-change: transform, opacity;
+ transition:
+ transform 400ms cubic-bezier(0.33, 1, 0.68, 1),
+ opacity 400ms cubic-bezier(0.33, 1, 0.68, 1);
+}
+
+.installer-toast.is-entering {
+ opacity: 0;
+ transform: translate3d(50px, 0, 0);
+}
+
+.installer-toast.is-leaving {
+ opacity: 0;
+ transform: translate3d(50px, 0, 0);
+ pointer-events: none;
+}
+
+.installer-toast-content {
+ display: flex;
+ align-items: start;
+ gap: var(--space-6);
+}
+
+.installer-toast-body {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-block: auto;
+}
+
+.installer-toast-icon {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--border-radius-s);
+ background: var(--bgcolor-neutral-invert-weaker);
+ color: var(--fgcolor-on-invert);
+ flex-shrink: 0;
+}
+
+.installer-toast-icon svg {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.installer-toast-icon[data-status='error'] {
+ background: var(--bgcolor-error);
+ color: var(--fgcolor-on-error);
+}
+
+.installer-toast-close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-3);
+ border: var(--border-width-s) solid transparent;
+ border-radius: var(--border-radius-s);
+ background: transparent;
+ color: var(--fgcolor-neutral-tertiary);
+ cursor: pointer;
+ transition: all 0.15s ease-in-out;
+}
+
+.installer-toast-close svg {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.installer-toast-close:hover {
+ color: var(--fgcolor-neutral-secondary);
+ background: var(--overlay-neutral-hover);
+}
+
+.installer-toast-close:active {
+ color: var(--fgcolor-neutral-secondary);
+ background: var(--overlay-neutral-pressed);
+}
+
+.installer-toast-close:focus-visible {
+ outline: var(--border-width-l) solid var(--border-focus);
+ outline-offset: var(--border-width-s);
+}
+
+.is-hidden {
+ display: none !important;
+}
+
+@media (min-width: 768px) {
+ .installer-toast-stack {
+ top: var(--base-24);
+ right: var(--base-24);
+ }
+}
+
+@media (max-width: 640px) {
+ .installer-toast-stack {
+ left: 0;
+ right: 0;
+ top: var(--base-12);
+ padding: 0 var(--space-4);
+ align-items: stretch;
+ }
+
+ .installer-toast {
+ inline-size: 100%;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ color: var(--fgcolor-neutral-primary);
+ background: var(--bgcolor-neutral-default);
+ font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif;
+}
+
+.installer-page {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-7);
+ gap: var(--gap-l);
+ background: var(--bgcolor-neutral-default);
+ position: relative;
+ overflow: hidden;
+}
+
+.installer-main {
+ flex: 1;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ z-index: 1;
+ min-height: 0;
+}
+
+.installer-backdrop {
+ position: fixed;
+ inset: 0;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--duration-medium) var(--ease-standard);
+ z-index: 0;
+}
+
+.installer-page[data-step='5'] .installer-backdrop {
+ opacity: 1;
+}
+
+.installer-gradients {
+ top: 34%;
+ left: 17%;
+ width: 409px;
+ height: 194px;
+ opacity: 0.32;
+ position: absolute;
+ pointer-events: none;
+ transform: translateY(-50%) rotate(-25deg);
+}
+
+.installer-blob {
+ position: absolute;
+ left: 0;
+ top: 0;
+ --blob-x: 0;
+ --blob-y: 0;
+ transform: translate(var(--blob-x), var(--blob-y)) scale(1);
+ transform-origin: center;
+ animation: none;
+}
+
+.installer-blob.blob-one {
+ --blob-x: 268.3px;
+ --blob-y: 108.3px;
+}
+
+.installer-blob.blob-two {
+ --blob-x: 101.3px;
+ --blob-y: 101.3px;
+}
+
+.installer-card {
+ width: 100%;
+ max-width: 500px;
+ max-height: 100%;
+ padding: var(--space-8);
+ background: var(--bgcolor-neutral-primary);
+ border-radius: var(--border-radius-l);
+ outline: var(--border-width-s) solid var(--border-neutral);
+ outline-offset: -1px;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ transition: opacity var(--duration-medium) var(--ease-standard);
+ overflow: hidden;
+}
+
+.installer-page[data-step='5'] .installer-card {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.installer-page[data-install-locked='true'] .step-indicators {
+ opacity: 0.4;
+}
+
+.selector-card.is-disabled {
+ cursor: default;
+ pointer-events: auto;
+}
+
+.selector-card.is-disabled .selector-content,
+.selector-card.is-disabled .selector-icon {
+ opacity: 0.4;
+}
+
+.installer-step {
+ display: grid;
+ position: relative;
+ width: 100%;
+ min-height: var(--step-min-height, auto);
+ flex: 1 1 auto;
+ overflow: hidden;
+}
+
+.action-shell {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin-top: auto;
+ flex-shrink: 0;
+}
+
+.install-screen {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity var(--duration-medium) var(--ease-standard);
+ z-index: 1;
+}
+
+.installer-page[data-step='5'] .install-screen {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.install-screen-content {
+ width: 100%;
+ max-width: 500px;
+}
+
+.step-panel {
+ grid-area: 1 / 1;
+ width: 100%;
+ opacity: 1;
+ transition: opacity var(--duration-medium) var(--ease-standard);
+ will-change: opacity;
+}
+
+.step-panel:not(.is-measure) {
+ height: 100%;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.step-panel:not(.is-measure)::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+}
+
+.step-panel.is-entering {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.step-panel.is-exiting {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.step-panel.is-measure {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+}
+
+.stack-none {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-none);
+}
+
+.stack-xxxs {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-xxxs);
+}
+
+.stack-xxs {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-xxs);
+}
+
+.stack-xs {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-xs);
+}
+
+.stack-s {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-s);
+}
+
+.stack-m {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-m);
+}
+
+.stack-l {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-l);
+}
+
+.stack-xl {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-xl);
+}
+
+.stack-xxl {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-xxl);
+}
+
+.stack-xxxl {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-xxxl);
+}
+
+.install-layout {
+ width: 100%;
+}
+
+.install-card {
+ width: 100%;
+ padding: var(--space-2);
+ background: var(--bgcolor-neutral-default);
+ border-radius: var(--border-radius-l);
+ border: var(--border-width-s) solid var(--border-neutral);
+}
+
+.install-panel {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--gap-xxxs);
+ transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+.install-header {
+ padding: var(--space-4);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--gap-xxxs);
+}
+
+.install-list {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0;
+}
+
+.install-row {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ padding: 0;
+ background: var(--bgcolor-neutral-primary);
+ border: var(--border-width-s) solid var(--border-neutral);
+ border-radius: 0;
+ overflow: hidden;
+ opacity: 1;
+ transform: translateY(0);
+ transition: opacity 0.2s ease,
+ transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
+ will-change: transform, opacity;
+}
+
+.install-row-main {
+ display: flex;
+ align-items: center;
+ gap: var(--gap-s);
+ height: 44px;
+ padding: 0 var(--space-6);
+ transition: height 0.3s cubic-bezier(0.32, 0.72, 0, 1),
+ opacity 0.2s ease,
+ transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+.install-row-label {
+ display: flex;
+ align-items: center;
+ gap: var(--gap-s);
+ min-width: 0;
+ min-height: 0;
+ padding-block-start: 0;
+}
+
+.install-text {
+ min-width: 0;
+ display: inline-flex;
+ transition: opacity 0.2s ease, transform 0.2s ease;
+}
+
+.install-text.is-enter {
+ opacity: 0;
+ transform: translateY(10px);
+}
+
+.install-row-toggle {
+ margin-left: auto;
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: var(--border-radius-xs);
+ background: transparent;
+ color: var(--fgcolor-neutral-secondary);
+ display: none;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ opacity: 0;
+ pointer-events: none;
+ transition: background-color var(--duration-short) var(--ease-standard),
+ color var(--duration-short) var(--ease-standard),
+ transform var(--duration-short) var(--ease-standard);
+}
+
+.install-row-toggle svg {
+ width: 20px;
+ height: 20px;
+}
+
+
+.install-row-details {
+ max-height: none;
+ opacity: 0;
+ overflow: hidden;
+ display: grid;
+ grid-template-rows: 0fr;
+ padding: 0;
+ border: none;
+ transition: grid-template-rows var(--duration-medium) var(--ease-standard),
+ opacity var(--duration-medium) var(--ease-standard);
+}
+
+.install-row:first-child {
+ border-top-left-radius: var(--border-radius-m);
+ border-top-right-radius: var(--border-radius-m);
+}
+
+.install-row + .install-row {
+ margin-top: -1px;
+}
+
+.install-row:last-child {
+ border-bottom-left-radius: var(--border-radius-m);
+ border-bottom-right-radius: var(--border-radius-m);
+}
+
+.install-row.is-entering {
+ opacity: 0;
+ transform: translateY(-20px);
+}
+
+.install-row.is-entering .install-row-main {
+ height: 0;
+ opacity: 0;
+ transform: translateY(-20px);
+}
+
+.install-icon {
+ width: 16px;
+ height: 16px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+}
+
+.install-icon-spinner,
+.install-icon-check {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ inset: 0;
+ transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+.install-icon-error {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ transition: opacity 200ms cubic-bezier(0.32, 0.72, 0, 1);
+ color: var(--fgcolor-error);
+}
+
+.install-icon-check {
+ opacity: 0;
+}
+
+.install-icon-spinner svg {
+ animation: none;
+ transform: rotate(var(--spinner-rotation));
+}
+
+.install-row[data-status='completed'] .install-icon-spinner {
+ opacity: 0;
+}
+
+.install-row[data-status='completed'] .install-icon-spinner svg {
+ animation: none;
+}
+
+.install-row[data-status='completed'] .install-icon-check {
+ opacity: 1;
+}
+
+/* Keep the checkmark subtle: rely on container fade only (no stroke drawing). */
+
+.install-row[data-status='error'] {
+ height: auto;
+ overflow: hidden;
+}
+
+.install-row[data-status='error'] .install-icon-spinner,
+.install-row[data-status='error'] .install-icon-check {
+ opacity: 0;
+}
+
+.install-row[data-status='error'] .install-icon-error {
+ opacity: 1;
+}
+
+.install-row[data-status='error'] .install-row-toggle {
+ opacity: 1;
+ pointer-events: auto;
+ display: inline-flex;
+}
+
+.install-row[data-status='error'] {
+ cursor: pointer;
+}
+
+.install-row[data-status='error'] .button {
+ cursor: pointer;
+}
+
+.install-row.is-open .install-row-toggle {
+ transform: rotate(180deg);
+}
+
+.install-row.is-open .install-row-details {
+ grid-template-rows: 1fr;
+ opacity: 1;
+}
+
+.install-row-details-inner {
+ position: relative;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ border-top: var(--border-width-s) solid var(--border-neutral);
+ border-left: none;
+ border-right: none;
+ border-radius: 0;
+ background: var(--bgcolor-neutral-primary);
+}
+
+.install-error-code {
+ margin: 0;
+ padding: var(--space-4) var(--space-6);
+ width: 100%;
+ background: var(--bgcolor-neutral-default);
+ border: none;
+ border-radius: 0;
+ font-family: var(--font-family-code), var(--mono-fallbacks), monospace;
+ font-size: var(--font-size-xs);
+ line-height: 140%;
+ letter-spacing: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: break-word;
+ max-height: 160px;
+ overflow-y: auto;
+ overflow-x: auto;
+ color: var(--fgcolor-neutral-primary);
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.install-error-code::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+}
+
+
+.install-error-actions {
+ padding: var(--space-3) var(--space-6);
+ background: var(--bgcolor-neutral-primary);
+ border-top: var(--border-width-s) solid var(--border-neutral);
+ border-radius: 0 0 var(--border-radius-m) var(--border-radius-m);
+ display: flex;
+ justify-content: flex-end;
+ flex-direction: row;
+ gap: var(--gap-m);
+}
+
+.install-error-details .button {
+ align-self: center;
+ margin-top: 0;
+}
+
+@keyframes install-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+.typography-title-s {
+ font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif;
+ font-size: var(--font-size-l);
+ font-weight: 400;
+ line-height: 130%;
+ letter-spacing: -0.144px;
+}
+
+.typography-title-s,
+.typography-text-m-400,
+.typography-text-m-500 {
+ margin: 0;
+}
+
+.typography-text-m-400 {
+ font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif;
+ font-size: var(--font-size-s);
+ font-weight: 400;
+ line-height: 140%;
+ letter-spacing: -0.063px;
+}
+
+.typography-text-m-500 {
+ font-family: var(--font-family-brand), var(--sans-fallbacks), sans-serif;
+ font-size: var(--font-size-s);
+ font-weight: 500;
+ line-height: 140%;
+ letter-spacing: -0.063px;
+}
+
+.typography-text-xs-400 {
+ font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif;
+ font-size: var(--font-size-xs);
+ font-weight: 400;
+ line-height: 130%;
+ letter-spacing: -0.12px;
+}
+
+.typography-text-xs-500 {
+ font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif;
+ font-size: var(--font-size-xs);
+ font-weight: 500;
+ line-height: 130%;
+ letter-spacing: -0.12px;
+}
+
+.typography-caption-400 {
+ font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif;
+ font-size: var(--font-size-xs);
+ font-weight: 400;
+ line-height: 140%;
+ letter-spacing: -0.063px;
+}
+
+.typography-caption-500 {
+ font-family: var(--font-family-sansserif), var(--sans-fallbacks), sans-serif;
+ font-size: var(--font-size-xs);
+ font-weight: 500;
+ line-height: 140%;
+ letter-spacing: -0.063px;
+}
+
+.label-text {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.label-optional {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 var(--space-1);
+}
+
+.label-info-button {
+ width: 16px;
+ height: 16px;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--fgcolor-neutral-secondary);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: default;
+}
+
+.label-info-button svg {
+ width: 16px;
+ height: 16px;
+}
+
+.text-neutral-primary {
+ color: var(--fgcolor-neutral-primary);
+}
+
+.text-neutral-secondary {
+ color: var(--fgcolor-neutral-secondary);
+}
+
+.text-neutral-tertiary {
+ color: var(--fgcolor-neutral-tertiary);
+}
+
+.text-warning {
+ color: var(--fgcolor-warning);
+}
+
+.text-error {
+ color: var(--fgcolor-error);
+}
+
+.text-on-success-weak {
+ color: var(--fgcolor-on-success-weak);
+}
+
+.text-on-invert {
+ color: var(--fgcolor-on-invert);
+}
+
+.input-group {
+ width: 100%;
+}
+
+.field-error {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ color: var(--fgcolor-error);
+ max-height: 0;
+ opacity: 0;
+ overflow: hidden;
+ /*padding-inline-start: var(--space-2);*/
+ transition: max-height var(--duration-extended) var(--ease-standard),
+ opacity var(--duration-extended) var(--ease-standard);
+}
+
+.field-error-icon {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+}
+
+.field-error-icon svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+.field-error.is-visible {
+ opacity: 1;
+ max-height: 64px;
+}
+
+.field-helper {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ color: var(--fgcolor-neutral-secondary);
+}
+
+.field-helper-icon {
+ display: flex;
+ flex-shrink: 0;
+ align-items: center;
+}
+
+.field-helper-icon svg {
+ width: 1rem;
+ height: 1rem;
+}
+
+.input-field {
+ width: 100%;
+ padding: var(--space-3) var(--space-6);
+ background: var(--bgcolor-neutral-default);
+ border: var(--border-width-s) solid var(--border-neutral);
+ outline: none;
+ border-radius: var(--border-radius-s);
+ transition: all var(--duration-fast) var(--ease-in-out);
+}
+
+.input-field:focus {
+ border-color: var(--border-focus);
+ box-shadow: inset 0 0 0 1px var(--border-focus);
+}
+
+.input-field.is-error {
+ border-color: var(--border-error);
+ box-shadow: none;
+}
+
+.input-field.is-error:focus {
+ border-color: var(--border-error);
+ box-shadow: inset 0 0 0 1px var(--border-error);
+}
+
+.input-field::placeholder {
+ color: var(--fgcolor-neutral-tertiary);
+}
+
+.input-field[type='number'] {
+ appearance: textfield;
+ -moz-appearance: textfield;
+}
+
+.input-field[type='number']::-webkit-outer-spin-button,
+.input-field[type='number']::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+.selector-group {
+ display: flex;
+ gap: var(--gap-m);
+ width: 100%;
+ position: relative;
+ overflow: visible;
+}
+
+.selector-card {
+ position: relative;
+ flex: 1;
+ min-height: 52px;
+ display: flex;
+ align-items: center;
+ gap: var(--gap-s);
+ padding: var(--space-4) var(--space-6);
+ background: var(--bgcolor-neutral-default);
+ border: none;
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral);
+ border-radius: var(--border-radius-s);
+ cursor: pointer;
+ overflow: hidden;
+ transition: all var(--duration-fast) var(--ease-in-out);
+}
+
+.selector-card.has-tooltip {
+ overflow: visible;
+}
+
+.selector-card::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--overlay-neutral-hover);
+ opacity: 0;
+ transition: opacity var(--duration-fast) var(--ease-in-out);
+}
+
+.selector-card:hover::before,
+.selector-card.selected::before {
+ opacity: 1;
+}
+
+.selector-card.is-disabled::before,
+.selector-card.is-disabled:hover::before {
+ opacity: 0;
+}
+
+.selector-group.is-locked .selector-card {
+ cursor: default;
+}
+
+.selector-group.is-locked .selector-card:hover::before {
+ opacity: 0;
+}
+
+.selector-card.has-tooltip .tooltip {
+ top: calc(100% + 6px);
+ bottom: auto;
+ transform: translateX(-50%) translateY(-8px);
+}
+
+.selector-card.has-tooltip {
+ z-index: 0;
+}
+
+.selector-card.has-tooltip:hover {
+ z-index: 3;
+}
+
+.selector-card.has-tooltip:hover .tooltip,
+.selector-card.has-tooltip:focus-within .tooltip {
+ opacity: 1;
+ visibility: visible;
+ transform: translateX(-50%) translateY(0);
+ transition: opacity var(--duration-short) var(--ease-standard),
+ transform var(--duration-short) var(--ease-standard),
+ visibility 0s;
+}
+
+.tooltip-db-locked {
+ width: 193px;
+ max-width: none;
+ min-width: 193px;
+ text-align: center;
+}
+.selector-card.selected {
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger);
+}
+
+.selector-card:focus-within {
+ outline: none;
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral),
+ inset 0 0 0 var(--border-width-l) var(--border-focus);
+}
+
+.selector-card.selected:focus-within {
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-stronger),
+ inset 0 0 0 var(--border-width-l) var(--border-focus);
+}
+
+.selector-content {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ flex: 1;
+}
+
+.selector-icon {
+ position: relative;
+ width: 32px;
+ height: 32px;
+ flex-shrink: 0;
+}
+
+.accordion {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.accordion-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--gap-s);
+ width: 100%;
+ padding: 0;
+ border: none;
+ border-radius: var(--border-radius-s);
+ background: transparent;
+ cursor: pointer;
+ transition: background var(--duration-fast) var(--ease-in-out);
+ font-family: inherit;
+ font-size: inherit;
+ font-weight: inherit;
+ line-height: inherit;
+ letter-spacing: inherit;
+}
+
+.accordion-toggle:focus-visible {
+ outline: var(--border-width-l) solid var(--border-focus);
+}
+
+.accordion-chevron {
+ width: 20px;
+ height: 20px;
+ transition: transform var(--duration-slow) var(--ease-in-out);
+ color: var(--fgcolor-neutral-tertiary);
+}
+
+.accordion-chevron[data-open='true'] {
+ transform: rotate(180deg);
+}
+
+.accordion-content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gap-l);
+ width: 100%;
+ max-height: 0;
+ opacity: 0;
+ overflow: hidden;
+ transition: max-height var(--duration-medium) var(--ease-out),
+ opacity var(--duration-medium) var(--ease-out);
+}
+
+.accordion-content.open {
+ opacity: 1;
+ overflow: visible;
+ margin-top: var(--gap-m);
+}
+
+.divider {
+ width: 100%;
+ height: 1px;
+ background: var(--border-neutral);
+ margin-top: var(--divider-gap-top, var(--gap-xl));
+ margin-bottom: var(--gap-xl);
+}
+
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--gap-xs);
+ padding: var(--space-3) var(--space-5);
+ min-height: var(--space-10);
+ min-width: var(--button-min-width-s);
+ border-radius: var(--border-radius-s);
+ border: none;
+ box-shadow: inset 0 0 0 var(--border-width-s) transparent;
+ cursor: pointer;
+ transition: all var(--duration-fast) var(--ease-in-out);
+ outline-offset: var(--border-width-s);
+ background: transparent;
+ color: var(--fgcolor-neutral-secondary);
+}
+
+.button-text {
+ display: inline-flex;
+ align-items: center;
+}
+
+.button:focus-visible {
+ outline: var(--border-width-l) solid var(--border-focus);
+}
+
+.button.primary {
+ background: var(--bgcolor-accent);
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent);
+ color: var(--fgcolor-on-accent);
+}
+
+.button.primary:hover {
+ background: var(--bgcolor-accent-secondary);
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent-secondary);
+}
+
+.button.primary:active {
+ background: var(--bgcolor-accent-tertiary);
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-accent-tertiary);
+}
+
+.button.primary:disabled {
+ background: var(--bgcolor-neutral-invert-weaker);
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--bgcolor-neutral-invert-weaker);
+}
+
+.button.secondary {
+ background: var(--bgcolor-neutral-primary);
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral);
+ color: var(--fgcolor-neutral-primary);
+}
+
+.button.secondary:hover {
+ background: var(--bgcolor-neutral-secondary);
+}
+
+.button.secondary:active {
+ background: var(--bgcolor-neutral-tertiary);
+}
+
+.button.secondary:disabled {
+ box-shadow: inset 0 0 0 var(--border-width-s) var(--border-neutral-strong);
+}
+
+.button:disabled {
+ opacity: 0.4;
+ pointer-events: none;
+}
+
+.action-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--gap-xl);
+ width: 100%;
+ flex-wrap: nowrap;
+}
+
+.step-indicators {
+ display: flex;
+ align-items: center;
+ line-height: 0;
+}
+
+.step-indicator {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 0;
+}
+
+.step-indicator svg {
+ display: block;
+}
+
+.step-indicator .indicator-active {
+ display: none;
+}
+
+.step-indicator.is-active .indicator-active {
+ display: inline-flex;
+}
+
+.step-indicator.is-active .indicator-inactive {
+ display: none;
+}
+
+.step-indicator.is-hidden {
+ display: none;
+}
+
+.step-layout {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.inline-alert {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: var(--gap-m);
+ width: 100%;
+ padding: var(--space-6);
+ background: var(--bgcolor-neutral-default);
+ border-radius: var(--border-radius-s);
+ outline: var(--border-width-s) solid var(--border-neutral-strong);
+ outline-offset: -1px;
+ --alert-primary-color: var(--fgcolor-neutral-secondary);
+}
+
+.inline-alert--warning {
+ background: var(--bgcolor-warning-weaker);
+ outline-color: var(--border-warning-weak);
+ --alert-primary-color: var(--fgcolor-warning);
+}
+
+.inline-alert-content {
+ display: inline-flex;
+ align-items: flex-start;
+ gap: var(--gap-s);
+ align-self: stretch;
+}
+
+.inline-alert-icon {
+ width: 20px;
+ height: 20px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--alert-primary-color);
+ flex-shrink: 0;
+}
+
+.inline-alert-text {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.input-action {
+ display: flex;
+ align-items: center;
+ gap: var(--gap-l);
+ width: 100%;
+ padding: var(--space-3) var(--space-5) var(--space-3) var(--space-6);
+ background: var(--bgcolor-neutral-default);
+ border-radius: var(--border-radius-s);
+ border: var(--border-width-s) solid var(--border-neutral);
+ outline: none;
+ transition: all var(--duration-fast) var(--ease-in-out);
+}
+
+.input-action:focus-within {
+ border-color: var(--border-focus);
+ box-shadow: inset 0 0 0 1px var(--border-focus);
+}
+
+.input-action.is-error {
+ border-color: var(--border-error);
+ box-shadow: none;
+}
+
+.input-action.is-error:focus-within {
+ border-color: var(--border-error);
+ box-shadow: inset 0 0 0 1px var(--border-error);
+}
+
+.input-action-input {
+ flex: 1;
+ border: none;
+ background: transparent;
+ padding: 0;
+ outline: none;
+}
+
+.input-action-buttons {
+ display: flex;
+ align-items: center;
+ gap: var(--gap-m);
+}
+
+.tooltip-wrapper {
+ position: relative;
+ display: inline-flex;
+}
+
+.tooltip {
+ position: absolute;
+ left: 50%;
+ bottom: calc(100% + 6px);
+ transform: translateX(-50%) translateY(8px);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: max-content;
+ max-width: 11.25rem;
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--border-radius-s);
+ background: var(--bgcolor-neutral-invert-weak);
+ color: var(--fgcolor-on-invert);
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: opacity var(--duration-short) var(--ease-standard),
+ transform var(--duration-short) var(--ease-standard),
+ visibility 0s linear var(--duration-short);
+ z-index: 5;
+}
+
+.tooltip.is-open {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+ transition: opacity var(--duration-short) var(--ease-standard),
+ transform var(--duration-short) var(--ease-standard),
+ visibility 0s;
+}
+
+.tooltip-portal {
+ position: fixed;
+ left: 0;
+ top: 0;
+ bottom: auto;
+ transform: translateY(8px);
+}
+
+.tooltip-assistant {
+ width: 246px;
+ max-width: 246px;
+ text-align: left;
+}
+
+.tooltip-wrapper:hover .tooltip,
+.tooltip-wrapper:focus-within .tooltip,
+.tooltip-wrapper.is-open .tooltip {
+ opacity: 1;
+ visibility: visible;
+ transform: translateX(-50%) translateY(0);
+ transition: opacity var(--duration-short) var(--ease-standard),
+ transform var(--duration-short) var(--ease-standard),
+ visibility 0s;
+}
+
+.input-icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--fgcolor-neutral-secondary);
+ cursor: pointer;
+ border-radius: var(--border-radius-xs);
+}
+
+.input-icon-button svg {
+ width: 16px;
+ height: 16px;
+ display: block;
+}
+
+.input-icon-button:hover {
+ background: var(--overlay-neutral-hover);
+}
+
+.input-icon-button:active {
+ background: var(--overlay-neutral-pressed);
+}
+
+.password-toggle-icon {
+ display: inline-flex;
+}
+
+.password-toggle [data-password-icon="hide"] {
+ display: none;
+}
+
+.password-toggle.is-visible [data-password-icon="show"] {
+ display: none;
+}
+
+.password-toggle.is-visible [data-password-icon="hide"] {
+ display: inline-flex;
+}
+
+.icon-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-3);
+ background: var(--bgcolor-neutral-primary);
+ border-radius: var(--border-radius-s);
+ outline: var(--border-width-s) solid var(--border-neutral-strong);
+ outline-offset: -1px;
+ border: none;
+ color: var(--fgcolor-neutral-tertiary);
+ cursor: pointer;
+}
+
+.icon-button.is-rotating svg {
+ animation: installer-rotate-once 0.35s linear;
+}
+
+.input-row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-4);
+ width: 100%;
+}
+
+.input-row .input-group {
+ flex: 1;
+}
+
+.input-row .button {
+ margin-top: calc(var(--label-line-height) + var(--space-3));
+}
+
+.input-row .icon-button {
+ margin-top: calc(var(--label-line-height) + var(--space-3));
+}
+
+.review-card {
+ width: 100%;
+ padding: var(--space-6);
+ background: var(--bgcolor-neutral-default);
+ border-radius: var(--border-radius-m);
+ outline: var(--border-width-s) solid var(--border-neutral);
+ outline-offset: -1px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+}
+
+.review-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--gap-m);
+}
+
+.review-label {
+ text-align: right;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-1) var(--space-2);
+ border-radius: 6px;
+}
+
+.badge-success {
+ background: var(--bgcolor-success-weak);
+ color: var(--fgcolor-on-success-weak);
+}
+
+.badge-warning {
+ background: var(--bgcolor-warning-weak);
+ color: var(--fgcolor-on-warning-weak);
+}
+
+.badge-neutral {
+ background: var(--bgcolor-neutral-tertiary);
+ color: var(--fgcolor-neutral-secondary);
+}
+
+.installer-footer {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ padding-bottom: var(--space-7);
+ position: relative;
+ z-index: 1;
+}
+
+.appwrite-logo {
+ width: 131px;
+ height: 25px;
+ position: relative;
+}
+
+.appwrite-logo svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ * {
+ transition: none !important;
+ animation: none !important;
+ }
+}
+
+@keyframes installer-rotate-once {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (max-width: 640px) {
+ .installer-page {
+ padding: var(--space-6);
+ gap: var(--gap-m);
+ }
+
+ .installer-card {
+ padding: var(--space-6);
+ }
+
+ .installer-step {
+ min-height: 0;
+ }
+
+ .selector-group {
+ flex-direction: column;
+ }
+
+ .input-row {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .input-row .button {
+ align-self: flex-start;
+ margin-top: 0;
+ }
+
+ .input-row .icon-button {
+ margin-top: 0;
+ }
+
+ .step-layout[data-step='2'] .input-row {
+ flex-direction: row;
+ align-items: flex-end;
+ }
+
+ .action-bar {
+ gap: var(--gap-s);
+ }
+}
diff --git a/app/views/install/installer/icons/appwrite-logo.svg b/app/views/install/installer/icons/appwrite-logo.svg
new file mode 100644
index 0000000000..f0f2fa5af0
--- /dev/null
+++ b/app/views/install/installer/icons/appwrite-logo.svg
@@ -0,0 +1,13 @@
+
diff --git a/app/views/install/installer/icons/appwrite-mark.svg b/app/views/install/installer/icons/appwrite-mark.svg
new file mode 100644
index 0000000000..7dff2b5fc4
--- /dev/null
+++ b/app/views/install/installer/icons/appwrite-mark.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/views/install/installer/icons/chevron-down.svg b/app/views/install/installer/icons/chevron-down.svg
new file mode 100644
index 0000000000..74d9c14cfd
--- /dev/null
+++ b/app/views/install/installer/icons/chevron-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/icons/copy.svg b/app/views/install/installer/icons/copy.svg
new file mode 100644
index 0000000000..008d89098a
--- /dev/null
+++ b/app/views/install/installer/icons/copy.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/views/install/installer/icons/exclamation-circle.svg b/app/views/install/installer/icons/exclamation-circle.svg
new file mode 100644
index 0000000000..3416951e48
--- /dev/null
+++ b/app/views/install/installer/icons/exclamation-circle.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/icons/eye-off.svg b/app/views/install/installer/icons/eye-off.svg
new file mode 100644
index 0000000000..9c0b0a063b
--- /dev/null
+++ b/app/views/install/installer/icons/eye-off.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/views/install/installer/icons/eye.svg b/app/views/install/installer/icons/eye.svg
new file mode 100644
index 0000000000..897b425bf3
--- /dev/null
+++ b/app/views/install/installer/icons/eye.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/views/install/installer/icons/indicator-active.svg b/app/views/install/installer/icons/indicator-active.svg
new file mode 100644
index 0000000000..e620bdf6e9
--- /dev/null
+++ b/app/views/install/installer/icons/indicator-active.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/icons/indicator-inactive.svg b/app/views/install/installer/icons/indicator-inactive.svg
new file mode 100644
index 0000000000..78b5ef3ac0
--- /dev/null
+++ b/app/views/install/installer/icons/indicator-inactive.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/icons/info.svg b/app/views/install/installer/icons/info.svg
new file mode 100644
index 0000000000..6f0524fb49
--- /dev/null
+++ b/app/views/install/installer/icons/info.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/icons/install-bg-1.svg b/app/views/install/installer/icons/install-bg-1.svg
new file mode 100644
index 0000000000..7291974bee
--- /dev/null
+++ b/app/views/install/installer/icons/install-bg-1.svg
@@ -0,0 +1,16 @@
+
diff --git a/app/views/install/installer/icons/install-bg-2.svg b/app/views/install/installer/icons/install-bg-2.svg
new file mode 100644
index 0000000000..9e49ddb99f
--- /dev/null
+++ b/app/views/install/installer/icons/install-bg-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/app/views/install/installer/icons/install-check.svg b/app/views/install/installer/icons/install-check.svg
new file mode 100644
index 0000000000..7d43d007c6
--- /dev/null
+++ b/app/views/install/installer/icons/install-check.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/views/install/installer/icons/install-spinner.svg b/app/views/install/installer/icons/install-spinner.svg
new file mode 100644
index 0000000000..543d2c4b22
--- /dev/null
+++ b/app/views/install/installer/icons/install-spinner.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/views/install/installer/icons/lock.svg b/app/views/install/installer/icons/lock.svg
new file mode 100644
index 0000000000..6a87c21345
--- /dev/null
+++ b/app/views/install/installer/icons/lock.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/icons/mariadb.svg b/app/views/install/installer/icons/mariadb.svg
new file mode 100644
index 0000000000..921fbbb0dc
--- /dev/null
+++ b/app/views/install/installer/icons/mariadb.svg
@@ -0,0 +1,10 @@
+
diff --git a/app/views/install/installer/icons/mongodb.svg b/app/views/install/installer/icons/mongodb.svg
new file mode 100644
index 0000000000..5470eccb38
--- /dev/null
+++ b/app/views/install/installer/icons/mongodb.svg
@@ -0,0 +1,6 @@
+
diff --git a/app/views/install/installer/icons/refresh.svg b/app/views/install/installer/icons/refresh.svg
new file mode 100644
index 0000000000..56f70b425a
--- /dev/null
+++ b/app/views/install/installer/icons/refresh.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/icons/step-dot.svg b/app/views/install/installer/icons/step-dot.svg
new file mode 100644
index 0000000000..490a039d20
--- /dev/null
+++ b/app/views/install/installer/icons/step-dot.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/icons/warning.svg b/app/views/install/installer/icons/warning.svg
new file mode 100644
index 0000000000..131b29171c
--- /dev/null
+++ b/app/views/install/installer/icons/warning.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/views/install/installer/js/constants.js b/app/views/install/installer/js/constants.js
new file mode 100644
index 0000000000..940c0bc780
--- /dev/null
+++ b/app/views/install/installer/js/constants.js
@@ -0,0 +1,11 @@
+(() => {
+ window.InstallerConstants = Object.freeze({
+ stepTransitionMs: 260,
+ errorClearMs: 180,
+ installPollIntervalMs: 4000,
+ installFallbackDelayMs: 12000,
+ redirectDelayMs: 2500,
+ progressTransitionDelayMs: 320,
+ progressCompleteDelayMs: 140,
+ });
+})();
diff --git a/app/views/install/installer/js/installer.js b/app/views/install/installer/js/installer.js
new file mode 100644
index 0000000000..463b7f6221
--- /dev/null
+++ b/app/views/install/installer/js/installer.js
@@ -0,0 +1,450 @@
+(() => {
+ const stepContainer = document.querySelector('.installer-step');
+ const installerCard = document.querySelector('.installer-card');
+ const backButton = document.querySelector('[data-action="back"]');
+ const nextButton = document.querySelector('[data-action="next"]');
+ const installScreen = document.querySelector('.install-screen-content');
+ const indicatorNodes = Array.from(document.querySelectorAll('.step-indicator'));
+ const STEP_TRANSITION_TIMEOUT = window.InstallerConstants?.stepTransitionMs ?? 260;
+
+ if (!stepContainer || !installerCard) return;
+
+ const { validateInstallRequest } = window.InstallerStepsProgress || {};
+
+ const isUpgrade = document.body?.dataset.upgrade === 'true';
+ const stepFlow = isUpgrade ? [1, 4, 5] : [1, 2, 3, 4, 5];
+ const cardSteps = stepFlow.filter((step) => step !== 5);
+
+ const normalizeStep = (step) => {
+ const numeric = clampStep(step);
+ if (stepFlow.includes(numeric)) return numeric;
+ if (numeric <= stepFlow[0]) return stepFlow[0];
+ for (let i = 0; i < stepFlow.length; i += 1) {
+ if (numeric < stepFlow[i]) {
+ return stepFlow[i];
+ }
+ }
+ return stepFlow[stepFlow.length - 1];
+ };
+
+ const buildStepConfig = () => {
+ const config = {};
+ stepFlow.forEach((step, index) => {
+ if (step === 5) {
+ config[step] = { back: { target: null }, next: { target: null } };
+ return;
+ }
+ const prev = stepFlow[index - 1] ?? null;
+ const next = stepFlow[index + 1] ?? null;
+ const label = next === 5 ? (isUpgrade ? 'Update' : 'Install') : 'Next';
+ config[step] = {
+ back: { target: prev },
+ next: { label, target: next }
+ };
+ });
+ return config;
+ };
+
+ const STEP_CONFIG = buildStepConfig();
+
+ const stepCache = new Map();
+ let maxStepHeight = 0;
+ let isTransitioning = false;
+ let pendingStep = null;
+ let pendingPushState = false;
+
+ const clampStep = (step) => Math.max(1, Math.min(5, step));
+ const isInstallLocked = () => Boolean(window.InstallerSteps?.isInstallLocked?.());
+
+ const scrollToFirstError = (panel) => {
+ if (!panel) return;
+ const getErrorNode = () => panel.querySelector('.field-error.is-visible')
+ || panel.querySelector('.field-error')
+ || panel.querySelector('.input-field.is-error, .input-action.is-error');
+ const container = panel.closest('.step-panel') || panel;
+ const attemptScroll = () => {
+ const target = getErrorNode();
+ if (!target || typeof target.getBoundingClientRect !== 'function') return false;
+ const targetRect = target.getBoundingClientRect();
+ const containerRect = container.getBoundingClientRect();
+ const targetTop = targetRect.top - containerRect.top + container.scrollTop;
+ const targetBottom = targetTop + targetRect.height;
+ const viewTop = container.scrollTop;
+ const viewBottom = viewTop + containerRect.height;
+ const padding = 12;
+
+ let nextScrollTop = viewTop;
+ if (targetTop < viewTop + padding) {
+ nextScrollTop = Math.max(0, targetTop - padding);
+ } else if (targetBottom > viewBottom - padding) {
+ nextScrollTop = Math.max(0, targetBottom - containerRect.height + padding);
+ }
+
+ if (Math.abs(nextScrollTop - viewTop) < 1) {
+ return false;
+ }
+
+ container.scrollTo({ top: nextScrollTop, behavior: 'smooth' });
+ return true;
+ };
+
+ let remaining = 20;
+ let lastScrollTop = -1;
+ const settle = () => {
+ if (remaining <= 0) return;
+ const moved = attemptScroll();
+ remaining -= 1;
+ const currentTop = container.scrollTop;
+ const delta = Math.abs(currentTop - lastScrollTop);
+ lastScrollTop = currentTop;
+ if (!moved && delta < 0.5) {
+ return;
+ }
+ requestAnimationFrame(settle);
+ };
+ requestAnimationFrame(settle);
+ };
+
+ const getStepFromUrl = () => {
+ const url = new URL(window.location.href);
+ const step = Number(url.searchParams.get('step') || 1);
+ return normalizeStep(Number.isNaN(step) ? 1 : step);
+ };
+
+ const buildStepUrl = (step) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set('step', step);
+ return url;
+ };
+
+ const setStepInUrl = (step, pushState) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set('step', step);
+
+ if (pushState) {
+ window.history.pushState({ step }, '', url.toString());
+ }
+
+ return url;
+ };
+
+ const updateActionBar = (step) => {
+ const config = STEP_CONFIG[step] || STEP_CONFIG[1];
+ if (!backButton || !nextButton) return;
+ const locked = isInstallLocked();
+
+ const setButtonLabel = (button, label) => {
+ if (!button) return;
+ let text = button.querySelector('.button-text');
+ if (!text) {
+ text = document.createElement('span');
+ text.className = 'button-text typography-text-m-500';
+ button.textContent = '';
+ button.appendChild(text);
+ }
+ text.textContent = label;
+ };
+
+ if (!locked && config.back?.target) {
+ backButton.disabled = false;
+ backButton.setAttribute('data-step-target', String(config.back.target));
+ } else {
+ backButton.disabled = true;
+ backButton.removeAttribute('data-step-target');
+ }
+ setButtonLabel(backButton, 'Back');
+
+ if (!locked && config.next?.target) {
+ setButtonLabel(nextButton, config.next?.label || 'Next');
+ nextButton.setAttribute('data-step-target', String(config.next?.target || 1));
+ nextButton.disabled = false;
+ } else {
+ setButtonLabel(nextButton, config.next?.label || 'Next');
+ nextButton.removeAttribute('data-step-target');
+ nextButton.disabled = true;
+ }
+
+ indicatorNodes.forEach((node, index) => {
+ const isVisible = index < cardSteps.length;
+ node.classList.toggle('is-hidden', !isVisible);
+ if (!isVisible) {
+ node.classList.remove('is-active');
+ return;
+ }
+ node.classList.toggle('is-active', cardSteps[index] === step);
+ });
+
+ installerCard.setAttribute('data-step', String(step));
+ document.body.dataset.step = String(step);
+ if (locked) {
+ document.body.dataset.installLocked = 'true';
+ } else {
+ delete document.body.dataset.installLocked;
+ }
+ };
+
+ const measureStepHeight = (panel) => {
+ if (!panel) return;
+ const height = panel.getBoundingClientRect().height;
+ if (!height) return;
+ maxStepHeight = Math.max(maxStepHeight, height);
+ stepContainer.style.setProperty('--step-min-height', `${maxStepHeight}px`);
+ };
+
+ const runStepInit = (step, rootElement) => {
+ if (!window.InstallerSteps || typeof window.InstallerSteps.initStep !== 'function') return;
+ const root = rootElement || stepContainer;
+ window.InstallerSteps.initStep(step, root);
+ updateActionBar(step);
+ };
+
+ const fetchStepHtml = (step, url) => {
+ if (stepCache.has(step)) {
+ return Promise.resolve(stepCache.get(step));
+ }
+
+ const fetchUrl = new URL(url);
+ fetchUrl.searchParams.set('partial', '1');
+
+ return fetch(fetchUrl.toString(), {
+ headers: {
+ 'X-Requested-With': 'XMLHttpRequest'
+ }
+ })
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error('Failed to load step');
+ }
+ return response.text();
+ })
+ .then((html) => {
+ stepCache.set(step, html);
+ return html;
+ });
+ };
+
+ const preloadSteps = (steps) => {
+ const current = getStepFromUrl();
+ const targets = steps.filter((step) => step !== current);
+
+ return Promise.all(
+ targets.map((step) => {
+ const url = buildStepUrl(step);
+ return fetchStepHtml(step, url)
+ .then((html) => {
+ const panel = document.createElement('div');
+ panel.className = 'step-panel is-measure';
+ panel.innerHTML = html;
+ stepContainer.appendChild(panel);
+ panel.getBoundingClientRect();
+ measureStepHeight(panel);
+ panel.remove();
+ })
+ .catch(() => null);
+ })
+ );
+ };
+
+ const swapPanels = (step, html, onDone) => {
+ const activePanel = stepContainer.querySelector('.step-panel');
+
+ const measurePanel = document.createElement('div');
+ measurePanel.className = 'step-panel is-measure';
+ measurePanel.innerHTML = html;
+ stepContainer.appendChild(measurePanel);
+ measurePanel.getBoundingClientRect();
+ measureStepHeight(measurePanel);
+ measurePanel.remove();
+
+ const newPanel = document.createElement('div');
+ newPanel.className = 'step-panel is-entering';
+ newPanel.innerHTML = html;
+ stepContainer.appendChild(newPanel);
+ runStepInit(step, newPanel);
+
+ newPanel.getBoundingClientRect();
+
+ requestAnimationFrame(() => {
+ newPanel.classList.remove('is-entering');
+ newPanel.classList.add('is-active');
+ if (activePanel) {
+ activePanel.classList.add('is-exiting');
+ }
+ });
+
+ const finalize = () => {
+ if (activePanel && activePanel.parentNode) {
+ activePanel.parentNode.removeChild(activePanel);
+ }
+ newPanel.classList.remove('is-entering');
+ if (typeof onDone === 'function') {
+ onDone();
+ }
+ };
+
+ const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ if (prefersReduced) {
+ finalize();
+ return;
+ }
+
+ let finished = false;
+ const finishOnce = () => {
+ if (finished) return;
+ finished = true;
+ finalize();
+ };
+
+ newPanel.addEventListener(
+ 'transitionend',
+ (event) => {
+ if (event.propertyName === 'opacity') {
+ finishOnce();
+ }
+ },
+ { once: true }
+ );
+
+ setTimeout(finishOnce, STEP_TRANSITION_TIMEOUT);
+ };
+
+ const showInstallScreen = (step, html) => {
+ if (!installScreen) return;
+ installScreen.innerHTML = html;
+ runStepInit(step, installScreen);
+ };
+
+ const hideInstallScreen = () => {
+ if (!installScreen) return;
+ installScreen.innerHTML = '';
+ };
+
+ const loadStep = (step, pushState) => {
+ const targetStep = normalizeStep(Number(step));
+ const currentStep = getStepFromUrl();
+ if (targetStep === currentStep && pushState) return;
+
+ isTransitioning = true;
+ const url = setStepInUrl(targetStep, pushState);
+
+ fetchStepHtml(targetStep, url)
+ .then((html) => {
+ if (targetStep === 5) {
+ showInstallScreen(targetStep, html);
+ isTransitioning = false;
+ if (pendingStep !== null && pendingStep !== targetStep) {
+ const nextStep = pendingStep;
+ const nextPushState = pendingPushState;
+ pendingStep = null;
+ pendingPushState = false;
+ loadStep(nextStep, nextPushState);
+ return;
+ }
+ pendingStep = null;
+ pendingPushState = false;
+ return;
+ }
+
+ hideInstallScreen();
+ swapPanels(targetStep, html, () => {
+ isTransitioning = false;
+ if (pendingStep !== null && pendingStep !== targetStep) {
+ const nextStep = pendingStep;
+ const nextPushState = pendingPushState;
+ pendingStep = null;
+ pendingPushState = false;
+ loadStep(nextStep, nextPushState);
+ return;
+ }
+ pendingStep = null;
+ pendingPushState = false;
+ });
+ })
+ .catch(() => {
+ isTransitioning = false;
+ window.location.href = url.toString();
+ });
+ };
+
+ const requestStep = (step, pushState) => {
+ const targetStep = normalizeStep(Number(step));
+ if (isInstallLocked() && targetStep !== 5) {
+ loadStep(5, true);
+ return;
+ }
+ if (isTransitioning) {
+ pendingStep = targetStep;
+ pendingPushState = pendingPushState || pushState;
+ return;
+ }
+ loadStep(targetStep, pushState);
+ };
+
+ document.addEventListener('click', async (event) => {
+ const button = event.target.closest('[data-step-target]');
+ if (!button || button.disabled) return;
+ event.preventDefault();
+ const target = button.getAttribute('data-step-target');
+ if (!target) return;
+ const action = button.getAttribute('data-action');
+ if (action === 'next') {
+ const currentStep = getStepFromUrl();
+ const panel = stepContainer.querySelector('.step-panel') || stepContainer;
+ const validator = window.InstallerSteps?.validateStep;
+ if (typeof validator === 'function') {
+ const valid = validator(currentStep, panel);
+ if (!valid) {
+ scrollToFirstError(panel);
+ return;
+ }
+ }
+ }
+ if (action === 'next' && String(target) === '5' && typeof validateInstallRequest === 'function') {
+ const isValid = await validateInstallRequest();
+ if (!isValid) {
+ return;
+ }
+ }
+ if (isInstallLocked() && Number(target) !== 5) {
+ requestStep(5, true);
+ return;
+ }
+ requestStep(target, true);
+ });
+
+ window.addEventListener('popstate', (event) => {
+ const step = event.state?.step || getStepFromUrl();
+ if (isInstallLocked() && Number(step) !== 5) {
+ requestStep(5, false);
+ return;
+ }
+ requestStep(step, false);
+ });
+
+ document.addEventListener('DOMContentLoaded', () => {
+ let step = getStepFromUrl();
+ if (isInstallLocked() && step !== 5) {
+ const url = buildStepUrl(5);
+ window.history.replaceState({ step: 5 }, '', url.toString());
+ step = 5;
+ } else {
+ const url = buildStepUrl(step);
+ window.history.replaceState({ step }, '', url.toString());
+ }
+ const activePanel = stepContainer.querySelector('.step-panel') || stepContainer;
+ runStepInit(step, activePanel);
+ measureStepHeight(activePanel);
+ if (step === 5 && installScreen) {
+ runStepInit(step, installScreen);
+ }
+ const preload = () => {
+ measureStepHeight(activePanel);
+ preloadSteps(cardSteps);
+ };
+ if (document.fonts && document.fonts.ready) {
+ document.fonts.ready.then(preload).catch(preload);
+ } else {
+ preload();
+ }
+ });
+})();
diff --git a/app/views/install/installer/js/modules/context.js b/app/views/install/installer/js/modules/context.js
new file mode 100644
index 0000000000..bc08a8c8df
--- /dev/null
+++ b/app/views/install/installer/js/modules/context.js
@@ -0,0 +1,105 @@
+(() => {
+ const getBodyDataset = () => document.body?.dataset ?? {};
+ const isUpgradeMode = () => getBodyDataset().upgrade === 'true';
+ const getLockedDatabase = () => getBodyDataset().lockedDatabase || '';
+
+ const STEP_IDS = Object.freeze({
+ CONFIG_FILES: 'config-files',
+ DOCKER_COMPOSE: 'docker-compose',
+ ENV_VARS: 'env-vars',
+ DOCKER_CONTAINERS: 'docker-containers',
+ ACCOUNT_SETUP: 'account-setup'
+ });
+
+ const STATUS = Object.freeze({
+ IN_PROGRESS: 'in-progress',
+ COMPLETED: 'completed',
+ ERROR: 'error'
+ });
+
+ const SSE_EVENTS = Object.freeze({
+ PING: 'ping',
+ INSTALL_ID: 'install-id',
+ PROGRESS: 'progress',
+ DONE: 'done',
+ ERROR: 'error'
+ });
+
+ const buildInstallationSteps = (upgrade) => (upgrade ? [
+ {
+ id: STEP_IDS.CONFIG_FILES,
+ inProgress: 'Updating configuration files...',
+ done: 'Configuration files updated'
+ },
+ {
+ id: STEP_IDS.DOCKER_COMPOSE,
+ inProgress: 'Updating Docker Compose file...',
+ done: 'Docker Compose file updated'
+ },
+ {
+ id: STEP_IDS.ENV_VARS,
+ inProgress: 'Updating environment variables...',
+ done: 'Environment variables updated'
+ },
+ {
+ id: STEP_IDS.DOCKER_CONTAINERS,
+ inProgress: 'Restarting Docker containers...',
+ done: 'Docker containers restarted'
+ }
+ ] : [
+ {
+ id: STEP_IDS.CONFIG_FILES,
+ inProgress: 'Creating configuration files...',
+ done: 'Configuration files created'
+ },
+ {
+ id: STEP_IDS.DOCKER_COMPOSE,
+ inProgress: 'Generating Docker Compose file...',
+ done: 'Docker Compose file generated'
+ },
+ {
+ id: STEP_IDS.ENV_VARS,
+ inProgress: 'Configuring environment variables...',
+ done: 'Environment variables configured'
+ },
+ {
+ id: STEP_IDS.DOCKER_CONTAINERS,
+ inProgress: 'Starting Docker containers...',
+ done: 'Docker containers started'
+ },
+ {
+ id: STEP_IDS.ACCOUNT_SETUP,
+ inProgress: 'Creating Appwrite account...',
+ done: 'Appwrite account created (redirecting...)'
+ }
+ ]);
+
+ const INSTALLATION_STEPS = buildInstallationSteps(isUpgradeMode());
+ const CONSTANTS = window.InstallerConstants || {};
+ const TIMINGS = {
+ errorClear: CONSTANTS.errorClearMs ?? 180,
+ installPollInterval: CONSTANTS.installPollIntervalMs ?? 4000,
+ installFallbackDelay: CONSTANTS.installFallbackDelayMs ?? 12000,
+ redirectDelay: CONSTANTS.redirectDelayMs ?? 500,
+ progressTransitionDelay: CONSTANTS.progressTransitionDelayMs ?? 140,
+ progressCompleteDelay: CONSTANTS.progressCompleteDelayMs ?? 120
+ };
+
+ const clampStep = (step) => {
+ const numeric = Number(step);
+ if (Number.isNaN(numeric)) return 1;
+ return Math.max(1, Math.min(5, numeric));
+ };
+
+ window.InstallerStepsContext = Object.freeze({
+ getBodyDataset,
+ isUpgradeMode,
+ getLockedDatabase,
+ STEP_IDS,
+ STATUS,
+ SSE_EVENTS,
+ INSTALLATION_STEPS,
+ TIMINGS,
+ clampStep
+ });
+})();
diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js
new file mode 100644
index 0000000000..02c1e0ef2b
--- /dev/null
+++ b/app/views/install/installer/js/modules/progress.js
@@ -0,0 +1,899 @@
+(() => {
+ const {
+ INSTALLATION_STEPS,
+ TIMINGS,
+ getBodyDataset,
+ STEP_IDS,
+ STATUS,
+ SSE_EVENTS
+ } = window.InstallerStepsContext;
+ const {
+ formState,
+ applyLockPayload,
+ applyBodyDefaults,
+ setInstallLock,
+ getInstallLock,
+ clearInstallLock,
+ isInstallLocked,
+ syncInstallLockFlag,
+ getStoredInstallId,
+ storeInstallId,
+ clearInstallId
+ } = window.InstallerStepsState || {};
+ const { extractHostname, isLocalHost } = window.InstallerStepsValidation || {};
+ const { generateSecretKey } = window.InstallerStepsUI || {};
+ const { showToast } = window.InstallerToast || {};
+
+ let activeInstall = null;
+ let unloadGuard = null;
+ const csrfToken = document.querySelector('meta[name="appwrite-installer-csrf"]')?.getAttribute('content') || '';
+
+ const withCsrfHeader = (headers = {}) => {
+ if (!csrfToken) {
+ return headers;
+ }
+ return { ...headers, 'X-Appwrite-Installer-CSRF': csrfToken };
+ };
+
+ const showCsrfToast = () => {
+ showToast?.({
+ status: 'error',
+ title: 'Session expired',
+ description: 'Refresh the page and try again.',
+ dismissible: true
+ });
+ };
+
+ const validateInstallRequest = async () => {
+ try {
+ const response = await fetch('/install/validate', {
+ method: 'POST',
+ headers: withCsrfHeader({
+ 'Content-Type': 'application/json'
+ })
+ });
+ if (!response.ok) {
+ showCsrfToast();
+ return false;
+ }
+ const data = await response.json().catch(() => ({}));
+ if (!data?.success) {
+ showCsrfToast();
+ return false;
+ }
+ return true;
+ } catch (error) {
+ showCsrfToast();
+ return false;
+ }
+ };
+
+ const setUnloadGuard = (enabled) => {
+ if (!enabled && unloadGuard) {
+ window.removeEventListener('beforeunload', unloadGuard);
+ unloadGuard = null;
+ return;
+ }
+
+ if (enabled && !unloadGuard) {
+ unloadGuard = (event) => {
+ event.preventDefault();
+ event.returnValue = '';
+ return '';
+ };
+ window.addEventListener('beforeunload', unloadGuard);
+ }
+ };
+
+ const cleanupInstallFlow = () => {
+ if (activeInstall?.controller) {
+ activeInstall.controller.abort();
+ if (activeInstall.pollTimer) {
+ clearInterval(activeInstall.pollTimer);
+ }
+ if (activeInstall.fallbackTimer) {
+ clearTimeout(activeInstall.fallbackTimer);
+ }
+ activeInstall = null;
+ }
+ stopSyncedSpinnerRotation();
+ setUnloadGuard(false);
+ };
+
+ const getStepDefinition = (id) => INSTALLATION_STEPS.find((step) => step.id === id);
+
+ const getProgressLabel = (step, status, message) => {
+ if (!step) return message || '';
+ if (status === STATUS.ERROR) {
+ const normalized = normalizeInstallError(message || '');
+ return normalized.summary || 'Installation failed.';
+ }
+ if (status === STATUS.COMPLETED) return step.done;
+ return step.inProgress;
+ };
+
+ const updateInstallRow = (row, step, status, message) => {
+ if (!row || !step) return;
+ row.dataset.status = status;
+ row.dataset.step = step.id;
+ if (status !== STATUS.ERROR) {
+ row.classList.remove('is-open');
+ const toggle = row.querySelector('[data-install-toggle]');
+ if (toggle) {
+ toggle.setAttribute('aria-expanded', 'false');
+ }
+ }
+ const label = getProgressLabel(step, status, message);
+ const text = row.querySelector('[data-install-text]');
+ if (text) {
+ if (text.textContent !== label) {
+ text.classList.remove('is-enter');
+ text.textContent = label;
+ text.classList.add('is-enter');
+ requestAnimationFrame(() => {
+ text.classList.remove('is-enter');
+ });
+ }
+ }
+
+ // Show/hide "Navigate to Console" button for account setup errors
+ const consoleBtn = row.querySelector('[data-install-console]');
+ if (consoleBtn) {
+ const shouldShow = step.id === STEP_IDS.ACCOUNT_SETUP && status === STATUS.ERROR;
+ consoleBtn.classList.toggle('is-hidden', !shouldShow);
+ }
+ };
+
+ const normalizeInstallError = (message) => {
+ const text = String(message || '').trim();
+ if (!text) {
+ return { summary: '', details: '' };
+ }
+ const colonIndex = text.indexOf(':');
+ if (colonIndex > 0 && colonIndex < 80) {
+ const summary = text.slice(0, colonIndex).trim();
+ const details = text.slice(colonIndex + 1).trim();
+ return { summary, details };
+ }
+ if (text.length > 180) {
+ return { summary: text.slice(0, 180).trim() + '…', details: text };
+ }
+ return { summary: text, details: '' };
+ };
+
+ let spinnerAnimationFrame = null;
+ const stopSyncedSpinnerRotation = () => {
+ if (spinnerAnimationFrame) {
+ cancelAnimationFrame(spinnerAnimationFrame);
+ spinnerAnimationFrame = null;
+ }
+ };
+
+ const startSyncedSpinnerRotation = (container) => {
+ stopSyncedSpinnerRotation();
+ if (!container) return;
+ let startTime = null;
+ const animate = (timestamp) => {
+ if (!startTime) startTime = timestamp;
+ const elapsed = timestamp - startTime;
+ const rotation = ((elapsed / 1000) * 360 * 1.5) % 360;
+ container.style.setProperty('--spinner-rotation', `${rotation}deg`);
+ spinnerAnimationFrame = requestAnimationFrame(animate);
+ };
+ spinnerAnimationFrame = requestAnimationFrame(animate);
+ };
+
+ const updateInstallErrorDetails = (row, error) => {
+ if (!row) return;
+ const traceNode = row.querySelector('[data-install-trace]');
+ const normalized = normalizeInstallError(error?.message || '');
+ const output = error?.output || '';
+ const trace = error?.trace || '';
+ const detailChunks = [];
+ if (normalized.details) detailChunks.push(normalized.details);
+ if (output) detailChunks.push(output);
+ if (trace) detailChunks.push(trace);
+ const detailText = detailChunks.join('\n\n');
+
+ if (traceNode) {
+ traceNode.textContent = detailText;
+ traceNode.style.display = detailText ? 'block' : 'none';
+ }
+ };
+
+ const createInstallRow = (template, step) => {
+ const fragment = template.content.cloneNode(true);
+ const row = fragment.querySelector('.install-row');
+ if (!row) return null;
+ const toggle = row.querySelector('[data-install-toggle]');
+ const setOpenState = (isOpen) => {
+ row.classList.toggle('is-open', isOpen);
+ if (toggle) {
+ toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
+ }
+ };
+ const toggleRow = () => {
+ if (!row.dataset.status || row.dataset.status !== STATUS?.ERROR) {
+ return;
+ }
+ setOpenState(!row.classList.contains('is-open'));
+ };
+ row.addEventListener('click', (event) => {
+ if (event.target.closest('[data-install-retry]')) {
+ return;
+ }
+ if (event.target.closest('[data-install-toggle]')) {
+ return;
+ }
+ if (event.target.closest('.install-row-details')) {
+ return;
+ }
+ toggleRow();
+ });
+ if (toggle) {
+ toggle.addEventListener('click', (event) => {
+ event.stopPropagation();
+ toggleRow();
+ });
+ }
+ updateInstallRow(row, step, STATUS.IN_PROGRESS);
+ return row;
+ };
+
+ const generateInstallId = () => {
+ if (window.crypto?.randomUUID) {
+ return window.crypto.randomUUID();
+ }
+ const bytes = new Uint8Array(16);
+ window.crypto.getRandomValues(bytes);
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
+ };
+
+ const buildRedirectUrl = () => {
+ const dataset = getBodyDataset?.() ?? {};
+ const rawDomain = (formState?.appDomain || dataset.defaultAppDomain || '').trim();
+ if (!rawDomain) return '';
+ const httpPort = (formState?.httpPort || dataset.defaultHttpPort || '').trim();
+ const httpsPort = (formState?.httpsPort || dataset.defaultHttpsPort || '').trim();
+ const hasPort = rawDomain.includes(':') || rawDomain.startsWith('[');
+ let host = rawDomain;
+ const hostForProtocol = extractHostname?.(rawDomain);
+ const normalizedHost = hostForProtocol?.toLowerCase?.() ?? '';
+ if (hostForProtocol === '0.0.0.0') {
+ host = rawDomain.replace('0.0.0.0', 'localhost');
+ } else if (normalizedHost === 'traefik') {
+ host = rawDomain.replace(hostForProtocol, 'localhost');
+ }
+ let protocol = 'http';
+ let port = httpPort;
+ if (httpsPort && httpsPort !== '0' && !isLocalHost?.(normalizedHost)) {
+ protocol = 'https';
+ port = httpsPort;
+ }
+ if (!hasPort && port && ((protocol === 'http' && port !== '80') || (protocol === 'https' && port !== '443'))) {
+ host = `${rawDomain}:${port}`;
+ }
+ return `${protocol}://${host}`;
+ };
+
+ const redirectToApp = () => {
+ const url = buildRedirectUrl();
+ if (!url) return;
+ window.location.href = url;
+ };
+
+ const notifyInstallComplete = (installId, session) => {
+ if (!installId) return Promise.resolve();
+ const payload = { installId };
+ const sessionSecret = session?.sessionSecret || session?.secret;
+ const sessionId = session?.sessionId || session?.id;
+ const sessionExpire = session?.sessionExpire || session?.expire;
+ if (sessionSecret) {
+ payload.sessionSecret = sessionSecret;
+ }
+ if (sessionId) {
+ payload.sessionId = sessionId;
+ }
+ if (sessionExpire) {
+ payload.sessionExpire = sessionExpire;
+ }
+ return fetch('/install/complete', {
+ method: 'POST',
+ headers: withCsrfHeader({
+ 'Content-Type': 'application/json'
+ }),
+ body: JSON.stringify(payload)
+ }).catch(() => {});
+ };
+
+ const buildInstallPayload = (installId) => {
+ const normalizedSecret = (formState?.opensslKey || '').trim();
+ if (!normalizedSecret && generateSecretKey) {
+ formState.opensslKey = generateSecretKey();
+ }
+ const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost';
+ const normalizedHttpPort = (formState?.httpPort || '').trim() || '80';
+ const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443';
+ const normalizedEmail = (formState?.emailCertificates || '').trim();
+ const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim();
+ const normalizedAccountEmail = (formState?.accountEmail || '').trim();
+ const normalizedAccountPassword = (formState?.accountPassword || '').trim();
+
+ return {
+ installId,
+ httpPort: normalizedHttpPort,
+ httpsPort: normalizedHttpsPort,
+ database: formState?.database || 'mongodb',
+ appDomain: normalizedDomain,
+ emailCertificates: normalizedEmail,
+ opensslKey: (formState?.opensslKey || '').trim(),
+ assistantOpenAIKey: normalizedAssistantKey,
+ accountEmail: normalizedAccountEmail,
+ accountPassword: normalizedAccountPassword
+ };
+ };
+
+ const fetchInstallStatus = async (installId) => {
+ if (!installId) return null;
+ const response = await fetch(`/install/status?installId=${encodeURIComponent(installId)}`, {
+ cache: 'no-store'
+ });
+ if (!response.ok) return null;
+ const json = await response.json();
+ return json.progress || null;
+ };
+
+ const readEventStream = async (stream, onEvent) => {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder('utf-8');
+ let buffer = '';
+
+ try {
+ const processEvent = (rawEvent) => {
+ if (!rawEvent) return;
+ const lines = rawEvent.split('\n');
+ let eventName = 'message';
+ let data = '';
+
+ lines.forEach((line) => {
+ if (line.startsWith('event:')) {
+ eventName = line.replace('event:', '').trim();
+ } else if (line.startsWith('data:')) {
+ data += line.replace('data:', '').trim();
+ }
+ });
+
+ if (data) {
+ try {
+ const parsed = JSON.parse(data);
+ onEvent(eventName, parsed);
+ } catch (error) {
+ onEvent(eventName, { message: data });
+ }
+ }
+ };
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) {
+ buffer = buffer.replace(/\r\n/g, '\n');
+ if (buffer.trim()) {
+ processEvent(buffer);
+ }
+ break;
+ }
+ buffer += decoder.decode(value, { stream: true });
+ buffer = buffer.replace(/\r\n/g, '\n');
+ let separatorIndex = buffer.indexOf('\n\n');
+
+ while (separatorIndex !== -1) {
+ const rawEvent = buffer.slice(0, separatorIndex);
+ buffer = buffer.slice(separatorIndex + 2);
+ processEvent(rawEvent);
+ separatorIndex = buffer.indexOf('\n\n');
+ }
+ }
+ } finally {
+ try {
+ reader.releaseLock();
+ } catch (error) {}
+ }
+ };
+
+ const initStep5 = (root) => {
+ if (!root) return;
+
+ if (activeInstall?.controller) {
+ activeInstall.controller.abort();
+ }
+ if (activeInstall?.pollTimer) {
+ clearInterval(activeInstall.pollTimer);
+ }
+ if (activeInstall?.fallbackTimer) {
+ clearTimeout(activeInstall.fallbackTimer);
+ }
+ activeInstall = null;
+
+ const list = root.querySelector('[data-install-list]');
+ const template = root.querySelector('#install-row-template');
+ if (!list || !template) return;
+ startSyncedSpinnerRotation(list);
+
+ list.innerHTML = '';
+ const rowsById = new Map();
+ const progressState = new Map();
+ let pendingProgressTimer = null;
+ let pendingProgressStep = null;
+ let pendingCompletionTimer = null;
+ syncInstallLockFlag?.();
+ applyLockPayload?.();
+ applyBodyDefaults?.();
+
+ const ensureRow = (step) => {
+ if (!step) return null;
+ if (rowsById.has(step.id)) {
+ return rowsById.get(step.id);
+ }
+ const row = createInstallRow(template, step);
+ if (!row) return null;
+ row.classList.add('is-entering');
+ list.appendChild(row);
+ row.getBoundingClientRect();
+ requestAnimationFrame(() => {
+ row.classList.remove('is-entering');
+ });
+ rowsById.set(step.id, row);
+ return row;
+ };
+
+ const installPanel = root.querySelector('.install-panel');
+ let panelHeightCleanup = null;
+ const animatePanelHeight = (mutate) => {
+ if (!installPanel) {
+ mutate();
+ return;
+ }
+ if (panelHeightCleanup) {
+ panelHeightCleanup();
+ panelHeightCleanup = null;
+ }
+ const currentHeight = installPanel.getBoundingClientRect().height;
+ installPanel.style.height = `${currentHeight}px`;
+ installPanel.getBoundingClientRect();
+ mutate();
+ const nextHeight = installPanel.getBoundingClientRect().height;
+ if (currentHeight === nextHeight) {
+ installPanel.style.height = '';
+ return;
+ }
+ installPanel.style.height = `${currentHeight}px`;
+ installPanel.getBoundingClientRect();
+ installPanel.style.height = `${nextHeight}px`;
+ const cleanup = () => {
+ installPanel.style.height = '';
+ installPanel.removeEventListener('transitionend', onEnd);
+ };
+ const onEnd = (event) => {
+ if (event.propertyName === 'height') {
+ cleanup();
+ }
+ };
+ panelHeightCleanup = cleanup;
+ installPanel.addEventListener('transitionend', onEnd);
+ };
+
+ const renderProgress = () => {
+ animatePanelHeight(() => {
+ const visibleSteps = [];
+ for (const step of INSTALLATION_STEPS) {
+ const state = progressState.get(step.id);
+ if (!state) break;
+ visibleSteps.push(step);
+ }
+
+ visibleSteps.forEach((step) => {
+ const state = progressState.get(step.id);
+ if (!state) return;
+ const row = ensureRow(step);
+ if (row) {
+ updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message);
+ if (state.status === STATUS?.ERROR) {
+ updateInstallErrorDetails(row, {
+ message: state.message,
+ trace: state.details?.trace,
+ output: state.details?.output
+ });
+ }
+ }
+ });
+ });
+ };
+
+ const firstStep = INSTALLATION_STEPS[0];
+ if (firstStep) {
+ progressState.set(firstStep.id, {
+ status: STATUS.IN_PROGRESS,
+ message: firstStep.inProgress
+ });
+ }
+ renderProgress();
+
+ const applyProgress = (payload) => {
+ const step = getStepDefinition(payload.step) || {
+ id: payload.step,
+ inProgress: payload.message || payload.step,
+ done: payload.message || payload.step
+ };
+ progressState.set(step.id, {
+ status: payload.status || STATUS.IN_PROGRESS,
+ message: payload.message,
+ details: payload.details
+ });
+ renderProgress();
+ if (activeInstall) {
+ activeInstall.lastEventAt = Date.now();
+ if (payload.status === STATUS.ERROR) {
+ if (activeInstall.pollTimer) {
+ clearInterval(activeInstall.pollTimer);
+ activeInstall.pollTimer = null;
+ }
+ if (activeInstall.fallbackTimer) {
+ clearTimeout(activeInstall.fallbackTimer);
+ activeInstall.fallbackTimer = null;
+ }
+ }
+ }
+ scheduleFallback();
+ };
+
+ const handleProgress = (payload) => {
+ if (!payload || !payload.step) return;
+
+ const existingState = progressState.get(payload.step);
+ if (existingState && existingState.status === STATUS.COMPLETED && payload.status === STATUS.IN_PROGRESS) {
+ return;
+ }
+
+ if (pendingProgressTimer && pendingProgressStep === payload.step && payload.status !== STATUS.IN_PROGRESS) {
+ clearTimeout(pendingProgressTimer);
+ pendingProgressTimer = null;
+ pendingProgressStep = null;
+ }
+ if (pendingCompletionTimer) {
+ clearTimeout(pendingCompletionTimer);
+ pendingCompletionTimer = null;
+ }
+ const step = getStepDefinition(payload.step) || {
+ id: payload.step,
+ inProgress: payload.message || payload.step,
+ done: payload.message || payload.step
+ };
+ if (payload.status === STATUS.IN_PROGRESS) {
+ const currentIndex = INSTALLATION_STEPS.findIndex((candidate) => candidate.id === step.id);
+ const completionTargets = [];
+ if (currentIndex > 0) {
+ for (let i = 0; i < currentIndex; i += 1) {
+ const previousStep = INSTALLATION_STEPS[i];
+ const previousState = progressState.get(previousStep.id);
+ if (previousState && previousState.status !== STATUS.COMPLETED) {
+ completionTargets.push({
+ step: previousStep,
+ details: previousState.details
+ });
+ }
+ }
+ }
+ if (completionTargets.length && currentIndex > 0) {
+ pendingProgressStep = payload.step;
+ pendingCompletionTimer = setTimeout(() => {
+ pendingCompletionTimer = null;
+ completionTargets.forEach(({ step: previousStep, details }) => {
+ progressState.set(previousStep.id, {
+ status: STATUS.COMPLETED,
+ message: previousStep.done,
+ details
+ });
+ });
+ renderProgress();
+ }, TIMINGS?.progressCompleteDelay ?? 0);
+ pendingProgressTimer = setTimeout(() => {
+ pendingProgressTimer = null;
+ pendingProgressStep = null;
+ applyProgress(payload);
+ }, TIMINGS?.progressTransitionDelay ?? 0);
+ scheduleFallback();
+ return;
+ }
+ }
+ applyProgress(payload);
+ };
+
+ const applySnapshot = (snapshot) => {
+ if (!snapshot || !snapshot.steps) return;
+ INSTALLATION_STEPS.forEach((step) => {
+ const detail = snapshot.steps[step.id];
+ if (!detail) return;
+ progressState.set(step.id, {
+ status: detail.status,
+ message: detail.message,
+ details: snapshot.details?.[step.id]
+ });
+ });
+ renderProgress();
+ };
+
+ const startPolling = () => {
+ if (!activeInstall || activeInstall.pollTimer) return;
+ activeInstall.pollTimer = setInterval(async () => {
+ if (!activeInstall || activeInstall.completed) return;
+ const snapshot = await fetchInstallStatus(activeInstall.installId);
+ if (snapshot) {
+ applySnapshot(snapshot);
+ }
+ }, TIMINGS?.installPollInterval ?? 0);
+ };
+
+ const scheduleFallback = () => {
+ if (!activeInstall) return;
+ if (activeInstall.fallbackTimer) {
+ clearTimeout(activeInstall.fallbackTimer);
+ }
+ activeInstall.fallbackTimer = setTimeout(() => {
+ if (!activeInstall) return;
+ startPolling();
+ }, TIMINGS?.installFallbackDelay ?? 0);
+ };
+
+ const finalizeInstall = () => {
+ if (!activeInstall) return;
+ activeInstall.completed = true;
+ if (activeInstall.pollTimer) {
+ clearInterval(activeInstall.pollTimer);
+ }
+ if (activeInstall.fallbackTimer) {
+ clearTimeout(activeInstall.fallbackTimer);
+ }
+ stopSyncedSpinnerRotation();
+ setUnloadGuard(false);
+ };
+
+ const startInstallStream = async (installId, options = {}) => {
+ const isValid = await validateInstallRequest();
+ if (!isValid) {
+ return;
+ }
+ activeInstall = {
+ installId,
+ controller: new AbortController(),
+ lastEventAt: Date.now(),
+ pollTimer: null,
+ fallbackTimer: null,
+ completed: false
+ };
+
+ const payload = buildInstallPayload(installId);
+ if (options.retryStep) {
+ payload.retryStep = options.retryStep;
+ }
+ setInstallLock?.(installId, payload);
+ setUnloadGuard(true);
+
+ try {
+ scheduleFallback();
+ const response = await fetch('/install', {
+ method: 'POST',
+ headers: withCsrfHeader({
+ 'Content-Type': 'application/json',
+ 'Accept': 'text/event-stream'
+ }),
+ body: JSON.stringify(payload),
+ signal: activeInstall.controller.signal
+ });
+
+ if (!response.ok || !response.body) {
+ let errorMessage = null;
+ try {
+ const contentType = response.headers.get('Content-Type') || '';
+ if (contentType.includes('application/json')) {
+ const data = await response.json();
+ errorMessage = data?.message || null;
+ }
+ } catch (error) {
+ errorMessage = null;
+ }
+ if (errorMessage) {
+ handleProgress({
+ step: STEP_IDS.CONFIG_FILES,
+ status: STATUS.ERROR,
+ message: errorMessage
+ });
+ finalizeInstall();
+ return;
+ }
+ startPolling();
+ return;
+ }
+
+ await readEventStream(response.body, (event, data) => {
+ if (!activeInstall) return;
+ if (event === SSE_EVENTS.INSTALL_ID && data?.installId) {
+ activeInstall.installId = data.installId;
+ storeInstallId?.(data.installId);
+ return;
+ }
+ if (event === SSE_EVENTS.PROGRESS) {
+ handleProgress(data);
+ return;
+ }
+ if (event === SSE_EVENTS.DONE) {
+ const lastStep = INSTALLATION_STEPS[INSTALLATION_STEPS.length - 1];
+ if (lastStep) {
+ const lastState = progressState.get(lastStep.id);
+ if (!lastState || (lastState.status !== STATUS.COMPLETED && lastState.status !== STATUS.ERROR)) {
+ progressState.set(lastStep.id, {
+ status: STATUS.COMPLETED,
+ message: lastStep.done,
+ details: lastState?.details
+ });
+ renderProgress();
+ }
+ }
+ const accountState = progressState.get(STEP_IDS.ACCOUNT_SETUP);
+ const accountRequired = INSTALLATION_STEPS.some((step) => step.id === STEP_IDS.ACCOUNT_SETUP);
+ if (accountRequired && accountState?.status === STATUS.ERROR) {
+ finalizeInstall();
+ return;
+ }
+ const sessionDetails = accountState?.details;
+ if (accountRequired && !(sessionDetails?.sessionSecret || sessionDetails?.secret)) {
+ handleProgress({
+ step: STEP_IDS.ACCOUNT_SETUP,
+ status: STATUS.ERROR,
+ message: 'Account creation did not complete. Please retry.'
+ });
+ finalizeInstall();
+ return;
+ }
+ finalizeInstall();
+ notifyInstallComplete(activeInstall?.installId, sessionDetails).finally(() => {
+ setTimeout(() => redirectToApp(), TIMINGS?.redirectDelay ?? 0);
+ });
+ return;
+ }
+ if (event === SSE_EVENTS.ERROR) {
+ if (data?.message) {
+ const existingError = Array.from(progressState.values()).some((state) => state?.status === STATUS.ERROR);
+ if (data.step || !existingError) {
+ let targetStep = data.step;
+ if (!targetStep) {
+ for (const candidate of INSTALLATION_STEPS) {
+ const state = progressState.get(candidate.id);
+ if (!state || state.status !== STATUS.COMPLETED) {
+ targetStep = candidate.id;
+ break;
+ }
+ }
+ }
+ handleProgress({
+ step: targetStep || STEP_IDS.CONFIG_FILES,
+ status: STATUS.ERROR,
+ message: data.message,
+ details: data.details
+ });
+ }
+ }
+ finalizeInstall();
+ }
+ });
+ if (activeInstall && !activeInstall.completed) {
+ startPolling();
+ }
+ } catch (error) {
+ if (!activeInstall || activeInstall.controller.signal.aborted) {
+ return;
+ }
+ startPolling();
+ }
+ };
+
+ const resumeInstall = async (installId) => {
+ const snapshot = await fetchInstallStatus(installId);
+ if (!snapshot) return false;
+ activeInstall = {
+ installId,
+ controller: new AbortController(),
+ lastEventAt: Date.now(),
+ pollTimer: null,
+ fallbackTimer: null,
+ completed: false
+ };
+ applySnapshot(snapshot);
+ startPolling();
+ setUnloadGuard(true);
+ return true;
+ };
+
+ const resetProgressFrom = (stepId) => {
+ const index = INSTALLATION_STEPS.findIndex((step) => step.id === stepId);
+ if (index === -1) return;
+ INSTALLATION_STEPS.slice(index).forEach((step) => {
+ progressState.delete(step.id);
+ const row = rowsById.get(step.id);
+ if (row && row.parentNode) {
+ row.parentNode.removeChild(row);
+ }
+ rowsById.delete(step.id);
+ });
+ };
+
+ const retryInstallStep = (stepId) => {
+ if (!stepId) return;
+ if (activeInstall?.controller) {
+ activeInstall.controller.abort();
+ }
+ if (activeInstall?.pollTimer) {
+ clearInterval(activeInstall.pollTimer);
+ }
+ if (activeInstall?.fallbackTimer) {
+ clearTimeout(activeInstall.fallbackTimer);
+ }
+
+ resetProgressFrom(stepId);
+
+ const step = getStepDefinition(stepId);
+ progressState.set(stepId, {
+ status: STATUS.IN_PROGRESS,
+ message: step?.inProgress || 'Retrying...'
+ });
+
+ const row = ensureRow(step);
+ if (row) {
+ updateInstallRow(row, step, STATUS.IN_PROGRESS, step.inProgress || 'Retrying...');
+ }
+
+ const installId = activeInstall?.installId || getInstallLock?.()?.installId || generateInstallId();
+ storeInstallId?.(installId);
+ startInstallStream(installId, { retryStep: stepId });
+ };
+
+ list.addEventListener('click', (event) => {
+ const consoleButton = event.target.closest('[data-install-console]');
+ const retryButton = event.target.closest('[data-install-retry]');
+
+ if (consoleButton) {
+ redirectToApp();
+ return;
+ }
+
+ if (retryButton) {
+ const row = retryButton.closest('.install-row');
+ const stepId = row?.dataset.step;
+ retryInstallStep(stepId);
+ }
+ });
+
+ const lock = getInstallLock?.();
+ const existingInstallId = lock?.installId || getStoredInstallId?.();
+ if (existingInstallId) {
+ resumeInstall(existingInstallId).then((resumed) => {
+ if (!resumed) {
+ clearInstallId?.();
+ clearInstallLock?.();
+ const newInstallId = generateInstallId();
+ storeInstallId?.(newInstallId);
+ startInstallStream(newInstallId);
+ }
+ });
+ } else {
+ const newInstallId = generateInstallId();
+ storeInstallId?.(newInstallId);
+ startInstallStream(newInstallId);
+ }
+ };
+
+ window.InstallerStepsProgress = {
+ initStep5,
+ cleanupInstallFlow,
+ validateInstallRequest
+ };
+})();
diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js
new file mode 100644
index 0000000000..ab20ccdc5e
--- /dev/null
+++ b/app/views/install/installer/js/modules/state.js
@@ -0,0 +1,155 @@
+(() => {
+ const {
+ getBodyDataset,
+ isUpgradeMode,
+ getLockedDatabase
+ } = window.InstallerStepsContext || {};
+
+ const INSTALL_LOCK_KEY = 'appwrite-install-lock';
+ const INSTALL_ID_KEY = 'appwrite-install-id';
+
+ const formState = {
+ appDomain: null,
+ database: null,
+ httpPort: null,
+ httpsPort: null,
+ emailCertificates: null,
+ opensslKey: null,
+ assistantOpenAIKey: null,
+ accountEmail: null,
+ accountPassword: null
+ };
+
+ const dispatchStateChange = (key) => {
+ if (!key || typeof document === 'undefined') return;
+ try {
+ document.dispatchEvent(new CustomEvent('installer:state-change', {
+ detail: { key, value: formState[key] }
+ }));
+ } catch (error) {}
+ };
+
+ const setStateIfEmpty = (key, value) => {
+ if (value === null || value === undefined || value === '') return;
+ if (formState[key] === null || formState[key] === undefined || formState[key] === '') {
+ formState[key] = value;
+ }
+ };
+
+ const applyBodyDefaults = () => {
+ const data = getBodyDataset?.() ?? {};
+ setStateIfEmpty('appDomain', data.defaultAppDomain);
+ setStateIfEmpty('httpPort', data.defaultHttpPort);
+ setStateIfEmpty('httpsPort', data.defaultHttpsPort);
+ setStateIfEmpty('emailCertificates', data.defaultEmailCertificates);
+ setStateIfEmpty('opensslKey', data.defaultSecretKey);
+ setStateIfEmpty('assistantOpenAIKey', data.defaultAssistantOpenaiKey);
+ if (data.lockedDatabase) {
+ formState.database = data.lockedDatabase;
+ }
+ if (!isUpgradeMode?.()) {
+ setStateIfEmpty('database', data.defaultDatabase);
+ }
+ };
+
+ const getInstallLock = () => {
+ try {
+ const raw = sessionStorage.getItem(INSTALL_LOCK_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object') return null;
+ return parsed;
+ } catch (error) {
+ return null;
+ }
+ };
+
+ const setInstallLock = (installId, payload) => {
+ const lock = {
+ installId,
+ payload: payload || null,
+ startedAt: Date.now()
+ };
+ try {
+ sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock));
+ } catch (error) {}
+ if (document.body) {
+ document.body.dataset.installLocked = 'true';
+ }
+ return lock;
+ };
+
+ const clearInstallLock = () => {
+ try {
+ sessionStorage.removeItem(INSTALL_LOCK_KEY);
+ } catch (error) {}
+ if (document.body) {
+ delete document.body.dataset.installLocked;
+ }
+ };
+
+ const isInstallLocked = () => {
+ return Boolean(getInstallLock());
+ };
+
+ const syncInstallLockFlag = () => {
+ if (!document.body) return;
+ if (isInstallLocked()) {
+ document.body.dataset.installLocked = 'true';
+ } else {
+ delete document.body.dataset.installLocked;
+ }
+ };
+
+ const applyLockPayload = () => {
+ const lock = getInstallLock();
+ if (!lock || !lock.payload) return;
+ const payload = lock.payload;
+ setStateIfEmpty('appDomain', payload.appDomain);
+ setStateIfEmpty('database', payload.database);
+ setStateIfEmpty('httpPort', payload.httpPort);
+ setStateIfEmpty('httpsPort', payload.httpsPort);
+ setStateIfEmpty('emailCertificates', payload.emailCertificates);
+ setStateIfEmpty('opensslKey', payload.opensslKey);
+ setStateIfEmpty('assistantOpenAIKey', payload.assistantOpenAIKey);
+ setStateIfEmpty('accountEmail', payload.accountEmail);
+ setStateIfEmpty('accountPassword', payload.accountPassword);
+ };
+
+ const getStoredInstallId = () => {
+ try {
+ return sessionStorage.getItem(INSTALL_ID_KEY);
+ } catch (error) {
+ return null;
+ }
+ };
+
+ const storeInstallId = (installId) => {
+ try {
+ sessionStorage.setItem(INSTALL_ID_KEY, installId);
+ } catch (error) {}
+ };
+
+ const clearInstallId = () => {
+ try {
+ sessionStorage.removeItem(INSTALL_ID_KEY);
+ } catch (error) {}
+ };
+
+ window.InstallerStepsState = {
+ formState,
+ dispatchStateChange,
+ setStateIfEmpty,
+ applyBodyDefaults,
+ applyLockPayload,
+ getInstallLock,
+ setInstallLock,
+ clearInstallLock,
+ isInstallLocked,
+ syncInstallLockFlag,
+ getStoredInstallId,
+ storeInstallId,
+ clearInstallId,
+ getLockedDatabase: getLockedDatabase || (() => '')
+ };
+})();
diff --git a/app/views/install/installer/js/modules/toast.js b/app/views/install/installer/js/modules/toast.js
new file mode 100644
index 0000000000..5a8eb55f41
--- /dev/null
+++ b/app/views/install/installer/js/modules/toast.js
@@ -0,0 +1,95 @@
+(() => {
+ const TOAST_STACK_ID = 'installer-toast-stack';
+ const DEFAULT_TIMEOUT = 5000;
+ const MAX_TOASTS = 3;
+ const ICONS = {
+ error: '',
+ close: ''
+ };
+
+ const getStack = () => document.getElementById(TOAST_STACK_ID);
+
+ const dismissToast = (toast) => {
+ if (!toast) return;
+ if (toast.classList.contains('is-leaving')) return;
+ toast.classList.add('is-leaving');
+ const remove = () => toast.remove();
+ toast.addEventListener('transitionend', remove, { once: true });
+ setTimeout(remove, 450);
+ };
+
+ const showToast = ({
+ title = '',
+ description = '',
+ status = 'error',
+ dismissible = true,
+ timeout = DEFAULT_TIMEOUT
+ } = {}) => {
+ const stack = getStack();
+ if (!stack) return;
+ const visibleToasts = Array.from(
+ stack.querySelectorAll('.installer-toast:not(.is-leaving)')
+ );
+ if (visibleToasts.length >= MAX_TOASTS) {
+ dismissToast(visibleToasts[0]);
+ }
+
+ const toast = document.createElement('div');
+ toast.className = 'installer-toast is-entering';
+ toast.dataset.status = status;
+ toast.setAttribute('role', status === 'error' ? 'alert' : 'status');
+
+ const content = document.createElement('div');
+ content.className = 'installer-toast-content';
+
+ const icon = document.createElement('span');
+ icon.className = 'installer-toast-icon';
+ icon.dataset.status = status;
+ icon.innerHTML = ICONS.error;
+ content.appendChild(icon);
+
+ const body = document.createElement('section');
+ body.className = 'installer-toast-body';
+
+ if (title) {
+ const titleNode = document.createElement('p');
+ titleNode.className = 'installer-toast-title typography-text-m-500';
+ titleNode.textContent = title;
+ body.appendChild(titleNode);
+ }
+
+ if (description) {
+ const descNode = document.createElement('p');
+ descNode.className = 'installer-toast-description typography-text-m-400';
+ descNode.textContent = description;
+ body.appendChild(descNode);
+ }
+
+ content.appendChild(body);
+ toast.appendChild(content);
+
+ if (dismissible) {
+ const close = document.createElement('button');
+ close.type = 'button';
+ close.className = 'installer-toast-close';
+ close.setAttribute('aria-label', 'Dismiss notification');
+ close.innerHTML = ICONS.close;
+ close.addEventListener('click', () => dismissToast(toast));
+ toast.appendChild(close);
+ }
+
+ stack.appendChild(toast);
+ toast.getBoundingClientRect();
+ requestAnimationFrame(() => {
+ toast.classList.remove('is-entering');
+ });
+
+ if (timeout > 0) {
+ setTimeout(() => dismissToast(toast), timeout);
+ }
+ };
+
+ window.InstallerToast = Object.freeze({
+ showToast
+ });
+})();
diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js
new file mode 100644
index 0000000000..19aa3b63a0
--- /dev/null
+++ b/app/views/install/installer/js/modules/ui.js
@@ -0,0 +1,278 @@
+(() => {
+ const { TIMINGS } = window.InstallerStepsContext || {};
+ const { formState } = window.InstallerStepsState || {};
+
+ const clearFieldErrors = (root) => {
+ if (!root) return;
+ root.querySelectorAll('.field-error').forEach((node) => {
+ node.classList.remove('is-visible');
+ });
+ root.querySelectorAll('.input-field.is-error, .input-action.is-error').forEach((node) => {
+ node.classList.remove('is-error');
+ });
+ root.querySelectorAll('.field-helper').forEach((helper) => {
+ helper.style.display = '';
+ });
+ };
+
+ const setFieldError = (input, message) => {
+ if (!input) return;
+ const group = input.closest('.input-group');
+ if (!group) return;
+ let error = group.querySelector('.field-error');
+ let errorText = error?.querySelector('.field-error-text');
+ const hasSameMessage = Boolean(errorText && errorText.textContent === message);
+ const alreadyVisible = Boolean(error && error.classList.contains('is-visible'));
+
+ if (hasSameMessage && alreadyVisible) {
+ return;
+ }
+
+ if (!error) {
+ const template = document.getElementById('field-error-template');
+ if (template && template.content) {
+ const fragment = template.content.cloneNode(true);
+ error = fragment.querySelector('.field-error');
+ group.appendChild(fragment);
+ }
+ errorText = error?.querySelector('.field-error-text');
+ }
+ if (errorText) {
+ errorText.textContent = message;
+ }
+
+ if (!alreadyVisible) {
+ requestAnimationFrame(() => {
+ error.classList.add('is-visible');
+ });
+ }
+
+ input.classList.add('is-error');
+ const actionWrapper = input.closest('.input-action');
+ if (actionWrapper) {
+ actionWrapper.classList.add('is-error');
+ }
+ const helper = group.querySelector('.field-helper');
+ if (helper) {
+ helper.style.display = 'none';
+ }
+ };
+
+ const bindErrorClear = (input) => {
+ if (!input) return;
+ const handler = () => {
+ const group = input.closest('.input-group');
+ const error = group?.querySelector('.field-error');
+ if (error) {
+ error.classList.remove('is-visible');
+ }
+ input.classList.remove('is-error');
+ const actionWrapper = input.closest('.input-action');
+ if (actionWrapper) {
+ actionWrapper.classList.remove('is-error');
+ }
+ const helper = group?.querySelector('.field-helper');
+ if (helper) {
+ helper.style.display = '';
+ }
+ };
+ input.addEventListener('input', handler);
+ input.addEventListener('change', handler);
+ };
+
+ const toDatabaseLabel = (value) => {
+ if (!value) return '';
+ return value.toLowerCase() === 'mariadb' ? 'MariaDB' : 'MongoDB';
+ };
+
+ const updateDatabaseSelection = (radio, root) => {
+ if (!radio || !root) return;
+ const allOptions = root.querySelectorAll('.selector-card');
+ allOptions.forEach((option) => option.classList.remove('selected'));
+ const selectedOption = radio.closest('.selector-card');
+ if (selectedOption) {
+ selectedOption.classList.add('selected');
+ }
+ };
+
+ const syncResetButton = (input, button) => {
+ const defaultValue = input.dataset.default ?? '';
+ button.disabled = input.value === defaultValue;
+ };
+
+ const setupResetButtons = (root) => {
+ const inputs = root.querySelectorAll('.input-field[data-default]');
+ inputs.forEach((input) => {
+ const button = root.querySelector(`[data-reset-target="${input.id}"]`);
+ if (!button) return;
+
+ syncResetButton(input, button);
+
+ input.addEventListener('input', () => syncResetButton(input, button));
+ button.addEventListener('click', () => {
+ input.value = input.dataset.default ?? '';
+ syncResetButton(input, button);
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ });
+ };
+
+ const toggleAccordion = (button) => {
+ const content = button.nextElementSibling;
+ const icon = button.querySelector('.accordion-chevron');
+ const isOpen = button.classList.contains('is-open');
+
+ button.classList.toggle('is-open', !isOpen);
+ button.setAttribute('aria-expanded', String(!isOpen));
+
+ if (content) {
+ if (!isOpen) {
+ content.classList.add('open');
+ content.style.maxHeight = `${content.scrollHeight}px`;
+ } else {
+ content.style.maxHeight = '0px';
+ content.classList.remove('open');
+ }
+ }
+
+ if (icon) {
+ icon.setAttribute('data-open', String(!isOpen));
+ }
+ };
+
+ const setupAccordion = (root) => {
+ const buttons = root.querySelectorAll('.accordion-toggle');
+ buttons.forEach((button) => {
+ button.addEventListener('click', () => toggleAccordion(button));
+ });
+ };
+
+ const openAccordion = (root) => {
+ const toggle = root.querySelector('.accordion-toggle');
+ const content = root.querySelector('.accordion-content');
+ if (!toggle || !content) return;
+ if (!toggle.classList.contains('is-open')) {
+ toggle.classList.add('is-open');
+ toggle.setAttribute('aria-expanded', 'true');
+ content.classList.add('open');
+ content.style.maxHeight = `${content.scrollHeight}px`;
+ }
+ };
+
+ const disableControls = (root) => {
+ const inputs = root.querySelectorAll('input, select, textarea');
+ inputs.forEach((input) => {
+ if (input.type === 'radio' || input.type === 'checkbox') {
+ input.disabled = true;
+ } else {
+ input.readOnly = true;
+ input.setAttribute('aria-disabled', 'true');
+ }
+ });
+
+ const buttons = root.querySelectorAll('button');
+ buttons.forEach((button) => {
+ if (button.matches('[data-copy-target]')) return;
+ button.disabled = true;
+ button.setAttribute('aria-disabled', 'true');
+ });
+
+ root.classList.add('is-locked');
+ };
+
+ const generateSecretKey = () => {
+ const array = new Uint8Array(32);
+ window.crypto.getRandomValues(array);
+ return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
+ };
+
+ const copyToClipboard = (value, input) => {
+ if (!value) return;
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(value);
+ return;
+ }
+ if (input) {
+ input.select();
+ document.execCommand('copy');
+ input.setSelectionRange(0, 0);
+ return;
+ }
+ const textArea = document.createElement('textarea');
+ textArea.value = value;
+ textArea.style.position = 'fixed';
+ textArea.style.top = '-9999px';
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+ try {
+ document.execCommand('copy');
+ } catch (error) {} finally {
+ document.body.removeChild(textArea);
+ }
+ };
+
+ const setTooltipText = (wrapper, message) => {
+ if (!wrapper) return;
+ const tooltip = wrapper.querySelector('.tooltip');
+ if (tooltip && message) {
+ tooltip.textContent = message;
+ }
+ };
+
+ const resetTooltipText = (wrapper) => {
+ if (!wrapper) return;
+ const defaultText = wrapper.dataset.tooltipDefault;
+ if (!defaultText) return;
+ setTooltipText(wrapper, defaultText);
+ };
+
+ const updateReviewSummary = (root) => {
+ if (!root) return;
+ const valueNodes = root.querySelectorAll('[data-review-value]');
+ valueNodes.forEach((node) => {
+ const key = node.dataset.reviewValue;
+ if (!key) return;
+ let value = formState?.[key];
+ if (key === 'database') {
+ value = toDatabaseLabel(formState?.database);
+ }
+ if (value) {
+ node.textContent = value;
+ }
+ });
+
+ const badge = root.querySelector('[data-review-badge]');
+ if (badge) {
+ const hasKey = Boolean((formState?.opensslKey || '').trim());
+ badge.textContent = hasKey ? 'Generated' : 'Missing';
+ badge.classList.remove('badge-success', 'badge-warning');
+ badge.classList.add(hasKey ? 'badge-success' : 'badge-warning');
+ }
+
+ const assistantBadge = root.querySelector('[data-review-assistant-badge]');
+ if (assistantBadge) {
+ const hasAssistantKey = Boolean((formState?.assistantOpenAIKey || '').trim());
+ assistantBadge.textContent = hasAssistantKey ? 'Enabled' : 'Disabled';
+ assistantBadge.classList.remove('badge-success', 'badge-neutral');
+ assistantBadge.classList.add(hasAssistantKey ? 'badge-success' : 'badge-neutral');
+ }
+ };
+
+ window.InstallerStepsUI = {
+ clearFieldErrors,
+ setFieldError,
+ bindErrorClear,
+ toDatabaseLabel,
+ updateDatabaseSelection,
+ setupResetButtons,
+ setupAccordion,
+ openAccordion,
+ disableControls,
+ generateSecretKey,
+ copyToClipboard,
+ setTooltipText,
+ resetTooltipText,
+ updateReviewSummary
+ };
+})();
diff --git a/app/views/install/installer/js/modules/validation.js b/app/views/install/installer/js/modules/validation.js
new file mode 100644
index 0000000000..13ab60ef4e
--- /dev/null
+++ b/app/views/install/installer/js/modules/validation.js
@@ -0,0 +1,117 @@
+(() => {
+ const isValidEmail = (email) => {
+ if (!email) return false;
+ const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return re.test(email);
+ };
+
+ const isValidPort = (value) => {
+ const numeric = Number(value);
+ if (!Number.isInteger(numeric)) return false;
+ return numeric >= 1 && numeric <= 65535;
+ };
+
+ const isValidPassword = (value) => {
+ if (!value) return false;
+ return value.length >= 8 && /\S/.test(value);
+ };
+
+ const isValidIPv4 = (host) => {
+ if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return false;
+ return host.split('.').every((part) => {
+ const num = Number(part);
+ return num >= 0 && num <= 255;
+ });
+ };
+
+ const isValidIPv6 = (host) => {
+ try {
+ const url = new URL(`http://[${host}]`);
+ return url.hostname.toLowerCase() === host.toLowerCase();
+ } catch (error) {
+ return false;
+ }
+ };
+
+ const isValidHostnameLabel = (label) => {
+ if (!label || label.length > 63) return false;
+ if (label.startsWith('-') || label.endsWith('-')) return false;
+ return /^[a-zA-Z0-9-]+$/.test(label);
+ };
+
+ const isValidDomain = (host) => {
+ if (host.length > 253) return false;
+ const labels = host.split('.');
+ return labels.every((label) => isValidHostnameLabel(label));
+ };
+
+ const isValidHost = (host) => {
+ if (host === 'localhost') return true;
+ if (isValidIPv4(host)) return true;
+ if (isValidIPv6(host)) return true;
+ return isValidDomain(host);
+ };
+
+ const isValidHostnameInput = (value) => {
+ if (!value) return false;
+ const trimmed = value.trim();
+ if (!trimmed) return false;
+
+ let host = trimmed;
+ let port = null;
+
+ if (trimmed.startsWith('[')) {
+ const match = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/);
+ if (!match) return false;
+ host = match[1] || '';
+ port = match[2] || null;
+ } else {
+ const parts = trimmed.split(':');
+ if (parts.length > 2) return false;
+ if (parts.length === 2) {
+ host = parts[0];
+ port = parts[1];
+ }
+ }
+
+ if (port !== null && port !== '' && !isValidPort(port)) {
+ return false;
+ }
+
+ return isValidHost(host);
+ };
+
+ const extractHostname = (value) => {
+ if (!value) return '';
+ const trimmed = value.trim();
+ if (trimmed.startsWith('[')) {
+ const end = trimmed.indexOf(']');
+ if (end !== -1) {
+ return trimmed.slice(1, end);
+ }
+ return trimmed;
+ }
+ const colonCount = (trimmed.match(/:/g) || []).length;
+ if (colonCount === 1) {
+ return trimmed.split(':')[0];
+ }
+ return trimmed;
+ };
+
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
+
+ const isLocalHost = (host) => {
+ if (!host) return false;
+ const normalized = host.toLowerCase();
+ return LOCAL_HOSTS.has(normalized);
+ };
+
+ window.InstallerStepsValidation = {
+ isValidEmail,
+ isValidPort,
+ isValidPassword,
+ isValidHostnameInput,
+ extractHostname,
+ isLocalHost
+ };
+})();
diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js
new file mode 100644
index 0000000000..77c4600227
--- /dev/null
+++ b/app/views/install/installer/js/steps.js
@@ -0,0 +1,434 @@
+(() => {
+ const Context = window.InstallerStepsContext || {};
+ const State = window.InstallerStepsState || {};
+ const Validation = window.InstallerStepsValidation || {};
+ const UI = window.InstallerStepsUI || {};
+ const Progress = window.InstallerStepsProgress || {};
+ const Tooltips = window.InstallerTooltips || null;
+
+ const {
+ INSTALLATION_STEPS,
+ clampStep
+ } = Context;
+
+ const {
+ formState,
+ dispatchStateChange,
+ applyBodyDefaults,
+ applyLockPayload,
+ clearInstallLock,
+ clearInstallId,
+ isInstallLocked,
+ syncInstallLockFlag,
+ getInstallLock,
+ getLockedDatabase
+ } = State;
+
+ const {
+ isValidEmail,
+ isValidPort,
+ isValidHostnameInput,
+ isValidPassword
+ } = Validation;
+
+ const {
+ clearFieldErrors,
+ setFieldError,
+ bindErrorClear,
+ updateDatabaseSelection,
+ setupResetButtons,
+ setupAccordion,
+ openAccordion,
+ disableControls,
+ generateSecretKey,
+ copyToClipboard,
+ setTooltipText,
+ resetTooltipText,
+ updateReviewSummary
+ } = UI;
+
+ let reviewListener = null;
+
+ const bindInputToState = (input, key) => {
+ if (!input) return;
+ const update = () => {
+ formState[key] = input.value;
+ dispatchStateChange?.(key);
+ };
+ input.addEventListener('input', update);
+ input.addEventListener('change', update);
+ update();
+ };
+
+ const lockDatabaseSelection = (root, lockedDatabase) => {
+ if (lockedDatabase) {
+ const radios = root.querySelectorAll('input[name="database"]');
+ radios.forEach((radio) => {
+ const isLockedChoice = radio.value === lockedDatabase;
+ const card = radio.closest('.selector-card');
+ radio.disabled = !isLockedChoice;
+ if (card) {
+ card.classList.toggle('is-disabled', !isLockedChoice);
+ }
+ if (isLockedChoice) {
+ radio.checked = true;
+ updateDatabaseSelection?.(radio, root);
+ }
+ });
+ }
+ };
+
+ const bindDatabaseSelection = (root) => {
+ const radios = root.querySelectorAll('input[name="database"]');
+ radios.forEach((radio) => {
+ radio.addEventListener('change', () => {
+ formState.database = radio.value;
+ updateDatabaseSelection?.(radio, root);
+ });
+ });
+ };
+
+ const hydrateStep1State = (root) => {
+ State.setStateIfEmpty?.('appDomain', root.querySelector('#hostname')?.value);
+ State.setStateIfEmpty?.('database', root.querySelector('input[name="database"]:checked')?.value);
+ State.setStateIfEmpty?.('httpPort', root.querySelector('#http-port')?.value);
+ State.setStateIfEmpty?.('httpsPort', root.querySelector('#https-port')?.value);
+ State.setStateIfEmpty?.('emailCertificates', root.querySelector('#ssl-email')?.value);
+ State.setStateIfEmpty?.('assistantOpenAIKey', root.querySelector('#assistant-openai-key')?.value);
+ };
+
+ const applyStep1State = (root) => {
+ const hostname = root.querySelector('#hostname');
+ if (hostname && formState.appDomain) hostname.value = formState.appDomain;
+
+ const httpPort = root.querySelector('#http-port');
+ if (httpPort && formState.httpPort) httpPort.value = formState.httpPort;
+
+ const httpsPort = root.querySelector('#https-port');
+ if (httpsPort && formState.httpsPort) httpsPort.value = formState.httpsPort;
+
+ const sslEmail = root.querySelector('#ssl-email');
+ if (sslEmail && formState.emailCertificates) sslEmail.value = formState.emailCertificates;
+
+ const assistantKey = root.querySelector('#assistant-openai-key');
+ if (assistantKey && formState.assistantOpenAIKey) {
+ assistantKey.value = formState.assistantOpenAIKey;
+ }
+
+ if (formState.database) {
+ const radio = root.querySelector(`input[name="database"][value="${formState.database}"]`);
+ if (radio) {
+ radio.checked = true;
+ updateDatabaseSelection?.(radio, root);
+ }
+ }
+ };
+
+ const initStep1 = (root) => {
+ if (!root) return;
+ syncInstallLockFlag?.();
+ applyLockPayload?.();
+ applyBodyDefaults?.();
+ hydrateStep1State(root);
+ applyStep1State(root);
+
+ if (isInstallLocked?.()) {
+ openAccordion?.(root);
+ disableControls?.(root);
+ return;
+ }
+
+ const lockedDatabase = getLockedDatabase?.() || '';
+ if (lockedDatabase) {
+ lockDatabaseSelection(root, lockedDatabase);
+ } else {
+ bindDatabaseSelection(root);
+ }
+
+ const hostname = root.querySelector('#hostname');
+ const httpPort = root.querySelector('#http-port');
+ const httpsPort = root.querySelector('#https-port');
+ const sslEmail = root.querySelector('#ssl-email');
+ const assistantKey = root.querySelector('#assistant-openai-key');
+
+ bindInputToState(hostname, 'appDomain');
+ bindInputToState(httpPort, 'httpPort');
+ bindInputToState(httpsPort, 'httpsPort');
+ bindInputToState(sslEmail, 'emailCertificates');
+ bindInputToState(assistantKey, 'assistantOpenAIKey');
+
+ bindErrorClear?.(hostname);
+ bindErrorClear?.(httpPort);
+ bindErrorClear?.(httpsPort);
+ bindErrorClear?.(sslEmail);
+ bindErrorClear?.(assistantKey);
+
+ const checked = root.querySelector('input[name="database"]:checked');
+ if (checked) {
+ updateDatabaseSelection?.(checked, root);
+ }
+
+ setupResetButtons?.(root);
+ setupAccordion?.(root);
+ Tooltips?.setupTooltipPortals?.(root);
+ };
+
+ const hydrateStep2State = (root) => {
+ const value = root.querySelector('#secret-key')?.value;
+ if (formState.opensslKey) return;
+ if (value) {
+ formState.opensslKey = value;
+ }
+ };
+
+ const applyStep2State = (root) => {
+ const input = root.querySelector('#secret-key');
+ if (input && formState.opensslKey) {
+ input.value = formState.opensslKey;
+ }
+ };
+
+ const initStep2 = (root) => {
+ if (!root) return;
+ syncInstallLockFlag?.();
+ applyLockPayload?.();
+ applyBodyDefaults?.();
+ hydrateStep2State(root);
+ if (!formState.opensslKey || !formState.opensslKey.trim()) {
+ formState.opensslKey = generateSecretKey?.();
+ dispatchStateChange?.('opensslKey');
+ }
+ applyStep2State(root);
+
+ const input = root.querySelector('#secret-key');
+ if (input) {
+ bindInputToState(input, 'opensslKey');
+ bindErrorClear?.(input);
+ }
+
+ const copyButton = root.querySelector('[data-copy-target]');
+ const tooltipWrapper = copyButton?.closest('.tooltip-wrapper');
+
+ if (tooltipWrapper) {
+ tooltipWrapper.addEventListener('mouseenter', () => resetTooltipText?.(tooltipWrapper));
+ tooltipWrapper.addEventListener('focusin', () => resetTooltipText?.(tooltipWrapper));
+ }
+
+ if (copyButton) {
+ copyButton.addEventListener('click', () => {
+ const targetId = copyButton.getAttribute('data-copy-target');
+ const targetInput = targetId ? root.querySelector(`#${targetId}`) : null;
+ const value = targetInput?.value || '';
+ copyToClipboard?.(value, targetInput);
+ copyButton.blur();
+
+ if (tooltipWrapper) {
+ const successText = tooltipWrapper.dataset.tooltipSuccess || 'Copied';
+ setTooltipText?.(tooltipWrapper, successText);
+ }
+ });
+ }
+
+ const regenerateButton = root.querySelector('[data-regenerate-target]');
+ if (regenerateButton && !isInstallLocked?.()) {
+ regenerateButton.addEventListener('click', () => {
+ const targetId = regenerateButton.getAttribute('data-regenerate-target');
+ const targetInput = targetId ? root.querySelector(`#${targetId}`) : null;
+ if (!targetInput) return;
+ regenerateButton.classList.remove('is-rotating');
+ void regenerateButton.offsetWidth;
+ regenerateButton.classList.add('is-rotating');
+ const handleAnimationEnd = () => {
+ regenerateButton.classList.remove('is-rotating');
+ };
+ regenerateButton.addEventListener('animationend', handleAnimationEnd, { once: true });
+ targetInput.value = generateSecretKey?.();
+ targetInput.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+ }
+
+ if (isInstallLocked?.()) {
+ disableControls?.(root);
+ }
+ };
+
+ const hydrateStep3State = (root) => {
+ State.setStateIfEmpty?.('accountEmail', root.querySelector('#account-email')?.value);
+ State.setStateIfEmpty?.('accountPassword', root.querySelector('#account-password')?.value);
+ };
+
+ const applyStep3State = (root) => {
+ const email = root.querySelector('#account-email');
+ if (email && formState.accountEmail) email.value = formState.accountEmail;
+
+ const password = root.querySelector('#account-password');
+ if (password && formState.accountPassword) password.value = formState.accountPassword;
+ };
+
+ const initStep3 = (root) => {
+ if (!root) return;
+ syncInstallLockFlag?.();
+ applyLockPayload?.();
+ applyBodyDefaults?.();
+ hydrateStep3State(root);
+ applyStep3State(root);
+
+ const email = root.querySelector('#account-email');
+ const password = root.querySelector('#account-password');
+ const passwordToggle = root.querySelector('[data-password-toggle="account-password"]');
+
+ bindInputToState(email, 'accountEmail');
+ bindInputToState(password, 'accountPassword');
+
+ bindErrorClear?.(email);
+ bindErrorClear?.(password);
+
+ if (password && passwordToggle) {
+ passwordToggle.addEventListener('click', () => {
+ const isVisible = passwordToggle.classList.toggle('is-visible');
+ password.type = isVisible ? 'text' : 'password';
+ passwordToggle.setAttribute('aria-label', isVisible ? 'Hide password' : 'Show password');
+ });
+ }
+
+ if (isInstallLocked?.()) {
+ disableControls?.(root);
+ }
+ };
+
+ const initStep4 = (root) => {
+ if (!root) return;
+ syncInstallLockFlag?.();
+ applyLockPayload?.();
+ applyBodyDefaults?.();
+ updateReviewSummary?.(root);
+ if (reviewListener) {
+ document.removeEventListener('installer:state-change', reviewListener);
+ }
+ reviewListener = () => updateReviewSummary?.(root);
+ document.addEventListener('installer:state-change', reviewListener);
+ if (isInstallLocked?.()) {
+ disableControls?.(root);
+ }
+ };
+
+ const initStep = (step, container) => {
+ if (!container) return;
+ const root = container.querySelector('.step-layout') || container;
+ const normalized = clampStep?.(step) ?? 1;
+ Tooltips?.cleanupTooltipPortals?.();
+ if (normalized !== 4 && reviewListener) {
+ document.removeEventListener('installer:state-change', reviewListener);
+ reviewListener = null;
+ }
+ if (normalized !== 5) {
+ Progress.cleanupInstallFlow?.();
+ }
+ if (normalized === 1) initStep1(root);
+ if (normalized === 2) initStep2(root);
+ if (normalized === 3) initStep3(root);
+ if (normalized === 4) initStep4(root);
+ if (normalized === 5) Progress.initStep5?.(root);
+ };
+
+ window.InstallerSteps = {
+ initStep1,
+ initStep2,
+ initStep3,
+ initStep4,
+ initStep5: Progress.initStep5,
+ installationSteps: INSTALLATION_STEPS || [],
+ isInstallLocked,
+ getInstallLock,
+ clearInstallLock,
+ initStep,
+ validateStep: (step, container) => {
+ const root = container?.querySelector('.step-layout') || container;
+ const normalized = clampStep?.(step) ?? 1;
+ if (normalized === 1) {
+ clearFieldErrors?.(root);
+ let valid = true;
+ const hostname = root?.querySelector('#hostname');
+ const httpPort = root?.querySelector('#http-port');
+ const httpsPort = root?.querySelector('#https-port');
+ const sslEmail = root?.querySelector('#ssl-email');
+
+ if (!hostname || !hostname.value.trim()) {
+ setFieldError?.(hostname, 'Please enter your Appwrite hostname');
+ valid = false;
+ } else if (!isValidHostnameInput?.(hostname.value.trim())) {
+ setFieldError?.(hostname, 'Please enter a valid hostname');
+ valid = false;
+ }
+
+ const parsePort = (input, label) => {
+ const value = input?.value;
+ if (!value || !isValidPort?.(value)) {
+ setFieldError?.(input, `Please enter a valid ${label} port (1-65535)`);
+ return false;
+ }
+ return true;
+ };
+
+ if (!parsePort(httpPort, 'HTTP')) valid = false;
+ if (!parsePort(httpsPort, 'HTTPS')) valid = false;
+
+ if (!sslEmail || !sslEmail.value.trim()) {
+ setFieldError?.(sslEmail, 'Please enter an email address for SSL certificates');
+ valid = false;
+ } else if (!isValidEmail?.(sslEmail.value.trim())) {
+ setFieldError?.(sslEmail, 'Please enter a valid email address');
+ valid = false;
+ }
+
+ if (!valid) {
+ openAccordion?.(root);
+ }
+
+ return valid;
+ }
+
+ if (normalized === 2) {
+ clearFieldErrors?.(root);
+ const secretKey = root?.querySelector('#secret-key');
+ const secretValue = secretKey?.value.trim() || '';
+ if (!secretKey || !secretValue) {
+ setFieldError?.(secretKey, 'Please enter or generate a secret API key');
+ return false;
+ }
+ if (secretValue.length > 64) {
+ setFieldError?.(secretKey, 'Secret API key must be 1-64 characters');
+ return false;
+ }
+ }
+
+ if (normalized === 3) {
+ clearFieldErrors?.(root);
+ let valid = true;
+ const email = root?.querySelector('#account-email');
+ const password = root?.querySelector('#account-password');
+
+ if (!email || !email.value.trim()) {
+ setFieldError?.(email, 'This field is required');
+ valid = false;
+ } else if (!isValidEmail?.(email.value.trim())) {
+ setFieldError?.(email, 'Please enter a valid email address');
+ valid = false;
+ }
+
+ const passwordValue = password?.value ?? '';
+ if (!password || !/\S/.test(passwordValue)) {
+ setFieldError?.(password, 'This field is required');
+ valid = false;
+ } else if (!isValidPassword?.(passwordValue)) {
+ setFieldError?.(password, 'Password must be at least 8 characters long');
+ valid = false;
+ }
+
+ return valid;
+ }
+
+ return true;
+ }
+ };
+})();
diff --git a/app/views/install/installer/js/tooltips.js b/app/views/install/installer/js/tooltips.js
new file mode 100644
index 0000000000..96b637b9a3
--- /dev/null
+++ b/app/views/install/installer/js/tooltips.js
@@ -0,0 +1,77 @@
+(() => {
+ const tooltipPortals = new Set();
+
+ const positionTooltipPortal = (tooltip, anchor) => {
+ if (!tooltip || !anchor) return;
+ const rect = anchor.getBoundingClientRect();
+ const tooltipRect = tooltip.getBoundingClientRect();
+ const offset = Number(tooltip.dataset.tooltipOffset || 6);
+ const padding = 8;
+ let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
+ left = Math.max(padding, Math.min(left, window.innerWidth - tooltipRect.width - padding));
+ const top = rect.bottom + offset;
+ tooltip.style.left = `${left}px`;
+ tooltip.style.top = `${top}px`;
+ };
+
+ const attachTooltipPortal = (tooltip) => {
+ if (!tooltip || tooltip.dataset.portalInitialized === 'true') return;
+ const anchor = tooltip.parentElement;
+ if (!anchor) return;
+
+ tooltip.dataset.portalInitialized = 'true';
+ tooltip.classList.add('tooltip-portal');
+ document.body.appendChild(tooltip);
+
+ const show = () => {
+ tooltip.classList.add('is-open');
+ positionTooltipPortal(tooltip, anchor);
+ };
+ const hide = () => {
+ tooltip.classList.remove('is-open');
+ };
+ const refresh = () => {
+ if (tooltip.classList.contains('is-open')) {
+ positionTooltipPortal(tooltip, anchor);
+ }
+ };
+
+ anchor.addEventListener('mouseenter', show);
+ anchor.addEventListener('mouseleave', hide);
+ anchor.addEventListener('focusin', show);
+ anchor.addEventListener('focusout', hide);
+ window.addEventListener('scroll', refresh, true);
+ window.addEventListener('resize', refresh);
+
+ tooltipPortals.add({
+ tooltip,
+ cleanup: () => {
+ anchor.removeEventListener('mouseenter', show);
+ anchor.removeEventListener('mouseleave', hide);
+ anchor.removeEventListener('focusin', show);
+ anchor.removeEventListener('focusout', hide);
+ window.removeEventListener('scroll', refresh, true);
+ window.removeEventListener('resize', refresh);
+ if (tooltip.parentElement) {
+ tooltip.parentElement.removeChild(tooltip);
+ }
+ }
+ });
+ };
+
+ const setupTooltipPortals = (root) => {
+ if (!root) return;
+ const portalTooltips = root.querySelectorAll('.tooltip[data-tooltip-portal]');
+ portalTooltips.forEach((tooltip) => attachTooltipPortal(tooltip));
+ };
+
+ const cleanupTooltipPortals = () => {
+ tooltipPortals.forEach((entry) => entry.cleanup());
+ tooltipPortals.clear();
+ };
+
+ window.InstallerTooltips = {
+ setupTooltipPortals,
+ cleanupTooltipPortals
+ };
+})();
diff --git a/app/views/install/installer/templates/steps/step-1.phtml b/app/views/install/installer/templates/steps/step-1.phtml
new file mode 100644
index 0000000000..0b331b8e02
--- /dev/null
+++ b/app/views/install/installer/templates/steps/step-1.phtml
@@ -0,0 +1,171 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/install/installer/templates/steps/step-2.phtml b/app/views/install/installer/templates/steps/step-2.phtml
new file mode 100644
index 0000000000..9b7eed11b1
--- /dev/null
+++ b/app/views/install/installer/templates/steps/step-2.phtml
@@ -0,0 +1,58 @@
+
+
+
+
+
Secure your app
+
+
+
+
+
+
+
+
+
+
+
+
+
Save your key
+
You won't be able to see this key again. Copy it somewhere safe before continuing.
+
+
+
+
+
+
+
+
+
diff --git a/app/views/install/installer/templates/steps/step-3.phtml b/app/views/install/installer/templates/steps/step-3.phtml
new file mode 100644
index 0000000000..8eaf0e044b
--- /dev/null
+++ b/app/views/install/installer/templates/steps/step-3.phtml
@@ -0,0 +1,61 @@
+
+
+
+
+
Create your account
+
+ Set up the email and password for your Appwrite account. You can use these
+ credentials to sign in later.
+
+
+
+
+
+
diff --git a/app/views/install/installer/templates/steps/step-4.phtml b/app/views/install/installer/templates/steps/step-4.phtml
new file mode 100644
index 0000000000..4886504eee
--- /dev/null
+++ b/app/views/install/installer/templates/steps/step-4.phtml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
SSL certificate email
+
+
+
Disabled
+
Appwrite Assistant
+
+
+
+
+
+
+
+
diff --git a/app/views/install/installer/templates/steps/step-5.phtml b/app/views/install/installer/templates/steps/step-5.phtml
new file mode 100644
index 0000000000..8fa810b259
--- /dev/null
+++ b/app/views/install/installer/templates/steps/step-5.phtml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/composer.json b/composer.json
index 67aea3d470..83db6e1024 100644
--- a/composer.json
+++ b/composer.json
@@ -14,7 +14,10 @@
"lint": "vendor/bin/pint --test --config pint.json",
"format": "vendor/bin/pint --config pint.json",
"bench": "vendor/bin/phpbench run --report=benchmark",
- "check": "./vendor/bin/phpstan analyse -c phpstan.neon"
+ "check": "./vendor/bin/phpstan analyse -c phpstan.neon",
+ "installer:clean": "php src/Appwrite/Platform/Installer/Server.php --clean",
+ "installer:dev": "docker compose build && composer installer:clean && php src/Appwrite/Platform/Installer/Server.php --docker",
+ "installer:dev-upgrade": "docker compose build && php src/Appwrite/Platform/Installer/Server.php --docker --upgrade"
},
"autoload": {
"psr-4": {
diff --git a/docs/WEB_INSTALLER.md b/docs/WEB_INSTALLER.md
new file mode 100644
index 0000000000..436bd22a50
--- /dev/null
+++ b/docs/WEB_INSTALLER.md
@@ -0,0 +1,131 @@
+# Appwrite Web Installer
+
+The Appwrite Web Installer provides a modern, user-friendly web interface for installing Appwrite. It runs in a
+temporary Docker container with its own web server before the Appwrite stack is created.
+
+## Architecture
+
+The web installer runs as part of the installation task in a lightweight PHP built-in web server:
+
+```
+docker run (temporary container)
+ └── Server.php
+ ├── PHP built-in server (0.0.0.0:20080)
+ ├── HttpHandler (routing, CSRF, SSE)
+ ├── State (progress tracking)
+ └── Config (installation settings)
+```
+
+## Features
+
+- Retry failed steps with payload validation
+- Step-by-step guided installation with real-time validation
+- Progress tracking with Server-Sent Events and polling fallback
+
+## Usage
+
+### Docker
+
+```bash
+docker run -it --rm \
+ --name appwrite-installer \
+ -p 20080:20080 \
+ --volume /var/run/docker.sock:/var/run/docker.sock \
+ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
+ --entrypoint="install" \
+ appwrite/appwrite:
+```
+
+Then open http://localhost:20080 in your browser.
+
+### Composer (Development)
+
+```bash
+composer installer:dev # Start web installer in Docker
+composer installer:dev-upgrade # Start web installer in upgrade mode
+composer installer:clean # Clean up installer containers and temp files
+```
+
+### CLI Mode
+
+```bash
+docker run -it --rm \
+ --name appwrite-installer \
+ --volume /var/run/docker.sock:/var/run/docker.sock \
+ --volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
+ --entrypoint="install" \
+ appwrite/appwrite: \
+ --interactive N
+```
+
+## Files
+
+### Frontend
+
+- `app/views/install/installer.phtml` - Main layout
+- `app/views/install/installer/` - UI components (templates, CSS, JS, icons)
+
+### Backend
+
+- `src/Appwrite/Platform/Installer/**/*.php` - Installer backend (Server, HttpHandler, State, Config)
+- `src/Appwrite/Platform/Tasks/Install.php` - Installation task execution
+- `app/views/install/*.phtml` - Docker Compose and env templates
+
+## Technical Details
+
+### Endpoints
+
+- `GET /` - Installer UI
+- `POST /install` - Start installation (SSE stream or JSON)
+- `GET /install/status?installId=...` - Progress polling
+- `POST /install/validate` - CSRF validation
+- `POST /install/complete` - Notify completion
+
+### URL Parameters
+
+- `?step=1..5` - Jump to specific installation step
+
+### Installation Flow
+
+1. User submits form → CSRF validation
+2. Acquire global lock (prevents concurrent installs)
+3. Stream progress via SSE (or poll if SSE fails)
+4. Generate `docker-compose.yml` and `.env`
+5. Run `docker compose up -d`
+6. Redirect to console, shutdown server
+
+## Security
+
+- **CSRF**: Cookie tokens with timing-safe comparison
+- **CSP**: Strict headers prevent XSS
+- **Validation**: Ports (1-65535), emails, domains, env var names
+- **Escaping**: All shell commands use `escapeshellarg()`
+- **Path protection**: `realpath()` + `str_starts_with()` checks
+- **File permissions**: Config files created with mode 0600
+- **Global lock**: File-based with exception-safe cleanup
+
+## Troubleshooting
+
+### ERR_EMPTY_RESPONSE
+
+Server binds to `0.0.0.0` (not `localhost`) for Docker port mapping. If `localhost` resolves to IPv6 `::1`, connection
+will fail from host.
+
+### Can't access localhost:20080
+
+Ensure `-p 20080:20080` is in docker run command. Try `http://127.0.0.1:20080` instead.
+
+### Installation fails
+
+- Check Docker is running
+- Verify write permissions for appwrite directory
+- Check browser console for errors
+- Review Docker logs: `docker logs appwrite-installer`
+
+## Support
+
+For issues or questions:
+
+- [GitHub Issues](https://github.com/appwrite/appwrite/issues)
+- [Discord Community](https://appwrite.io/discord)
+- [Documentation](https://appwrite.io/docs)
diff --git a/mongo-entrypoint.sh b/mongo-entrypoint.sh
new file mode 100644
index 0000000000..36b8b930dd
--- /dev/null
+++ b/mongo-entrypoint.sh
@@ -0,0 +1,37 @@
+#!/bin/bash
+set -e
+
+# Start MongoDB in the background
+mongod --replSet rs0 --bind_ip_all &
+
+# Wait for MongoDB to start
+echo "Waiting for MongoDB to start..."
+until mongosh --eval "print('MongoDB is ready')" > /dev/null 2>&1; do
+ sleep 1
+done
+
+# Initialize replica set if not already initialized
+echo "Initializing replica set..."
+mongosh --eval "
+try {
+ rs.status();
+ print('Replica set already initialized');
+} catch (e) {
+ rs.initiate({
+ _id: 'rs0',
+ members: [{ _id: 0, host: 'localhost:27017' }]
+ });
+ print('Replica set initialized');
+}
+"
+
+# Wait for replica set to be ready
+echo "Waiting for replica set to be ready..."
+until mongosh --eval "rs.status().ok" > /dev/null 2>&1; do
+ sleep 1
+done
+
+echo "MongoDB replica set is ready"
+
+# Keep the container running by waiting on the MongoDB process
+wait
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000000..f0f626965c
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,13 @@
+{
+ "name": "@appwrite.io/repo",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@appwrite.io/repo",
+ "version": "1.0.0",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/package.json b/package.json
index 1e2b1be430..aa4371ab22 100644
--- a/package.json
+++ b/package.json
@@ -8,5 +8,20 @@
"files": [
"docs/examples",
"app/config/specs"
- ]
+ ],
+ "version": "1.0.0",
+ "description": "> We just announced Timestamp Overrides for Appwrite Databases - [Learn more](https://appwrite.io/blog/post/announcing-timestamp-overrides)",
+ "main": "index.js",
+ "directories": {
+ "doc": "docs",
+ "test": "tests"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "type": "commonjs",
+ "bugs": {
+ "url": "https://github.com/appwrite/appwrite/issues"
+ },
+ "homepage": "https://github.com/appwrite/appwrite#readme"
}
diff --git a/public/images/logos/mariadb.svg b/public/images/logos/mariadb.svg
new file mode 100644
index 0000000000..0d00509e3d
--- /dev/null
+++ b/public/images/logos/mariadb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/images/logos/mongodb.svg b/public/images/logos/mongodb.svg
new file mode 100644
index 0000000000..54403d5284
--- /dev/null
+++ b/public/images/logos/mongodb.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/Appwrite/Platform/Installer/HttpHandler.php b/src/Appwrite/Platform/Installer/HttpHandler.php
new file mode 100644
index 0000000000..0f610cd9c6
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/HttpHandler.php
@@ -0,0 +1,687 @@
+paths = $paths;
+ $this->state = $state;
+ $this->config = $state->buildConfig();
+ }
+
+ public function handleRequest(): void
+ {
+ $uri = urldecode((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH));
+ if ($uri === '') {
+ $uri = '/';
+ }
+
+ if ($this->handleStaticFiles($uri)) {
+ return;
+ }
+
+ $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
+ $handled = match (true) {
+ $method === 'GET' && $uri === '/install/status' => $this->handleStatusRequest($uri),
+ $method === 'POST' && $uri === '/install/validate' => $this->handleValidateRequest($uri),
+ $method === 'POST' && $uri === '/install/complete' => $this->handleCompleteRequest($uri),
+ $method === 'POST' && $uri === '/install' => $this->handleInstallRequest($uri),
+ $uri === '/' => $this->handleInstallerView($uri),
+ default => false,
+ };
+
+ if ($handled) {
+ return;
+ }
+
+ http_response_code(self::HTTP_NOT_FOUND);
+ echo '404 Not Found';
+ }
+
+ private function handleStaticFiles(string $uri): bool
+ {
+ if ($uri === '/' || $uri === '') {
+ return false;
+ }
+
+ $appViewsPath = realpath($this->paths['views']);
+ $publicPath = realpath($this->paths['public']);
+
+ if (!$appViewsPath || !$publicPath) {
+ return false;
+ }
+
+ $filePath = null;
+ $extension = strtolower(pathinfo($uri, PATHINFO_EXTENSION));
+ if ($extension) {
+ $candidate = realpath($appViewsPath . $uri);
+ if ($candidate && str_starts_with($candidate, $appViewsPath . DIRECTORY_SEPARATOR)) {
+ $filePath = $candidate;
+ }
+ if (!$filePath) {
+ $candidate = realpath($publicPath . $uri);
+ if ($candidate && str_starts_with($candidate, $publicPath . DIRECTORY_SEPARATOR)) {
+ $filePath = $candidate;
+ }
+ }
+ }
+
+ if ($filePath && is_file($filePath)) {
+ $mimeTypes = [
+ 'css' => 'text/css',
+ 'js' => 'application/javascript',
+ 'json' => 'application/json',
+ 'png' => 'image/png',
+ 'jpg' => 'image/jpeg',
+ 'jpeg' => 'image/jpeg',
+ 'gif' => 'image/gif',
+ 'svg' => 'image/svg+xml',
+ 'ico' => 'image/x-icon',
+ 'woff' => 'font/woff',
+ 'woff2' => 'font/woff2',
+ 'ttf' => 'font/ttf',
+ 'eot' => 'application/vnd.ms-fontobject',
+ ];
+
+ if (!isset($mimeTypes[$extension])) {
+ return false;
+ }
+
+ $contentType = $mimeTypes[$extension];
+ header('Content-Type: ' . $contentType);
+ header('Content-Length: ' . filesize($filePath));
+ readfile($filePath);
+ return true;
+ }
+
+ return false;
+ }
+
+ private function handleStatusRequest(string $uri): bool
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'GET' || $uri !== '/install/status') {
+ return false;
+ }
+
+ header('Content-Type: application/json');
+
+ $installId = $this->state->sanitizeInstallId($_GET['installId'] ?? '');
+ if ($installId === '') {
+ http_response_code(self::HTTP_BAD_REQUEST);
+ echo json_encode(['success' => false, 'message' => 'Missing installId']);
+ return true;
+ }
+
+ $path = $this->state->progressFilePath($installId);
+ if (!file_exists($path)) {
+ http_response_code(self::HTTP_NOT_FOUND);
+ echo json_encode(['success' => false, 'message' => 'Install not found']);
+ return true;
+ }
+
+ $data = $this->state->readProgressFile($installId);
+ if ($this->hasPayload($data)) {
+ unset($data['payload']['opensslKey'], $data['payload']['assistantOpenAIKey']);
+ }
+ echo json_encode(['success' => true, 'progress' => $data]);
+ return true;
+ }
+
+ private function handleValidateRequest(string $uri): bool
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install/validate') {
+ return false;
+ }
+
+ header('Content-Type: application/json');
+ $this->validateCsrf(false);
+ echo json_encode(['success' => true]);
+ return true;
+ }
+
+ private function handleCompleteRequest(string $uri): bool
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install/complete') {
+ return false;
+ }
+
+ header('Content-Type: application/json');
+ $this->validateCsrf(false);
+
+ $input = json_decode(file_get_contents('php://input'), true);
+ if (!is_array($input)) {
+ $input = [];
+ }
+ $installId = $this->state->sanitizeInstallId($input['installId'] ?? '');
+ $sessionId = is_string($input['sessionId'] ?? null) ? $input['sessionId'] : null;
+ $sessionSecret = is_string($input['sessionSecret'] ?? null) ? $input['sessionSecret'] : null;
+ $sessionExpire = is_string($input['sessionExpire'] ?? null) ? $input['sessionExpire'] : null;
+
+ if ($installId !== '') {
+ $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
+ }
+
+ @touch(Server::INSTALLER_COMPLETE_FILE);
+
+ if ($sessionSecret) {
+ $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
+ || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
+ $sameSite = $isHttps ? 'None' : 'Lax';
+ $expires = 0;
+ if ($sessionExpire) {
+ $timestamp = strtotime($sessionExpire);
+ if ($timestamp !== false) {
+ $expires = $timestamp;
+ }
+ }
+ $cookieOptions = [
+ 'expires' => $expires,
+ 'path' => '/',
+ 'secure' => $isHttps,
+ 'httponly' => true,
+ 'samesite' => $sameSite,
+ ];
+ setcookie('a_session_console', $sessionSecret, $cookieOptions);
+ setcookie('a_session_console_legacy', $sessionSecret, $cookieOptions);
+ if ($sessionId) {
+ header('X-Appwrite-Session: ' . $sessionId);
+ }
+ }
+
+ @unlink(Server::INSTALLER_CONFIG_FILE);
+
+ echo json_encode(['success' => true]);
+
+ if (function_exists('fastcgi_finish_request')) {
+ fastcgi_finish_request();
+ }
+
+ if (function_exists('posix_getpid')) {
+ $pid = posix_getpid();
+ if ($pid) {
+ $command = 'sh -c ' . escapeshellarg("sleep " . self::SHUTDOWN_DELAY_SECONDS . "; kill $pid >/dev/null 2>&1");
+ @exec($command . ' >/dev/null 2>&1 &');
+ }
+ }
+
+ return true;
+ }
+
+ private function handleInstallRequest(string $uri): bool
+ {
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || $uri !== '/install') {
+ return false;
+ }
+
+ $acceptHeader = $_SERVER['HTTP_ACCEPT'] ?? '';
+ $acceptsStream = stripos($acceptHeader, 'text/event-stream') !== false;
+ $wantsStream = $acceptsStream;
+
+ if ($wantsStream) {
+ header('Content-Type: text/event-stream');
+ header('Cache-Control: no-cache');
+ header('Connection: keep-alive');
+ header('X-Accel-Buffering: no');
+ @ini_set('output_buffering', 'off');
+ @ini_set('zlib.output_compression', '0');
+ @ini_set('implicit_flush', '1');
+ if (function_exists('ob_get_level')) {
+ while (ob_get_level() > 0) {
+ @ob_end_flush();
+ }
+ }
+ @ob_implicit_flush(true);
+
+ echo "event: ping\n";
+ echo "data: {\"time\":" . time() . "}\n\n";
+ @ob_flush();
+ @flush();
+ } else {
+ header('Content-Type: application/json');
+ }
+
+ $this->validateCsrf($wantsStream);
+
+ $rawBody = file_get_contents('php://input');
+ $input = json_decode($rawBody, true);
+
+ if (!is_array($input)) {
+ http_response_code(self::HTTP_BAD_REQUEST);
+ echo json_encode(['success' => false, 'message' => 'Invalid request']);
+ return true;
+ }
+
+ $appDomain = trim((string) ($input['appDomain'] ?? ''));
+ if ($appDomain === '' || !$this->state->isValidAppDomainInput($appDomain)) {
+ $this->respondBadRequest('Please enter a valid hostname', $wantsStream);
+ }
+ $input['appDomain'] = $appDomain;
+
+ $httpPort = $input['httpPort'] ?? '';
+ if (!$this->state->isValidPort($httpPort)) {
+ $this->respondBadRequest('Please enter a valid HTTP port (1-65535)', $wantsStream);
+ }
+
+ $httpsPort = $input['httpsPort'] ?? '';
+ if (!$this->state->isValidPort($httpsPort)) {
+ $this->respondBadRequest('Please enter a valid HTTPS port (1-65535)', $wantsStream);
+ }
+
+ $emailCertificates = trim((string) ($input['emailCertificates'] ?? ''));
+ if ($emailCertificates === '' || !$this->state->isValidEmailAddress($emailCertificates)) {
+ $this->respondBadRequest('Please enter a valid email address', $wantsStream);
+ }
+ $input['emailCertificates'] = $emailCertificates;
+
+ $opensslKey = trim((string) ($input['opensslKey'] ?? ''));
+ if (!$this->state->isValidSecretKey($opensslKey)) {
+ $this->respondBadRequest('Secret API key must be 1-64 characters', $wantsStream);
+ }
+ $input['opensslKey'] = $opensslKey;
+
+ $assistantOpenAIKey = trim((string) ($input['assistantOpenAIKey'] ?? ''));
+ $input['assistantOpenAIKey'] = $assistantOpenAIKey;
+
+ $account = [];
+ if (!$this->config->isUpgrade()) {
+ $accountEmail = trim((string) ($input['accountEmail'] ?? ''));
+ if ($accountEmail === '' || !$this->state->isValidEmailAddress($accountEmail)) {
+ $this->respondBadRequest('Please enter a valid email address', $wantsStream, Server::STEP_ACCOUNT_SETUP);
+ }
+
+ $accountPassword = (string) ($input['accountPassword'] ?? '');
+ if (!$this->state->isValidPassword($accountPassword)) {
+ $this->respondBadRequest('Password must be at least 8 characters', $wantsStream, Server::STEP_ACCOUNT_SETUP);
+ }
+
+ // Derive name from email
+ $accountName = $this->deriveNameFromEmail($accountEmail);
+
+ $input['accountEmail'] = $accountEmail;
+ $input['accountPassword'] = $accountPassword;
+
+ $account = [
+ 'name' => $accountName,
+ 'email' => $accountEmail,
+ 'password' => $accountPassword,
+ ];
+ }
+
+ $lockedDatabase = $this->config->getLockedDatabase();
+ if (!$lockedDatabase) {
+ $database = strtolower(trim((string) ($input['database'] ?? '')));
+ if (!$this->state->isValidDatabaseAdapter($database)) {
+ $this->respondBadRequest('Please select a supported database', $wantsStream);
+ }
+ $input['database'] = $database;
+ }
+
+ $installId = $this->state->sanitizeInstallId($input['installId'] ?? '');
+ if ($installId === '') {
+ $installId = bin2hex(random_bytes(8));
+ }
+
+ @unlink(Server::INSTALLER_COMPLETE_FILE);
+
+ try {
+ $lockResult = $this->state->reserveGlobalLock($installId);
+ } catch (\Throwable $e) {
+ if ($wantsStream) {
+ $this->sendSseEvent(Server::STATUS_ERROR, ['message' => 'Lock failed: ' . $e->getMessage()]);
+ } else {
+ http_response_code(self::HTTP_INTERNAL_SERVER_ERROR);
+ echo json_encode(['success' => false, 'message' => 'Lock failed: ' . $e->getMessage()]);
+ }
+ return true;
+ }
+
+ if ($lockResult !== 'ok') {
+ if ($lockResult === 'locked') {
+ http_response_code(self::HTTP_CONFLICT);
+ echo json_encode(['success' => false, 'message' => 'Installation already in progress']);
+ } else {
+ http_response_code(self::HTTP_SERVICE_UNAVAILABLE);
+ echo json_encode(['success' => false, 'message' => 'Installer lock unavailable']);
+ }
+ return true;
+ }
+
+ $retryStep = $input['retryStep'] ?? null;
+ $allowedRetrySteps = [Server::STEP_DOCKER_COMPOSE, Server::STEP_ENV_VARS, Server::STEP_DOCKER_CONTAINERS];
+ if (!is_string($retryStep) || !in_array($retryStep, $allowedRetrySteps, true)) {
+ $retryStep = null;
+ }
+
+ $existingPath = $this->state->progressFilePath($installId);
+ $existing = null;
+ if (file_exists($existingPath)) {
+ $existing = $this->state->readProgressFile($installId);
+ if (!empty($existing['steps']) && $retryStep === null) {
+ http_response_code(self::HTTP_CONFLICT);
+ echo json_encode(['success' => false, 'message' => 'Installation already started']);
+ return true;
+ }
+ }
+
+ try {
+ ignore_user_abort(true);
+ $this->state->ensureBootstrapped();
+ require_once $this->paths['installPhp'];
+ $installer = new \Appwrite\Platform\Tasks\Install();
+
+ if ($wantsStream) {
+ $this->sendSseEvent('install-id', ['installId' => $installId]);
+ }
+
+ $this->state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS);
+
+ $payloadInput = [
+ '_APP_ENV' => 'production',
+ '_APP_OPENSSL_KEY_V1' => $input['opensslKey'] ?? '',
+ '_APP_DOMAIN' => $input['appDomain'] ?? 'localhost',
+ '_APP_DOMAIN_TARGET' => $input['appDomain'] ?? 'localhost',
+ '_APP_EMAIL_CERTIFICATES' => $input['emailCertificates'] ?? '',
+ '_APP_DB_ADAPTER' => $lockedDatabase ?? ($input['database'] ?? 'mongodb'),
+ '_APP_ASSISTANT_OPENAI_API_KEY' => $input['assistantOpenAIKey'] ?? '',
+ ];
+
+ if ($this->hasPayload($existing)) {
+ $stored = $existing['payload'];
+ $fieldsToCompare = [
+ 'httpPort',
+ 'httpsPort',
+ 'database',
+ 'appDomain',
+ 'emailCertificates',
+ ];
+ foreach ($fieldsToCompare as $field) {
+ if (isset($stored[$field]) && isset($input[$field])) {
+ $storedValue = (string) $stored[$field];
+ $inputValue = (string) $input[$field];
+ if (in_array($field, ['httpPort', 'httpsPort'], true)) {
+ $storedValue = trim($storedValue);
+ $inputValue = trim($inputValue);
+ }
+ if ($storedValue !== $inputValue) {
+ if ($installId !== '') {
+ $this->state->updateGlobalLock($installId, Server::STATUS_ERROR);
+ }
+ $this->respondBadRequest('Installation payload mismatch', $wantsStream);
+ }
+ }
+ }
+
+ $sensitiveFields = [
+ 'opensslKey' => 'opensslKeyHash',
+ 'assistantOpenAIKey' => 'assistantOpenAIKeyHash',
+ ];
+ foreach ($sensitiveFields as $field => $hashField) {
+ if (!isset($stored[$hashField]) && !isset($stored[$field])) {
+ continue;
+ }
+ $incomingHash = $this->state->hashSensitiveValue((string) ($input[$field] ?? ''));
+ if (isset($stored[$hashField])) {
+ if (!hash_equals((string) $stored[$hashField], $incomingHash)) {
+ if ($installId !== '') {
+ $this->state->updateGlobalLock($installId, Server::STATUS_ERROR);
+ }
+ $this->respondBadRequest('Installation payload mismatch', $wantsStream);
+ }
+ } elseif (isset($stored[$field]) && isset($input[$field]) && (string) $stored[$field] !== (string) $input[$field]) {
+ if ($installId !== '') {
+ $this->state->updateGlobalLock($installId, Server::STATUS_ERROR);
+ }
+ $this->respondBadRequest('Installation payload mismatch', $wantsStream);
+ }
+ }
+
+ $payloadInput['_APP_DOMAIN'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN'];
+ $payloadInput['_APP_DOMAIN_TARGET'] = $stored['appDomain'] ?? $payloadInput['_APP_DOMAIN_TARGET'];
+ $payloadInput['_APP_EMAIL_CERTIFICATES'] = $stored['emailCertificates'] ?? $payloadInput['_APP_EMAIL_CERTIFICATES'];
+ $payloadInput['_APP_DB_ADAPTER'] = $lockedDatabase ?? ($stored['database'] ?? $payloadInput['_APP_DB_ADAPTER']);
+ $input['httpPort'] = $stored['httpPort'] ?? $input['httpPort'] ?? $this->config->getDefaultHttpPort();
+ $input['httpsPort'] = $stored['httpsPort'] ?? $input['httpsPort'] ?? $this->config->getDefaultHttpsPort();
+ }
+
+ $vars = $this->config->getVars();
+ $shouldGenerateSecrets = !$installer->hasExistingConfig() && !$this->config->isUpgrade();
+ $envVars = $installer->prepareEnvironmentVariables($payloadInput, $vars, $shouldGenerateSecrets);
+
+ $this->state->writeProgressFile($installId, [
+ 'payload' => [
+ 'httpPort' => $input['httpPort'] ?? $this->config->getDefaultHttpPort(),
+ 'httpsPort' => $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(),
+ 'database' => $lockedDatabase ?? ($input['database'] ?? 'mongodb'),
+ 'appDomain' => $input['appDomain'] ?? 'localhost',
+ 'emailCertificates' => $input['emailCertificates'] ?? '',
+ 'opensslKeyHash' => $this->state->hashSensitiveValue($input['opensslKey'] ?? ''),
+ 'assistantOpenAIKeyHash' => $this->state->hashSensitiveValue($input['assistantOpenAIKey'] ?? ''),
+ ],
+ 'step' => 'start',
+ 'status' => Server::STATUS_IN_PROGRESS,
+ 'message' => 'Installation started',
+ 'updatedAt' => time(),
+ ]);
+
+ $progress = function (string $step, string $status, string $message, array $details = []) use ($installId, $wantsStream) {
+ $payload = [
+ 'installId' => $installId,
+ 'step' => $step,
+ 'status' => $status,
+ 'message' => $message,
+ 'updatedAt' => time(),
+ ];
+ if (!empty($details)) {
+ $payload['details'] = $details;
+ }
+ $this->state->writeProgressFile($installId, $payload);
+ $this->state->updateGlobalLock($installId, Server::STATUS_IN_PROGRESS);
+ if ($wantsStream) {
+ $this->sendSseEvent('progress', $payload);
+ }
+ };
+
+ $installer->performInstallation(
+ $input['httpPort'] ?? $this->config->getDefaultHttpPort(),
+ $input['httpsPort'] ?? $this->config->getDefaultHttpsPort(),
+ $this->config->getOrganization(),
+ $this->config->getImage(),
+ $envVars,
+ $this->config->getNoStart(),
+ $progress,
+ $retryStep,
+ $this->config->isUpgrade(),
+ $account
+ );
+
+ if ($wantsStream) {
+ $this->sendSseEvent('done', ['installId' => $installId, 'success' => true]);
+ usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
+ echo ": keepalive\n\n";
+ @ob_flush();
+ @flush();
+ usleep(self::SSE_KEEPALIVE_DELAY_MICROSECONDS);
+ } else {
+ echo json_encode([
+ 'success' => true,
+ 'installId' => $installId,
+ 'message' => 'Installation completed successfully',
+ ]);
+ }
+ $this->state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
+ } catch (\Throwable $e) {
+ $this->handleInstallationError($e, $installId, $wantsStream);
+ }
+
+ return true;
+ }
+
+ private function handleInstallerView(string $uri): bool
+ {
+ if ($uri !== '/' && $uri !== '') {
+ return false;
+ }
+
+ $csrfToken = $this->makeCsrf();
+ $this->sendInstallerHtmlHeaders();
+
+ $vars = $this->config->getVars();
+ $defaultHttpPort = $this->config->getDefaultHttpPort();
+ $defaultHttpsPort = $this->config->getDefaultHttpsPort();
+ $isUpgrade = $this->config->isUpgrade();
+ $lockedDatabase = $this->config->getLockedDatabase();
+ $isLocalInstall = $this->config->isLocal();
+
+ $defaultEmailCertificates = $vars['_APP_EMAIL_CERTIFICATES']['default'] ?? '';
+ if ($isLocalInstall && empty($defaultEmailCertificates)) {
+ $defaultEmailCertificates = 'walterobrien@example.com';
+ }
+
+ include $this->paths['views'] . '/installer.phtml';
+ return true;
+ }
+
+ private function sendInstallerHtmlHeaders(): void
+ {
+ header('Content-Type: text/html; charset=utf-8');
+ header('Content-Security-Policy: ' . implode('; ', self::INSTALLER_CSP));
+ }
+
+ private function makeCsrf(): string
+ {
+ $existing = $_COOKIE[self::CSRF_COOKIE] ?? '';
+ if (is_string($existing) && $existing !== '') {
+ return $existing;
+ }
+
+ $token = bin2hex(random_bytes(16));
+ setcookie(self::CSRF_COOKIE, $token, [
+ 'path' => '/',
+ 'samesite' => 'Strict',
+ 'httponly' => true,
+ ]);
+ return $token;
+ }
+
+ private function validateCsrf(bool $wantsStream): void
+ {
+ $cookie = $_COOKIE[self::CSRF_COOKIE] ?? '';
+ $header = $_SERVER['HTTP_X_APPWRITE_INSTALLER_CSRF'] ?? '';
+
+ if (!is_string($cookie) || !is_string($header) || $cookie === '' || $header === '' || !hash_equals($cookie, $header)) {
+ $this->respondBadRequest('Invalid CSRF token', $wantsStream);
+ }
+ }
+
+ private function sendSseEvent(string $event, array $payload): void
+ {
+ echo "event: $event\n";
+ echo 'data: ' . json_encode($payload) . "\n\n";
+ @ob_flush();
+ @flush();
+ }
+
+ private function respondBadRequest(string $message, bool $wantsStream, string $step = Server::STEP_CONFIG_FILES): void
+ {
+ if ($wantsStream) {
+ $this->sendSseEvent(Server::STATUS_ERROR, ['message' => $message, 'step' => $step]);
+ } else {
+ http_response_code(self::HTTP_BAD_REQUEST);
+ echo json_encode(['success' => false, 'message' => $message]);
+ }
+ exit;
+ }
+
+ private function buildErrorDetails(\Throwable $e): array
+ {
+ $details = ['trace' => $e->getTraceAsString()];
+ $previous = $e->getPrevious();
+ if ($previous instanceof \Throwable && $previous->getMessage() !== '') {
+ $details['output'] = $previous->getMessage();
+ }
+ return $details;
+ }
+
+ private function hasPayload(mixed $data): bool
+ {
+ return is_array($data) && isset($data['payload']) && is_array($data['payload']);
+ }
+
+ private function handleInstallationError(\Throwable $e, string $installId, bool $wantsStream): void
+ {
+ http_response_code(self::HTTP_INTERNAL_SERVER_ERROR);
+
+ if ($installId !== '') {
+ $this->state->writeProgressFile($installId, [
+ 'step' => Server::STATUS_ERROR,
+ 'status' => Server::STATUS_ERROR,
+ 'message' => $e->getMessage(),
+ 'details' => $this->buildErrorDetails($e),
+ 'updatedAt' => time(),
+ ]);
+ $this->state->updateGlobalLock($installId, Server::STATUS_ERROR);
+ }
+
+ @unlink(Server::INSTALLER_CONFIG_FILE);
+
+ if ($wantsStream) {
+ $this->sendSseEvent(Server::STATUS_ERROR, [
+ 'message' => $e->getMessage(),
+ 'details' => $this->buildErrorDetails($e)
+ ]);
+ } else {
+ echo json_encode(['success' => false, 'message' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * Derives a name from an email address
+ * Examples:
+ * admin@example.com -> Admin
+ * admin.123@example.com -> Admin123
+ */
+ private function deriveNameFromEmail(string $email): string
+ {
+ // extract before @ symbol
+ $parts = explode('@', $email);
+ $username = $parts[0] ?? '';
+
+ // remove all non-alphanumeric characters
+ $cleaned = preg_replace('/[^a-zA-Z0-9]/', '', $username);
+
+ // capitalize
+ return ucfirst($cleaned);
+ }
+}
diff --git a/src/Appwrite/Platform/Installer/Runtime/Config.php b/src/Appwrite/Platform/Installer/Runtime/Config.php
new file mode 100644
index 0000000000..370d9479da
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Runtime/Config.php
@@ -0,0 +1,205 @@
+containsKnownKeys($values)) {
+ $this->setVars($values);
+ return;
+ }
+ $this->apply($values);
+ }
+
+ public function apply(array $values): void
+ {
+ if ($this->hasValidStringValue($values, 'defaultHttpPort')) {
+ $this->setDefaultHttpPort((string) $values['defaultHttpPort']);
+ }
+ if ($this->hasValidStringValue($values, 'defaultHttpsPort')) {
+ $this->setDefaultHttpsPort((string) $values['defaultHttpsPort']);
+ }
+ if ($this->hasValidStringValue($values, 'organization')) {
+ $this->setOrganization((string) $values['organization']);
+ }
+ if ($this->hasValidStringValue($values, 'image')) {
+ $this->setImage((string) $values['image']);
+ }
+ if (array_key_exists('noStart', $values) && $values['noStart'] !== null) {
+ $this->setNoStart((bool) $values['noStart']);
+ }
+ if (array_key_exists('isUpgrade', $values) && $values['isUpgrade'] !== null) {
+ $this->setIsUpgrade((bool) $values['isUpgrade']);
+ }
+ if (array_key_exists('isLocal', $values) && $values['isLocal'] !== null) {
+ $this->setIsLocal((bool) $values['isLocal']);
+ }
+ if (array_key_exists('hostPath', $values)) {
+ $hostPath = $values['hostPath'];
+ $this->setHostPath($hostPath !== null && $hostPath !== '' ? (string) $hostPath : null);
+ }
+ if ($this->hasValidStringValue($values, 'lockedDatabase')) {
+ $this->setLockedDatabase((string) $values['lockedDatabase']);
+ }
+ if (array_key_exists('vars', $values) && is_array($values['vars'])) {
+ $this->setVars($values['vars']);
+ }
+ }
+
+ private function hasValidStringValue(array $values, string $key): bool
+ {
+ return array_key_exists($key, $values) && $values[$key] !== null && $values[$key] !== '';
+ }
+
+ private function containsKnownKeys(array $values): bool
+ {
+ foreach (self::KNOWN_KEYS as $key) {
+ if (array_key_exists($key, $values)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'defaultHttpPort' => $this->defaultHttpPort,
+ 'defaultHttpsPort' => $this->defaultHttpsPort,
+ 'organization' => $this->organization,
+ 'image' => $this->image,
+ 'noStart' => $this->noStart,
+ 'vars' => $this->vars,
+ 'isUpgrade' => $this->isUpgrade,
+ 'isLocal' => $this->isLocal,
+ 'hostPath' => $this->hostPath,
+ 'lockedDatabase' => $this->lockedDatabase,
+ ];
+ }
+
+ public function getDefaultHttpPort(): string
+ {
+ return $this->defaultHttpPort;
+ }
+
+ public function setDefaultHttpPort(string $value): void
+ {
+ $this->defaultHttpPort = $value;
+ }
+
+ public function getDefaultHttpsPort(): string
+ {
+ return $this->defaultHttpsPort;
+ }
+
+ public function setDefaultHttpsPort(string $value): void
+ {
+ $this->defaultHttpsPort = $value;
+ }
+
+ public function getOrganization(): string
+ {
+ return $this->organization;
+ }
+
+ public function setOrganization(string $value): void
+ {
+ $this->organization = $value;
+ }
+
+ public function getImage(): string
+ {
+ return $this->image;
+ }
+
+ public function setImage(string $value): void
+ {
+ $this->image = $value;
+ }
+
+ public function getNoStart(): bool
+ {
+ return $this->noStart;
+ }
+
+ public function setNoStart(bool $value): void
+ {
+ $this->noStart = $value;
+ }
+
+ public function getVars(): array
+ {
+ return $this->vars;
+ }
+
+ public function setVars(array $vars): void
+ {
+ $this->vars = $vars;
+ }
+
+ public function isUpgrade(): bool
+ {
+ return $this->isUpgrade;
+ }
+
+ public function setIsUpgrade(bool $value): void
+ {
+ $this->isUpgrade = $value;
+ }
+
+ public function isLocal(): bool
+ {
+ return $this->isLocal;
+ }
+
+ public function setIsLocal(bool $value): void
+ {
+ $this->isLocal = $value;
+ }
+
+ public function getHostPath(): ?string
+ {
+ return $this->hostPath;
+ }
+
+ public function setHostPath(?string $value): void
+ {
+ $this->hostPath = $value;
+ }
+
+ public function getLockedDatabase(): ?string
+ {
+ return $this->lockedDatabase;
+ }
+
+ public function setLockedDatabase(?string $value): void
+ {
+ $this->lockedDatabase = $value;
+ }
+}
diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php
new file mode 100644
index 0000000000..ab20297a80
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Runtime/State.php
@@ -0,0 +1,421 @@
+paths = $paths;
+ }
+
+ public function buildConfig(array $overrides = [], bool $useEnv = true): Config
+ {
+ $cfg = new Config();
+ $configJson = null;
+ $decodedOk = false;
+ if ($useEnv) {
+ $configJson = getenv('APPWRITE_INSTALLER_CONFIG');
+ if ($configJson !== false && $configJson !== '') {
+ $decoded = json_decode($configJson, true);
+ if (is_array($decoded)) {
+ $cfg->apply($decoded);
+ $decodedOk = true;
+ }
+ }
+ }
+ if ($useEnv && (!$decodedOk)) {
+ $fileConfig = $this->readConfigFile();
+ if (is_array($fileConfig)) {
+ $cfg->apply($fileConfig);
+ }
+ }
+
+ if ($cfg->isLocal() && empty($cfg->getVars())) {
+ $envPath = dirname($this->paths['init'], 2) . '/.env';
+ if (file_exists($envPath)) {
+ $envContent = file_get_contents($envPath);
+ if ($envContent !== false) {
+ $vars = $this->parseEnvFile($envContent);
+ if (!empty($vars)) {
+ $cfg->setVars($vars);
+ }
+ }
+ }
+ }
+
+ $cfg->apply($overrides);
+
+ return $cfg;
+ }
+
+ public function applyEnvConfig(Config|array $cfg): void
+ {
+ $values = $cfg instanceof Config ? $cfg->toArray() : $cfg;
+ $json = json_encode($values, JSON_UNESCAPED_SLASHES);
+ if (!is_string($json)) {
+ return;
+ }
+ putenv('APPWRITE_INSTALLER_CONFIG=' . $json);
+ $this->writeConfigFile($json);
+ }
+
+ private function readConfigFile(): ?array
+ {
+ $path = Server::INSTALLER_CONFIG_FILE;
+ if (!file_exists($path)) {
+ return null;
+ }
+ $contents = file_get_contents($path);
+ if ($contents === false || $contents === '') {
+ return null;
+ }
+ $decoded = json_decode($contents, true);
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ private function writeConfigFile(string $json): void
+ {
+ $path = Server::INSTALLER_CONFIG_FILE;
+ if (@file_put_contents($path, $json) === false) {
+ return;
+ }
+ @chmod($path, self::CONFIG_FILE_PERMISSION);
+ }
+
+
+ public function ensureBootstrapped(): void
+ {
+ if ($this->bootstrapped) {
+ return;
+ }
+
+ require_once $this->paths['vendor'];
+ require_once $this->paths['init'];
+ $this->bootstrapped = true;
+ }
+
+ public function sanitizeInstallId($value): string
+ {
+ if (!is_string($value)) {
+ return '';
+ }
+
+ $clean = preg_replace(self::PATTERN_INSTALL_ID_SANITIZE, '', $value);
+ if (!is_string($clean)) {
+ return '';
+ }
+
+ return substr($clean, 0, 64);
+ }
+
+ public function hashSensitiveValue(string $value): string
+ {
+ $trimmed = trim($value);
+ if ($trimmed === '') {
+ return '';
+ }
+ return hash('sha256', $trimmed);
+ }
+
+ public function isValidPort($value): bool
+ {
+ $string = (string) $value;
+ if ($string === '' || !preg_match(self::PATTERN_DIGITS_ONLY, $string)) {
+ return false;
+ }
+ $port = (int) $string;
+ return $port >= self::PORT_MIN && $port <= self::PORT_MAX;
+ }
+
+ public function isValidEmailAddress(string $value): bool
+ {
+ return filter_var($value, FILTER_VALIDATE_EMAIL) !== false;
+ }
+
+ public function isValidPassword(string $value): bool
+ {
+ return strlen($value) >= 8 && preg_match(self::PATTERN_HAS_NON_WHITESPACE, $value) === 1;
+ }
+
+ public function isValidSecretKey(string $value): bool
+ {
+ return $value !== '' && strlen($value) <= 64;
+ }
+
+ public function isValidAccountName(string $value): bool
+ {
+ return trim($value) !== '';
+ }
+
+ public function isValidAppDomainInput(string $value): bool
+ {
+ $value = trim($value);
+ if ($value === '') {
+ return false;
+ }
+
+ $host = $value;
+ $port = null;
+
+ if (str_starts_with($value, '[')) {
+ if (!preg_match(self::PATTERN_IPV6_WITH_PORT, $value, $matches)) {
+ return false;
+ }
+ $host = $matches[1] ?? '';
+ $port = $matches[2] ?? null;
+ } else {
+ $parts = explode(':', $value);
+ if (count($parts) > 2) {
+ return false;
+ }
+ if (count($parts) === 2) {
+ [$host, $port] = $parts;
+ }
+ }
+
+ if ($port !== null && $port !== '' && !$this->isValidPort($port)) {
+ return false;
+ }
+
+ return $this->isValidAppDomain($host);
+ }
+
+ public function isValidDatabaseAdapter(string $value): bool
+ {
+ return in_array($value, ['mongodb', 'mariadb'], true);
+ }
+
+ public function progressFilePath(string $installId): string
+ {
+ return sys_get_temp_dir() . '/appwrite-install-' . $installId . '.json';
+ }
+
+ public function reserveGlobalLock(string $installId): string
+ {
+ return (string) $this->withGlobalLock(function ($handle, $lock) use ($installId) {
+ if (!$handle) {
+ return 'unavailable';
+ }
+ if ($this->isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) {
+ return 'locked';
+ }
+ $payload = [
+ 'installId' => $installId,
+ 'status' => Server::STATUS_IN_PROGRESS,
+ 'updatedAt' => time(),
+ ];
+ ftruncate($handle, 0);
+ rewind($handle);
+ fwrite($handle, json_encode($payload));
+ return 'ok';
+ });
+ }
+
+ public function updateGlobalLock(string $installId, string $status): void
+ {
+ $this->withGlobalLock(function ($handle, $lock) use ($installId, $status) {
+ if (!$handle) {
+ return;
+ }
+ if ($this->isGlobalLockActive($lock) && ($lock['installId'] ?? '') !== $installId) {
+ return;
+ }
+ $payload = [
+ 'installId' => $installId,
+ 'status' => $status,
+ 'updatedAt' => time(),
+ ];
+ ftruncate($handle, 0);
+ rewind($handle);
+ fwrite($handle, json_encode($payload));
+ });
+ }
+
+ public function readProgressFile(string $installId): array
+ {
+ $path = $this->progressFilePath($installId);
+ if (!file_exists($path)) {
+ return [
+ 'installId' => $installId,
+ 'steps' => [],
+ ];
+ }
+
+ $contents = file_get_contents($path);
+ if ($contents === false) {
+ return [
+ 'installId' => $installId,
+ 'steps' => [],
+ ];
+ }
+
+ $data = json_decode($contents, true);
+ if (!is_array($data)) {
+ return [
+ 'installId' => $installId,
+ 'steps' => [],
+ ];
+ }
+
+ return $data;
+ }
+
+ public function writeProgressFile(string $installId, array $payload): void
+ {
+ $data = $this->readProgressFile($installId);
+ if (!isset($data['steps']) || !is_array($data['steps'])) {
+ $data['steps'] = [];
+ }
+
+ if (!empty($payload['step'])) {
+ $data['steps'][$payload['step']] = [
+ 'status' => $payload['status'] ?? Server::STATUS_IN_PROGRESS,
+ 'message' => $payload['message'] ?? '',
+ 'updatedAt' => $payload['updatedAt'] ?? time(),
+ ];
+ }
+
+ if (!empty($payload['status']) && $payload['status'] === Server::STATUS_ERROR) {
+ $data['error'] = $payload['message'] ?? 'Installation failed';
+ }
+
+ if (isset($payload['details']) && is_array($payload['details'])) {
+ $data['details'][$payload['step']] = $payload['details'];
+ }
+
+ if (isset($payload['payload']) && is_array($payload['payload'])) {
+ $data['payload'] = $payload['payload'];
+ if (!isset($data['startedAt'])) {
+ $data['startedAt'] = $payload['updatedAt'] ?? time();
+ }
+ }
+
+ $data['updatedAt'] = $payload['updatedAt'] ?? time();
+
+ file_put_contents($this->progressFilePath($installId), json_encode($data));
+ }
+
+ private function parseEnvFile(string $contents): array
+ {
+ $vars = [];
+ foreach ((array) preg_split(self::PATTERN_LINE_BREAKS, $contents) as $line) {
+ $line = trim($line);
+ if ($line === '' || $line[0] === '#') {
+ continue;
+ }
+ $pos = strpos($line, '=');
+ if ($pos === false) {
+ continue;
+ }
+ $key = trim(substr($line, 0, $pos));
+ $value = trim(substr($line, $pos + 1));
+ if ($key === '') {
+ continue;
+ }
+ $value = $this->stripEnvQuotes($value);
+
+ $vars[] = [
+ 'name' => $key,
+ 'default' => $value,
+ ];
+ }
+
+ return $vars;
+ }
+
+ private function stripEnvQuotes(string $value): string
+ {
+ if ($value === '') {
+ return $value;
+ }
+ $first = $value[0];
+ $last = substr($value, -1);
+ if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
+ $value = substr($value, 1, -1);
+ }
+ return $value;
+ }
+
+ private function globalLockPath(): string
+ {
+ return Server::INSTALLER_LOCK_FILE;
+ }
+
+ private function isGlobalLockActive(?array $lock): bool
+ {
+ if (!$lock || !isset($lock['updatedAt'])) {
+ return false;
+ }
+
+ if (isset($lock['status']) && in_array($lock['status'], [Server::STATUS_COMPLETED, Server::STATUS_ERROR], true)) {
+ return false;
+ }
+
+ if (time() - (int) $lock['updatedAt'] > self::GLOBAL_LOCK_TIMEOUT_SECONDS) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private function withGlobalLock(callable $callback)
+ {
+ $path = $this->globalLockPath();
+ $handle = fopen($path, 'c+');
+ if ($handle === false) {
+ return $callback(null, null);
+ }
+ if (!flock($handle, LOCK_EX)) {
+ fclose($handle);
+ return $callback(null, null);
+ }
+
+ $contents = stream_get_contents($handle);
+ $lock = null;
+ if ($contents !== false && $contents !== '') {
+ $decoded = json_decode($contents, true);
+ if (is_array($decoded)) {
+ $lock = $decoded;
+ }
+ }
+
+ try {
+ $result = $callback($handle, $lock);
+ } finally {
+ fflush($handle);
+ flock($handle, LOCK_UN);
+ fclose($handle);
+ }
+
+ return $result;
+ }
+
+ private function isValidAppDomain(string $value): bool
+ {
+ if ($value === 'localhost') {
+ return true;
+ }
+ if (filter_var($value, FILTER_VALIDATE_IP) !== false) {
+ return true;
+ }
+ return filter_var($value, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
+ }
+}
diff --git a/src/Appwrite/Platform/Installer/Server.php b/src/Appwrite/Platform/Installer/Server.php
new file mode 100644
index 0000000000..d0b4b51ee1
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Server.php
@@ -0,0 +1,326 @@
+initPaths();
+
+ $this->state = new State($this->paths);
+
+ /* launches the install/upgrade entrypoint in Docker. */
+ if (PHP_SAPI === 'cli') {
+ $this->runCli();
+ return;
+ }
+
+ $handler = new HttpHandler($this->paths, $this->state);
+
+ $handler->handleRequest();
+ }
+
+ private function initPaths(): void
+ {
+ if (!empty($this->paths)) {
+ return;
+ }
+
+ $root = dirname(__DIR__, 4);
+ $this->paths = [
+ 'public' => $root . '/public',
+ 'init' => $root . '/app/init.php',
+ 'views' => $root . '/app/views/install',
+ 'vendor' => $root . '/vendor/autoload.php',
+ 'installPhp' => $root . '/src/Appwrite/Platform/Tasks/Install.php',
+ ];
+ }
+
+ private function runCli(): void
+ {
+ $opts = getopt('', ['upgrade', 'locked-database::', 'docker', 'clean']);
+ $cfg = $this->state->buildConfig([], true);
+ $isDocker = isset($opts['docker']);
+ if ($isDocker) {
+ $cfg->setIsLocal(true);
+ if ($cfg->getHostPath() === null) {
+ $cwd = getcwd();
+ if ($cwd !== false) {
+ $cfg->setHostPath($cwd);
+ }
+ }
+ }
+ if (isset($opts['upgrade'])) {
+ $cfg->setIsUpgrade(true);
+ }
+ if (!empty($opts['locked-database'])) {
+ $cfg->setLockedDatabase($opts['locked-database']);
+ }
+ $this->state->applyEnvConfig($cfg);
+
+ $host = self::INSTALLER_WEB_HOST;
+ $port = (string) self::INSTALLER_WEB_PORT;
+
+ if (isset($opts['clean'])) {
+ $this->removeDockerInstallerContainer(self::DEFAULT_CONTAINER);
+ $this->killInstallerDevServers();
+ $this->cleanupWebInstallerFiles();
+ exit(0);
+ }
+
+ if (isset($opts['docker'])) {
+ $this->printInstallerUrl($host, $port);
+ $this->startDockerInstaller($opts);
+ }
+
+ $this->printInstallerUrl($host, $port);
+ $this->startInstallerDevServer($host, $port);
+ }
+
+ private function printInstallerUrl(string $host, string $port): void
+ {
+ $displayHost = $host === self::INSTALLER_WEB_HOST ? 'localhost' : $host;
+ $url = "http://$displayHost:$port";
+ fwrite(STDOUT, "Open $url" . PHP_EOL);
+ }
+
+ private function killInstallerDevServers(): void
+ {
+ $pattern = 'php -S .*app/views/install';
+ $command = 'pkill -f ' . escapeshellarg($pattern) . ' >/dev/null 2>&1';
+ exec($command);
+ }
+
+ private function startInstallerDevServer(string $host, string $port): void
+ {
+ $binary = escapeshellcmd(PHP_BINARY);
+ $address = escapeshellarg("$host:$port");
+ $docroot = escapeshellarg($this->paths['views']);
+ $router = escapeshellarg(__FILE__);
+ $command = "$binary -S $address -t $docroot $router";
+
+ $descriptorSpec = [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ $process = proc_open($command, $descriptorSpec, $pipes);
+ if (!is_resource($process)) {
+ fwrite(STDERR, "Failed to start PHP dev server." . PHP_EOL);
+ exit(1);
+ }
+
+ fclose($pipes[0]);
+ stream_set_blocking($pipes[1], false);
+ stream_set_blocking($pipes[2], false);
+
+ $lastStatus = null;
+ while (true) {
+ $read = [$pipes[1], $pipes[2]];
+ $write = null;
+ $except = null;
+
+ if (stream_select($read, $write, $except, null) === false) {
+ break;
+ }
+
+ foreach ($read as $stream) {
+ $line = fgets($stream);
+ if ($line === false) {
+ continue;
+ }
+
+ if ($stream === $pipes[2] && preg_match(self::DEV_SERVER_START_PATTERN, $line)) {
+ continue;
+ }
+ if (preg_match(self::PATTERN_SERVER_LOG_FILTER, $line)) {
+ continue;
+ }
+
+ $target = ($stream === $pipes[2]) ? STDERR : STDOUT;
+ fwrite($target, $line);
+ }
+
+ $status = proc_get_status($process);
+ $lastStatus = $status;
+ if (!$status['running']) {
+ break;
+ }
+ }
+
+ foreach ($pipes as $pipe) {
+ if (is_resource($pipe)) {
+ fclose($pipe);
+ }
+ }
+
+ $exitCode = proc_close($process);
+ /**
+ * Treat exit code 255 or signaled status as a clean shutdown.
+ */
+ if ($exitCode === 255 || ($lastStatus['signaled'] ?? false)) {
+ $exitCode = 0;
+ }
+ exit($exitCode);
+ }
+
+ private function removeDockerInstallerContainer(string $container): void
+ {
+ $name = escapeshellarg($container);
+ exec("docker rm -f $name >/dev/null 2>&1");
+ }
+
+ private function cleanupWebInstallerFiles(): void
+ {
+ $cwd = getcwd();
+ if ($cwd === false) {
+ return;
+ }
+
+ $filesToRemove = [
+ $cwd . '/.env.web-installer',
+ $cwd . '/docker-compose.web-installer.yml',
+ ];
+
+ foreach ($filesToRemove as $file) {
+ if (file_exists($file)) {
+ @unlink($file);
+ }
+ }
+
+ $tempDir = sys_get_temp_dir();
+ @unlink(self::INSTALLER_LOCK_FILE);
+ @unlink(self::INSTALLER_CONFIG_FILE);
+ foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) {
+ @unlink($file);
+ }
+ }
+
+ private function dockerImageExists(string $image): bool
+ {
+ $result = 1;
+ exec("docker image inspect " . escapeshellarg($image) . " >/dev/null 2>&1", $output, $result);
+ return $result === 0;
+ }
+
+ private function buildDockerInstallerImage(string $image): void
+ {
+ fwrite(STDOUT, "Building Docker image: {$image}\n");
+ $buildCommand = 'docker compose build appwrite';
+ passthru($buildCommand, $status);
+ if ($status !== 0 || !$this->dockerImageExists($image)) {
+ fwrite(STDERR, "Failed to build Docker image: $image\n");
+ fwrite(STDERR, "Try: docker compose build appwrite\n");
+ exit(1);
+ }
+ }
+
+ private function ensureLocalInstallerTag(string $source, string $target): void
+ {
+ $sourceArg = escapeshellarg($source);
+ $targetArg = escapeshellarg($target);
+ exec("docker tag {$sourceArg} {$targetArg}", $tagOutput, $tagStatus);
+ if ($tagStatus !== 0) {
+ fwrite(STDERR, "Failed to tag Docker image {$source} as {$target}\n");
+ exit(1);
+ }
+ }
+
+ private function startDockerInstaller(array $opts): void
+ {
+ $image = self::DEFAULT_IMAGE;
+ $container = self::DEFAULT_CONTAINER;
+ if (!$this->dockerImageExists($image)) {
+ $this->buildDockerInstallerImage($image);
+ }
+ $this->ensureLocalInstallerTag($image, 'appwrite/appwrite:local');
+ $port = (string)self::INSTALLER_WEB_PORT;
+ $entrypoint = isset($opts['upgrade']) ? 'upgrade' : 'install';
+
+ $this->removeDockerInstallerContainer($container);
+
+ $root = realpath(dirname(__DIR__, 4));
+ $volumePath = $root !== false ? $root : (getcwd() ?: '.');
+ $dockerConfig = $this->state->buildConfig([], false);
+ $dockerConfig->setIsLocal(true);
+ $dockerConfig->setHostPath($volumePath);
+ if (isset($opts['upgrade'])) {
+ $dockerConfig->setIsUpgrade(true);
+ }
+ if (!empty($opts['locked-database'])) {
+ $dockerConfig->setLockedDatabase($opts['locked-database']);
+ }
+ $configJson = json_encode($dockerConfig->toArray(), JSON_UNESCAPED_SLASHES);
+ if (!is_string($configJson)) {
+ $configJson = '{}';
+ }
+
+ $args = [
+ 'docker',
+ 'run',
+ '-i',
+ '--rm',
+ '--name', $container,
+ '-p', "127.0.0.1:$port:" . self::INSTALLER_WEB_PORT,
+ '--volume', '/var/run/docker.sock:/var/run/docker.sock',
+ '--volume', "$volumePath:/usr/src/code:rw",
+ ];
+ $args[] = '-e';
+ $args[] = 'APPWRITE_INSTALLER_CONFIG=' . $configJson;
+ $args[] = '--entrypoint=' . $entrypoint;
+ $args[] = $image;
+
+ $command = implode(' ', array_map(escapeshellarg(...), $args));
+ passthru($command, $status);
+ exit($status);
+ }
+}
+
+/**
+ * Run server only on direct execution to avoid Swoole exit exception during autoload.
+ *
+ * @return bool
+ */
+function shouldRunInstallerServer(): bool
+{
+ return PHP_SAPI === 'cli-server' || (PHP_SAPI === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__);
+}
+
+if (shouldRunInstallerServer()) {
+ $server = new Server();
+ $server->run();
+}
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
index cb8b0dd42e..5b7a01888c 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/Get.php
@@ -103,11 +103,9 @@ class Get extends Action
// Use transaction-aware document retrieval if transactionId is provided
if ($transactionId !== null) {
$document = $transactionState->getDocument($collectionTableId, $documentId, $transactionId, $queries);
- } elseif (! empty($selects)) {
- // has selects, allow relationship on documents!
+ } elseif (!empty($selects)) {
$document = $dbForProject->getDocument($collectionTableId, $documentId, $queries);
} else {
- // has no selects, disable relationship looping on documents!
$document = $dbForProject->skipRelationships(fn () => $dbForProject->getDocument($collectionTableId, $documentId, $queries));
}
} catch (QueryException $e) {
diff --git a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
index ff94e67b02..484c034d6e 100644
--- a/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
+++ b/src/Appwrite/Platform/Modules/Databases/Http/Databases/Collections/Documents/XList.php
@@ -16,6 +16,7 @@ use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Exception\Order as OrderException;
use Utopia\Database\Exception\Query as QueryException;
+use Utopia\Database\Exception\Timeout;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\Query\Cursor;
@@ -151,6 +152,8 @@ class XList extends Action
throw new Exception(Exception::DATABASE_QUERY_ORDER_NULL, $message);
} catch (QueryException $e) {
throw new Exception(Exception::GENERAL_QUERY_INVALID, $e->getMessage());
+ } catch (Timeout) {
+ throw new Exception(Exception::DATABASE_TIMEOUT);
}
$operations = 0;
diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php
index e51ea3188a..c354bf9cb7 100644
--- a/src/Appwrite/Platform/Tasks/Install.php
+++ b/src/Appwrite/Platform/Tasks/Install.php
@@ -4,17 +4,36 @@ namespace Appwrite\Platform\Tasks;
use Appwrite\Docker\Compose;
use Appwrite\Docker\Env;
+use Appwrite\Platform\Installer\Server as InstallerServer;
use Appwrite\Utopia\View;
use Utopia\Auth\Proofs\Password;
use Utopia\Auth\Proofs\Token;
use Utopia\CLI\Console;
use Utopia\Config\Config;
+use Utopia\Fetch\Client;
use Utopia\Platform\Action;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class Install extends Action
{
+ private const int INSTALL_STEP_DELAY_SECONDS = 2;
+ private const int WEB_SERVER_CHECK_ATTEMPTS = 10;
+ private const int WEB_SERVER_CHECK_DELAY_SECONDS = 1;
+
+ private const int HEALTH_CHECK_ATTEMPTS = 10;
+ private const int HEALTH_CHECK_DELAY_SECONDS = 3;
+
+ private const string PATTERN_ENV_VAR_NAME = '/^[A-Z0-9_]+$/';
+ private const string PATTERN_DB_PASSWORD_VAR = '/^_APP_DB_.*_PASS$/';
+ private const string PATTERN_SESSION_COOKIE = '/a_session_console=([^;]+)/';
+
+ private const string APPWRITE_API_URL = 'http://appwrite';
+ private const string GROWTH_API_URL = 'https://growth.appwrite.io/v1';
+
+ protected string $hostPath = '';
+ protected ?bool $isLocalInstall = null;
+ protected ?array $installerConfig = null;
protected string $path = '/usr/src/code/appwrite';
public static function getName(): string
@@ -35,12 +54,12 @@ class Install extends Action
->callback($this->action(...));
}
- public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void
+ public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart, bool $isUpgrade = false): void
{
+ $defaultHttpPort = '80';
+ $defaultHttpsPort = '443';
$config = Config::getParam('variables');
- $defaultHTTPPort = '80';
- $defaultHTTPSPort = '443';
- /** @var array> $vars array whre key is variable name and value is variable */
+ /** @var array> $vars array where key is variable name and value is variable */
$vars = [];
foreach ($config as $category) {
@@ -51,6 +70,9 @@ class Install extends Action
Console::success('Starting Appwrite installation...');
+ $isLocalInstall = $this->isLocalInstall();
+ $this->applyLocalPaths($isLocalInstall, true);
+
// Create directory with write permissions
if (!\file_exists(\dirname($this->path))) {
if (!@\mkdir(\dirname($this->path), 0755, true)) {
@@ -59,21 +81,16 @@ class Install extends Action
}
}
- $data = @file_get_contents($this->path . '/docker-compose.yml');
-
- if ($data !== false) {
- if ($interactive == 'Y' && Console::isInteractive()) {
- $answer = Console::confirm('Previous installation found, do you want to overwrite it (a backup will be created before overwriting)? (Y/n)');
-
- if (\strtolower($answer) !== 'y') {
- Console::info('No action taken.');
- return;
- }
- }
+ // Check for existing installation
+ $data = $this->readExistingCompose();
+ $envFileExists = file_exists($this->path . '/' . $this->getEnvFileName());
+ $existingInstallation = $data !== '' || $envFileExists;
+ if ($existingInstallation) {
$time = \time();
- Console::info('Compose file found, creating backup: docker-compose.yml.' . $time . '.backup');
- file_put_contents($this->path . '/docker-compose.yml.' . $time . '.backup', $data);
+ $composeFileName = $this->getComposeFileName();
+ Console::info('Compose file found, creating backup: ' . $composeFileName . '.' . $time . '.backup');
+ file_put_contents($this->path . '/' . $composeFileName . '.' . $time . '.backup', $data);
$compose = new Compose($data);
$appwrite = $compose->getService('appwrite');
$oldVersion = $appwrite?->getImageVersion();
@@ -81,14 +98,14 @@ class Install extends Action
$ports = $compose->getService('traefik')->getPorts();
} catch (\Throwable $th) {
$ports = [
- $defaultHTTPPort => $defaultHTTPPort,
- $defaultHTTPSPort => $defaultHTTPSPort
+ $defaultHttpPort => $defaultHttpPort,
+ $defaultHttpsPort => $defaultHttpsPort
];
Console::warning('Traefik not found. Falling back to default ports.');
}
if ($oldVersion) {
- foreach ($compose->getServices() as $service) { // Fetch all env vars from previous compose file
+ foreach ($compose->getServices() as $service) {
if (!$service) {
continue;
}
@@ -107,12 +124,14 @@ class Install extends Action
}
}
- $data = @file_get_contents($this->path . '/.env');
+ $envData = @file_get_contents($this->path . '/' . $this->getEnvFileName());
- if ($data !== false) { // Fetch all env vars from previous .env file
- Console::info('Env file found, creating backup: .env.' . $time . '.backup');
- file_put_contents($this->path . '/.env.' . $time . '.backup', $data);
- $env = new Env($data);
+ if ($envData !== false) {
+ if (!$isLocalInstall) {
+ Console::info('Env file found, creating backup: .env.' . $time . '.backup');
+ file_put_contents($this->path . '/.env.' . $time . '.backup', $envData);
+ }
+ $env = new Env($envData);
foreach ($env->list() as $key => $value) {
if (is_null($value)) {
@@ -127,40 +146,40 @@ class Install extends Action
}
foreach ($ports as $key => $value) {
- if ($value === $defaultHTTPPort) {
- $defaultHTTPPort = $key;
+ if ($value === $defaultHttpPort) {
+ $defaultHttpPort = $key;
}
- if ($value === $defaultHTTPSPort) {
- $defaultHTTPSPort = $key;
+ if ($value === $defaultHttpsPort) {
+ $defaultHttpsPort = $key;
}
}
}
}
- if (empty($httpPort)) {
- $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHTTPPort . ')');
- $httpPort = ($httpPort) ? $httpPort : $defaultHTTPPort;
- }
+ // If interactive and web mode enabled, start web server
+ if ($interactive === 'Y' && Console::isInteractive()) {
+ Console::success('Starting web installer...');
+ Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT);
+ Console::info('Press Ctrl+C to cancel installation');
- if (empty($httpsPort)) {
- $httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHTTPSPort . ')');
- $httpsPort = ($httpsPort) ? $httpsPort : $defaultHTTPSPort;
+ $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars);
+ return;
}
+ // Fall back to CLI mode
$enableAssistant = false;
$assistantExistsInOldCompose = false;
-
- if ($data !== false && isset($compose)) {
+ if ($existingInstallation && isset($compose)) {
try {
$assistantService = $compose->getService('appwrite-assistant');
$assistantExistsInOldCompose = $assistantService !== null;
} catch (\Throwable) {
- // assistant service doesn't exist, keep default false
+ /* ignore */
}
}
- if ($interactive == 'Y' && Console::isInteractive()) {
+ if ($interactive === 'Y' && Console::isInteractive()) {
$prompt = 'Add Appwrite Assistant? (Y/n)' . ($assistantExistsInOldCompose ? ' [Currently enabled]' : '');
$answer = Console::confirm($prompt);
@@ -173,125 +192,794 @@ class Install extends Action
$enableAssistant = true;
}
- $input = [];
+ if (empty($httpPort)) {
+ $httpPort = Console::confirm('Choose your server HTTP port: (default: ' . $defaultHttpPort . ')');
+ $httpPort = ($httpPort) ?: $defaultHttpPort;
+ }
- $password = new Password();
- $token = new Token();
+ if (empty($httpsPort)) {
+ $httpsPort = Console::confirm('Choose your server HTTPS port: (default: ' . $defaultHttpsPort . ')');
+ $httpsPort = ($httpsPort) ?: $defaultHttpsPort;
+ }
+
+ $userInput = [];
foreach ($vars as $var) {
if ($var['name'] === '_APP_ASSISTANT_OPENAI_API_KEY') {
if (!$enableAssistant) {
- $input[$var['name']] = '';
+ $userInput[$var['name']] = '';
continue;
}
- // key already exists
if (!empty($var['default'])) {
- $input[$var['name']] = $var['default'];
+ $userInput[$var['name']] = $var['default'];
continue;
}
- // if assistant enabled and no key, ask for it
if (Console::isInteractive() && $interactive === 'Y') {
- $input[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:');
- if (empty($input[$var['name']])) {
+ $userInput[$var['name']] = Console::confirm('Enter your OpenAI API key for Appwrite Assistant:');
+ if (empty($userInput[$var['name']])) {
Console::warning('No API key provided. Assistant will be disabled.');
$enableAssistant = false;
- $input[$var['name']] = '';
+ $userInput[$var['name']] = '';
}
- continue;
+ } else {
+ $userInput[$var['name']] = '';
}
- $input[$var['name']] = '';
continue;
}
- if (!empty($var['filter']) && ($interactive !== 'Y' || !Console::isInteractive())) {
- if ($data && $var['default'] !== null) {
- $input[$var['name']] = $var['default'];
- continue;
- }
-
- if ($var['filter'] === 'token') {
- $input[$var['name']] = $token->generate();
- continue;
- }
-
- if ($var['filter'] === 'password') {
- $input[$var['name']] = $password->generate();
- continue;
- }
- }
if (!$var['required'] || !Console::isInteractive() || $interactive !== 'Y') {
- $input[$var['name']] = $var['default'];
continue;
}
- $input[$var['name']] = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')');
+ $value = Console::confirm($var['question'] . ' (default: \'' . $var['default'] . '\')');
- if (empty($input[$var['name']])) {
- $input[$var['name']] = $var['default'];
+ if (!empty($value)) {
+ $userInput[$var['name']] = $value;
}
- if ($var['filter'] === 'domainTarget') {
- if ($input[$var['name']] !== 'localhost') {
- Console::warning("\nIf you haven't already done so, set the following record for {$input[$var['name']]} on your DNS provider:\n");
- $mask = "%-15.15s %-10.10s %-30.30s\n";
- printf($mask, "Type", "Name", "Value");
- printf($mask, "A or AAAA", "@", "");
- Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n");
- }
+ if ($var['filter'] === 'domainTarget' && !empty($value) && $value !== 'localhost') {
+ Console::warning("\nIf you haven't already done so, set the following record for {$value} on your DNS provider:\n");
+ $mask = "%-15.15s %-10.10s %-30.30s\n";
+ printf($mask, "Type", "Name", "Value");
+ printf($mask, "A or AAAA", "@", "");
+ Console::warning("\nUse 'AAAA' if you're using an IPv6 address and 'A' if you're using an IPv4 address.\n");
}
}
- $templateForCompose = new View(__DIR__ . '/../../../../app/views/install/compose.phtml');
- $templateForEnv = new View(__DIR__ . '/../../../../app/views/install/env.phtml');
+ $shouldGenerateSecrets = !$existingInstallation && !$isUpgrade;
+ $input = $this->prepareEnvironmentVariables($userInput, $vars, $shouldGenerateSecrets);
+ $this->performInstallation($httpPort, $httpsPort, $organization, $image, $input, $noStart, null, null, $isUpgrade);
+ }
+
+
+ protected function startWebServer(string $defaultHttpPort, string $defaultHttpsPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
+ {
+ $host = InstallerServer::INSTALLER_WEB_HOST;
+ $port = InstallerServer::INSTALLER_WEB_PORT;
+
+ @unlink(InstallerServer::INSTALLER_COMPLETE_FILE);
+
+ $this->setInstallerConfig([
+ 'defaultHttpPort' => $defaultHttpPort,
+ 'defaultHttpsPort' => $defaultHttpsPort,
+ 'organization' => $organization,
+ 'image' => $image,
+ 'noStart' => $noStart,
+ 'vars' => $vars,
+ 'isUpgrade' => $isUpgrade,
+ 'lockedDatabase' => $lockedDatabase,
+ 'isLocal' => $this->isLocalInstall(),
+ 'hostPath' => $this->hostPath ?: null,
+ ]);
+ $routerScript = dirname(__DIR__) . '/Installer/Server.php';
+ $docroot = $this->buildFromProjectPath('/app/views/install');
+
+ // Start PHP built-in server in background
+ $command = \sprintf(
+ 'php -S %s:%d -t %s %s 2>&1 & echo $!',
+ $host,
+ $port,
+ \escapeshellarg($docroot),
+ \escapeshellarg($routerScript)
+ );
+ $output = [];
+ \exec($command, $output);
+ $pid = isset($output[0]) ? (int) $output[0] : 0;
+
+ \register_shutdown_function(function () use ($pid) {
+ if ($pid > 0 && \function_exists('posix_kill')) {
+ @\posix_kill($pid, SIGTERM);
+ }
+ });
+ \sleep(3);
+
+ if (!$this->waitForWebServer($port)) {
+ Console::warning('Web installer did not respond in time. Please refresh the browser.');
+ return;
+ }
+
+ if ($this->isInstallationComplete($port)) {
+ Console::success('Installation completed.');
+ }
+ }
+
+ public function prepareEnvironmentVariables(array $userInput, array $vars, bool $shouldGenerateSecrets = true): array
+ {
+ $input = [];
+ $password = new Password();
+ $token = new Token();
+
+ // Start with all defaults
+ foreach ($vars as $var) {
+ $filter = $var['filter'] ?? null;
+ $default = $var['default'] ?? null;
+ $hasDefault = $default !== null && $default !== '';
+
+ if ($filter === 'token') {
+ if ($hasDefault) {
+ $input[$var['name']] = $default;
+ } elseif ($shouldGenerateSecrets) {
+ $input[$var['name']] = $token->generate();
+ } else {
+ $input[$var['name']] = '';
+ }
+ } elseif ($filter === 'password') {
+ if ($hasDefault) {
+ $input[$var['name']] = $default;
+ } elseif ($shouldGenerateSecrets) {
+ /*;#+@:/?& broke DSNs locally */
+ $input[$var['name']] = $this->generatePasswordValue($var['name'], $password);
+ } else {
+ $input[$var['name']] = '';
+ }
+ } else {
+ $input[$var['name']] = $default;
+ }
+ }
+
+ // Override with user inputs
+ foreach ($userInput as $key => $value) {
+ if ($value !== null && ($value !== '' || $key === '_APP_ASSISTANT_OPENAI_API_KEY')) {
+ $input[$key] = $value;
+ }
+ }
+
+ foreach ($input as $key => $value) {
+ if (!is_string($value)) {
+ continue;
+ }
+ if (str_contains($value, "\n") || str_contains($value, "\r")) {
+ throw new \InvalidArgumentException('Invalid value for ' . $key);
+ }
+ }
+
+ // Set database-specific connection details
+ $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
+ if ($database === 'mongodb') {
+ $input['_APP_DB_HOST'] = 'mongodb';
+ $input['_APP_DB_PORT'] = 27017;
+ } elseif ($database === 'mariadb') {
+ $input['_APP_DB_HOST'] = 'mariadb';
+ $input['_APP_DB_PORT'] = 3306;
+ }
+
+ return $input;
+ }
+
+ public function hasExistingConfig(): bool
+ {
+ $isLocalInstall = $this->isLocalInstall();
+ $this->applyLocalPaths($isLocalInstall, true);
+
+ if ($this->readExistingCompose() !== '') {
+ return true;
+ }
+
+ return file_exists($this->path . '/' . $this->getEnvFileName());
+ }
+
+ private function updateProgress(?callable $progress, string $step, string $status, array $messages = [], array $details = [], ?string $messageOverride = null): void
+ {
+ if (!$progress) {
+ return;
+ }
+
+ if ($messageOverride !== null) {
+ $message = $messageOverride;
+ } else {
+ $key = $status === InstallerServer::STATUS_COMPLETED ? 'done' : 'start';
+ $message = $messages[$step][$key] ?? null;
+ if ($message === null) {
+ return;
+ }
+ }
+
+ try {
+ $progress($step, $status, $message, $details);
+ } catch (\Throwable $e) {
+ }
+ if ($status === InstallerServer::STATUS_IN_PROGRESS) {
+ sleep(self::INSTALL_STEP_DELAY_SECONDS);
+ }
+ }
+
+ private function setInstallerConfig(array $config): void
+ {
+ $json = json_encode($config, JSON_UNESCAPED_SLASHES);
+ if (!is_string($json)) {
+ return;
+ }
+
+ putenv('APPWRITE_INSTALLER_CONFIG=' . $json);
+ $path = InstallerServer::INSTALLER_CONFIG_FILE;
+ if (@file_put_contents($path, $json) === false) {
+ return;
+ }
+ @chmod($path, 0600);
+ }
+
+ public function performInstallation(
+ string $httpPort,
+ string $httpsPort,
+ string $organization,
+ string $image,
+ array $input,
+ bool $noStart,
+ ?callable $progress = null,
+ ?string $resumeFromStep = null,
+ bool $isUpgrade = false,
+ array $account = []
+ ): void {
+ $isLocalInstall = $this->isLocalInstall();
+ $this->applyLocalPaths($isLocalInstall, false);
+
+ $isCLI = php_sapi_name() === 'cli';
+ if ($isLocalInstall) {
+ $useExistingConfig = false;
+ } else {
+ $useExistingConfig = file_exists($this->path . '/' . $this->getComposeFileName())
+ && file_exists($this->path . '/' . $this->getEnvFileName());
+ }
+
+ if ($isLocalInstall) {
+ $image = 'appwrite';
+ $organization = 'appwrite';
+ }
+
+ $templateForEnv = new View($this->buildFromProjectPath('/app/views/install/env.phtml'));
+ $templateForCompose = new View($this->buildFromProjectPath('/app/views/install/compose.phtml'));
+
+ $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
+
+ $version = \defined('APP_VERSION_STABLE') ? APP_VERSION_STABLE : 'latest';
+ if ($isLocalInstall) {
+ $version = 'local';
+ }
+
+ $assistantKey = (string) ($input['_APP_ASSISTANT_OPENAI_API_KEY'] ?? '');
+ $enableAssistant = trim($assistantKey) !== '';
$templateForCompose
->setParam('httpPort', $httpPort)
->setParam('httpsPort', $httpsPort)
- ->setParam('version', APP_VERSION_STABLE)
+ ->setParam('version', $version)
->setParam('organization', $organization)
->setParam('image', $image)
+ ->setParam('database', $database)
+ ->setParam('hostPath', $this->hostPath)
->setParam('enableAssistant', $enableAssistant);
$templateForEnv->setParam('vars', $input);
- if (!file_put_contents($this->path . '/docker-compose.yml', $templateForCompose->render(false))) {
- $message = 'Failed to save Docker Compose file';
- Console::error($message);
- Console::exit(1);
- }
+ $steps = [
+ InstallerServer::STEP_DOCKER_COMPOSE,
+ InstallerServer::STEP_ENV_VARS,
+ InstallerServer::STEP_DOCKER_CONTAINERS
+ ];
- if (!file_put_contents($this->path . '/.env', $templateForEnv->render(false))) {
- $message = 'Failed to save environment variables file';
- Console::error($message);
- Console::exit(1);
- }
-
- $env = '';
- $stdout = '';
- $stderr = '';
-
- foreach ($input as $key => $value) {
- if ($value) {
- $env .= $key . '=' . \escapeshellarg($value) . ' ';
+ $startIndex = 0;
+ if ($resumeFromStep !== null) {
+ $resumeIndex = array_search($resumeFromStep, $steps, true);
+ if ($resumeIndex !== false) {
+ $startIndex = $resumeIndex;
}
}
- $exit = 0;
- if (!$noStart) {
- Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\"");
- $exit = Console::execute("$env docker compose --project-directory $this->path up -d --remove-orphans --renew-anon-volumes", '', $stdout, $stderr);
- }
+ $currentStep = null;
- if ($exit !== 0) {
- $message = 'Failed to install Appwrite dockers';
- Console::error($message);
- Console::error($stderr);
- Console::exit($exit);
- } else {
- $message = 'Appwrite installed successfully';
- Console::success($message);
+ $messages = $this->buildStepMessages($isUpgrade);
+
+ try {
+ if ($startIndex <= 1) {
+ $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_IN_PROGRESS, $messages);
+ }
+
+ if ($startIndex <= 0) {
+ $currentStep = InstallerServer::STEP_DOCKER_COMPOSE;
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_IN_PROGRESS, $messages);
+
+ if (!$useExistingConfig) {
+ $this->writeComposeFile($templateForCompose);
+ }
+
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_COMPOSE, InstallerServer::STATUS_COMPLETED, $messages);
+ }
+
+ if ($startIndex <= 1) {
+ $currentStep = InstallerServer::STEP_ENV_VARS;
+ $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_IN_PROGRESS, $messages);
+
+ if (!$useExistingConfig) {
+ $this->writeEnvFile($templateForEnv);
+ }
+
+ $this->updateProgress($progress, InstallerServer::STEP_ENV_VARS, InstallerServer::STATUS_COMPLETED, $messages);
+ $this->updateProgress($progress, InstallerServer::STEP_CONFIG_FILES, InstallerServer::STATUS_COMPLETED, $messages);
+ }
+
+ if ($database === 'mongodb' && !$useExistingConfig) {
+ $this->copyMongoEntrypointIfNeeded();
+ }
+
+ if (!$noStart && $startIndex <= 2) {
+ $currentStep = InstallerServer::STEP_DOCKER_CONTAINERS;
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_IN_PROGRESS, $messages);
+ $this->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI);
+ $this->connectInstallerToAppwriteNetwork();
+
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
+
+ if (!$isUpgrade) {
+ // Create console account on fresh install only
+ $this->createInitialAdminAccount($account, $progress);
+ }
+
+ /* track installs for metrics */
+ $this->trackSelfHostedInstall($input, $isUpgrade, $version, $account);
+
+ if ($isCLI) {
+ Console::success('Appwrite installed successfully');
+ }
+ } else {
+ if ($isCLI) {
+ Console::success('Installation files created. Run "docker compose up -d" to start Appwrite');
+ }
+ }
+ } catch (\Throwable $e) {
+ if ($currentStep) {
+ $details = [
+ 'trace' => $e->getTraceAsString()
+ ];
+ $previous = $e->getPrevious();
+ if ($previous instanceof \Throwable && $previous->getMessage() !== '') {
+ $details['output'] = $previous->getMessage();
+ }
+ $this->updateProgress($progress, $currentStep, InstallerServer::STATUS_ERROR, $messages, $details, $e->getMessage());
+ }
+ throw $e;
}
}
+
+ private function createInitialAdminAccount(array $account, ?callable $progress): void
+ {
+ $name = $account['name'] ?? 'Admin';
+ $email = $account['email'] ?? null;
+ $password = $account['password'] ?? null;
+
+ if (!$email || !$password) {
+ return;
+ }
+
+ try {
+ $this->updateProgress(
+ $progress,
+ InstallerServer::STEP_ACCOUNT_SETUP,
+ InstallerServer::STATUS_IN_PROGRESS,
+ messageOverride: 'Creating Appwrite account'
+ );
+
+ $this->waitForApiReady();
+
+ // Create account and session
+ $userId = $this->makeApiCall('/v1/account', [
+ 'userId' => 'unique()',
+ 'email' => $email,
+ 'password' => $password,
+ 'name' => $name
+ ]);
+
+ $session = $this->makeApiCall('/v1/account/sessions/email', [
+ 'email' => $email,
+ 'password' => $password
+ ], true);
+
+ $this->updateProgress(
+ $progress,
+ InstallerServer::STEP_ACCOUNT_SETUP,
+ InstallerServer::STATUS_COMPLETED,
+ details: [
+ 'userId' => $userId,
+ 'sessionId' => $session['id'],
+ 'sessionSecret' => $session['secret'],
+ 'sessionExpire' => $session['expire'] ?? null
+ ],
+ messageOverride: 'Account created successfully'
+ );
+ } catch (\Throwable $e) {
+ $this->updateProgress(
+ $progress,
+ InstallerServer::STEP_ACCOUNT_SETUP,
+ InstallerServer::STATUS_ERROR,
+ messageOverride: 'Account creation failed: ' . $e->getMessage()
+ );
+ }
+ }
+
+ private function trackSelfHostedInstall(array $input, bool $isUpgrade, string $version, array $account): void
+ {
+ if ($this->isLocalInstall()) {
+ return;
+ }
+
+ $appEnv = $input['_APP_ENV'] ?? 'development';
+ $domain = $input['_APP_DOMAIN'] ?? 'localhost';
+
+ /* local or test instance */
+ if ($appEnv !== 'production') {
+ return;
+ }
+
+ /* prod but local or test instance */
+ if ($domain === 'localhost'
+ || str_starts_with($domain, '127.')
+ || str_starts_with($domain, '0.0.0.0')
+ ) {
+ return;
+ }
+
+ $type = $isUpgrade ? 'upgrade' : 'install';
+ $database = $input['_APP_DB_ADAPTER'] ?? 'mongodb';
+ $name = $account['name'] ?? 'Admin';
+ $email = $account['email'] ?? 'admin@selfhosted.local';
+
+ $payload = [
+ 'action' => $type,
+ 'account' => 'self-hosted',
+ 'url' => 'https://' . $domain,
+ 'category' => 'self_hosted',
+ 'label' => 'self_hosted_' . $type,
+ 'version' => $version,
+ 'data' => json_encode([
+ 'name' => $name,
+ 'email' => $email,
+ 'domain' => $domain,
+ 'database' => $database,
+ ]),
+ ];
+
+ try {
+ $client = new Client();
+ $client
+ ->addHeader('Content-Type', 'application/json')
+ ->fetch(self::GROWTH_API_URL . '/analytics', Client::METHOD_POST, $payload);
+ } catch (\Throwable) {
+ // tracking shouldn't block installation
+ }
+ }
+
+ private function waitForApiReady(): void
+ {
+ $client = new Client();
+ $pingHealth = function () use ($client): bool {
+ try {
+ return $client->fetch(self::APPWRITE_API_URL . '/v1/health/version')->getStatusCode() === 200;
+ } catch (\Throwable) {
+ return false;
+ }
+ };
+
+ for ($i = 0; $i < self::HEALTH_CHECK_ATTEMPTS; $i++) {
+ if ($pingHealth()) {
+ return;
+ }
+
+ if ($i < self::HEALTH_CHECK_ATTEMPTS - 1) {
+ sleep(self::HEALTH_CHECK_DELAY_SECONDS);
+ }
+ }
+
+ throw new \Exception('Failed to connect with Appwrite in time');
+ }
+
+ /* easier to just connect and call appwrite endpoint */
+ private function connectInstallerToAppwriteNetwork(): void
+ {
+ $network = escapeshellarg('appwrite');
+ $container = escapeshellarg(InstallerServer::DEFAULT_CONTAINER);
+ @exec("docker network connect $network $container 2>/dev/null");
+ }
+
+ private function makeApiCall(string $endpoint, array $body, bool $extractSession = false)
+ {
+ $client = new Client();
+ $client
+ ->setTimeout(30)
+ ->addHeader('Content-Type', 'application/json')
+ ->addHeader('X-Appwrite-Project', 'console');
+
+ $url = self::APPWRITE_API_URL . $endpoint;
+ $response = $client->fetch($url, Client::METHOD_POST, $body);
+
+ if ($response->getStatusCode() !== 201) {
+ $error = $response->json();
+ $message = $error['message'] ?? 'Unknown error';
+ throw new \Exception($message);
+ }
+
+ $data = $response->json();
+ if (!isset($data['$id'])) {
+ throw new \Exception('API response missing ID field');
+ }
+
+ if ($extractSession) {
+ $headers = $response->getHeaders();
+ $setCookie = $headers['set-cookie'] ?? $headers['Set-Cookie'] ?? null;
+
+ if (!$setCookie || !preg_match(self::PATTERN_SESSION_COOKIE, $setCookie, $matches)) {
+ throw new \Exception('Session created but no cookie found');
+ }
+
+ return [
+ 'id' => $data['$id'],
+ 'secret' => $matches[1],
+ 'expire' => $data['expire'] ?? null
+ ];
+ }
+
+ return $data['$id'];
+ }
+
+ private function buildStepMessages(bool $isUpgrade): array
+ {
+ $isUpgradeLabel = $isUpgrade ? 'updated' : 'created';
+ $verbs = [
+ InstallerServer::STEP_CONFIG_FILES => $isUpgrade ? 'Updating' : 'Creating',
+ InstallerServer::STEP_DOCKER_COMPOSE => $isUpgrade ? 'Updating' : 'Generating',
+ InstallerServer::STEP_ENV_VARS => $isUpgrade ? 'Updating' : 'Configuring',
+ InstallerServer::STEP_DOCKER_CONTAINERS => $isUpgrade ? 'Restarting' : 'Starting',
+ ];
+
+ return [
+ InstallerServer::STEP_CONFIG_FILES => [
+ 'start' => $verbs[InstallerServer::STEP_CONFIG_FILES] . ' configuration files...',
+ 'done' => 'Configuration files ' . $isUpgradeLabel,
+ ],
+ InstallerServer::STEP_DOCKER_COMPOSE => [
+ 'start' => $verbs[InstallerServer::STEP_DOCKER_COMPOSE] . ' Docker Compose file...',
+ 'done' => 'Docker Compose file ' . $isUpgradeLabel,
+ ],
+ InstallerServer::STEP_ENV_VARS => [
+ 'start' => $verbs[InstallerServer::STEP_ENV_VARS] . ' environment variables...',
+ 'done' => 'Environment variables ' . $isUpgradeLabel,
+ ],
+ InstallerServer::STEP_DOCKER_CONTAINERS => [
+ 'start' => $verbs[InstallerServer::STEP_DOCKER_CONTAINERS] . ' Docker containers...',
+ 'done' => $isUpgrade ? 'Docker containers restarted' : 'Docker containers started',
+ ],
+ ];
+ }
+
+ private function writeComposeFile(View $template): void
+ {
+ $composeFileName = $this->getComposeFileName();
+ $targetPath = $this->path . '/' . $composeFileName;
+ $renderedContent = $template->render(false);
+
+ $result = @file_put_contents($targetPath, $renderedContent);
+ if ($result === false) {
+ $lastError = error_get_last();
+ $errorMsg = $lastError ? $lastError['message'] : 'Unknown error';
+ throw new \Exception('Failed to save Docker Compose file: ' . $errorMsg . ' (path: ' . $targetPath . ')');
+ }
+ }
+
+ private function writeEnvFile(View $template): void
+ {
+ $envFileName = $this->getEnvFileName();
+ if (!\file_put_contents($this->path . '/' . $envFileName, $template->render(false))) {
+ throw new \Exception('Failed to save environment variables file');
+ }
+ }
+
+ private function copyMongoEntrypointIfNeeded(): void
+ {
+ $mongoEntrypoint = $this->buildFromProjectPath('/mongo-entrypoint.sh');
+
+ if (file_exists($mongoEntrypoint)) {
+ // Always use container path for file operations
+ copy($mongoEntrypoint, $this->path . '/mongo-entrypoint.sh');
+ }
+ }
+
+ protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI): void
+ {
+ $env = '';
+ if (!$useExistingConfig) {
+ foreach ($input as $key => $value) {
+ if ($value === null || $value === '') {
+ continue;
+ }
+ if (!preg_match(self::PATTERN_ENV_VAR_NAME, $key)) {
+ throw new \Exception("Invalid environment variable name: $key");
+ }
+ $env .= $key . '=' . \escapeshellarg((string) $value) . ' ';
+ }
+ }
+
+ if ($isCLI) {
+ Console::log("Running \"docker compose up -d --remove-orphans --renew-anon-volumes\"");
+ }
+
+ $composeFileName = $this->getComposeFileName();
+ $composeFile = $this->path . '/' . $composeFileName;
+
+ $command = [
+ 'docker',
+ 'compose',
+ '-f',
+ $composeFile,
+ ];
+
+ if ($isLocalInstall) {
+ $command[] = '--project-name';
+ $command[] = 'appwrite';
+ }
+
+ $command[] = '--project-directory';
+ $command[] = $this->path;
+ $command[] = 'up';
+ $command[] = '-d';
+ $command[] = '--remove-orphans';
+ $command[] = '--renew-anon-volumes';
+ $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)) . ' 2>&1';
+ \exec($commandLine, $output, $exit);
+
+ if ($exit !== 0) {
+ $message = trim(implode("\n", $output));
+ $previous = $message !== '' ? new \RuntimeException($message) : null;
+ throw new \RuntimeException('Failed to start containers', 0, $previous);
+ }
+ if ($isLocalInstall && $isCLI && !empty($output)) {
+ Console::log(implode("\n", $output));
+ }
+ }
+
+ protected function isLocalInstall(): bool
+ {
+ if ($this->isLocalInstall === null) {
+ $config = $this->readInstallerConfig();
+ $this->isLocalInstall = !empty($config['isLocal']);
+ }
+
+ return $this->isLocalInstall;
+ }
+
+ protected function readInstallerConfig(): array
+ {
+ if ($this->installerConfig !== null) {
+ return $this->installerConfig;
+ }
+
+ $this->installerConfig = [];
+ $decodeConfig = static function (string $json): ?array {
+ $decoded = json_decode($json, true);
+ return is_array($decoded) ? $decoded : null;
+ };
+
+ $json = getenv('APPWRITE_INSTALLER_CONFIG');
+ $path = InstallerServer::INSTALLER_CONFIG_FILE;
+ $fileJson = file_exists($path) ? file_get_contents($path) : null;
+
+ foreach ([$json, $fileJson] as $candidate) {
+ if (!is_string($candidate) || $candidate === '') {
+ continue;
+ }
+
+ $decoded = $decodeConfig($candidate);
+ if ($decoded !== null) {
+ $this->installerConfig = $decoded;
+ return $this->installerConfig;
+ }
+ }
+
+ return $this->installerConfig;
+ }
+
+ protected function getInstallerHostPath(): string
+ {
+ $config = $this->readInstallerConfig();
+ if (!empty($config['hostPath'])) {
+ return (string) $config['hostPath'];
+ }
+
+ $cwd = getcwd();
+ return $cwd !== false ? $cwd : '.';
+ }
+
+ protected function buildFromProjectPath(string $suffix): string
+ {
+ if ($suffix !== '' && $suffix[0] !== '/') {
+ $suffix = '/' . $suffix;
+ }
+ return dirname(__DIR__, 4) . $suffix;
+ }
+
+ protected function applyLocalPaths(bool $isLocalInstall, bool $force = false): void
+ {
+ if (!$isLocalInstall) {
+ return;
+ }
+ if (!$force && $this->hostPath !== '') {
+ return;
+ }
+ $this->path = '/usr/src/code';
+ $this->hostPath = $this->getInstallerHostPath();
+ }
+
+ protected function readExistingCompose(): string
+ {
+ $composeFile = $this->path . '/' . $this->getComposeFileName();
+ $data = @file_get_contents($composeFile);
+ return !empty($data) ? $data : '';
+ }
+
+ protected function generatePasswordValue(string $varName, Password $password): string
+ {
+ $value = $password->generate();
+ if (!\preg_match(self::PATTERN_DB_PASSWORD_VAR, $varName)) {
+ return $value;
+ }
+
+ return rtrim(strtr(base64_encode(hash('sha256', $value, true)), '+/', '-_'), '=');
+ }
+
+ protected function getComposeFileName(): string
+ {
+ return $this->isLocalInstall() ? 'docker-compose.web-installer.yml' : 'docker-compose.yml';
+ }
+
+ protected function getEnvFileName(): string
+ {
+ return $this->isLocalInstall() ? '.env.web-installer' : '.env';
+ }
+
+ private function isInstallationComplete(int $port): bool
+ {
+ while (true) {
+ if (file_exists(InstallerServer::INSTALLER_COMPLETE_FILE)) {
+ return true;
+ }
+ $handle = @fsockopen('localhost', $port, $errno, $errstr, 1);
+ if ($handle === false) {
+ return false;
+ }
+ \fclose($handle);
+ \sleep(1);
+ }
+ }
+
+ private function waitForWebServer(int $port): bool
+ {
+ for ($attempt = 0; $attempt < self::WEB_SERVER_CHECK_ATTEMPTS; $attempt++) {
+ $handle = @fsockopen('localhost', $port, $errno, $errstr, 1);
+ if ($handle !== false) {
+ \fclose($handle);
+ return true;
+ }
+ \sleep(self::WEB_SERVER_CHECK_DELAY_SECONDS);
+ }
+ return false;
+ }
}
diff --git a/src/Appwrite/Platform/Tasks/Upgrade.php b/src/Appwrite/Platform/Tasks/Upgrade.php
index d6159d2718..45f262bf96 100644
--- a/src/Appwrite/Platform/Tasks/Upgrade.php
+++ b/src/Appwrite/Platform/Tasks/Upgrade.php
@@ -2,12 +2,16 @@
namespace Appwrite\Platform\Tasks;
+use Appwrite\Docker\Env;
use Utopia\CLI\Console;
+use Utopia\System\System;
use Utopia\Validator\Boolean;
use Utopia\Validator\Text;
class Upgrade extends Install
{
+ private ?string $lockedDatabase = null;
+
public static function getName(): string
{
return 'upgrade';
@@ -15,6 +19,8 @@ class Upgrade extends Install
public function __construct()
{
+ parent::__construct();
+
$this
->desc('Upgrade Appwrite')
->param('http-port', '', new Text(4), 'Server HTTP port', true)
@@ -23,22 +29,43 @@ class Upgrade extends Install
->param('image', 'appwrite', new Text(0), 'Main appwrite docker image', true)
->param('interactive', 'Y', new Text(1), 'Run an interactive session', true)
->param('no-start', false, new Boolean(true), 'Run an interactive session', true)
- ->callback($this->action(...));
+ ->callback($this->upgradeAction(...));
}
- public function action(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void
+ public function upgradeAction(string $httpPort, string $httpsPort, string $organization, string $image, string $interactive, bool $noStart): void
{
+ $isLocalInstall = $this->isLocalInstall();
+ $this->applyLocalPaths($isLocalInstall, true);
+
// Check for previous installation
- $data = @file_get_contents($this->path . '/docker-compose.yml');
- if (empty($data)) {
+ if (empty($this->readExistingCompose())) {
Console::error('Appwrite installation not found.');
Console::log('The command was not run in the parent folder of your appwrite installation.');
Console::log('Please navigate to the parent directory of the Appwrite installation and try again.');
Console::log(' parent_directory <= you run the command in this directory');
Console::log(' └── appwrite');
- Console::log(' └── docker-compose.yml');
- Console::exit(1);
+ Console::log(' └── ' . $this->getComposeFileName());
+ return;
}
- parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart);
+
+ $database = null;
+ $envPath = $this->path . '/' . $this->getEnvFileName();
+ $envData = @file_get_contents($envPath);
+ if ($envData !== false) {
+ $env = new Env($envData);
+ $envVars = $env->list();
+ $database = $envVars['_APP_DB_ADAPTER'] ?? null;
+ }
+ if (empty($database)) {
+ $database = System::getEnv('_APP_DB_ADAPTER', 'mongodb');
+ }
+ $this->lockedDatabase = (string) $database;
+
+ parent::action($httpPort, $httpsPort, $organization, $image, $interactive, $noStart, true);
+ }
+
+ protected function startWebServer(string $defaultHttpPort, string $defaultHttpsPort, string $organization, string $image, bool $noStart, array $vars, bool $isUpgrade = false, ?string $lockedDatabase = null): void
+ {
+ parent::startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, true, $this->lockedDatabase);
}
}