diff --git a/app/views/install/compose.phtml b/app/views/install/compose.phtml
index 0f4df352bd..741d085445 100644
--- a/app/views/install/compose.phtml
+++ b/app/views/install/compose.phtml
@@ -13,7 +13,7 @@ $organization = $this->getParam('organization', '');
$image = $this->getParam('image', '');
$enableAssistant = $this->getParam('enableAssistant', false);
$dbService = $this->getParam('database', 'mongodb');
-$allowedDbServices = ['mariadb', 'mongodb', 'postgresql'];
+$allowedDbServices = ['mariadb', 'mongodb'];
if (!\in_array($dbService, $allowedDbServices, true)) {
$dbService = 'mongodb';
}
@@ -194,7 +194,7 @@ $hostPath = rtrim($this->getParam('hostPath', ''), '/');
appwrite-console:
<<: *x-logging
container_name: appwrite-console
- image: /console:7.6.4
+ image: /console:7.8.26
restart: unless-stopped
networks:
- appwrite
diff --git a/app/views/install/installer/css/styles.css b/app/views/install/installer/css/styles.css
index b1d8fe5089..7f253eed46 100644
--- a/app/views/install/installer/css/styles.css
+++ b/app/views/install/installer/css/styles.css
@@ -691,6 +691,19 @@ body {
transform: translateY(10px);
}
+.install-counter {
+ margin-left: auto;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ white-space: nowrap;
+ user-select: none;
+ color: var(--fgcolor-neutral-secondary);
+}
+
+.install-row[data-status='in-progress'] .install-counter:not(:empty) {
+ opacity: 1;
+}
+
.install-row-toggle {
margin-left: auto;
width: 32px;
@@ -897,6 +910,17 @@ body {
gap: var(--gap-m);
}
+.install-global-actions {
+ display: flex;
+ justify-content: center;
+ gap: var(--gap-m);
+ padding: var(--space-4) 0;
+}
+
+.install-global-actions.is-hidden {
+ display: none;
+}
+
.install-error-details .button {
align-self: center;
margin-top: 0;
diff --git a/app/views/install/installer/js/modules/progress.js b/app/views/install/installer/js/modules/progress.js
index bb1fa2f551..f7eb857af2 100644
--- a/app/views/install/installer/js/modules/progress.js
+++ b/app/views/install/installer/js/modules/progress.js
@@ -111,10 +111,10 @@
return normalized.summary || 'Installation failed.';
}
if (status === STATUS.COMPLETED) return step.done;
- return step.inProgress;
+ return message || step.inProgress;
};
- const updateInstallRow = (row, step, status, message) => {
+ const updateInstallRow = (row, step, status, message, details) => {
if (!row || !step) return;
row.dataset.status = status;
row.dataset.step = step.id;
@@ -138,6 +138,15 @@
}
}
+ const counter = row.querySelector('[data-install-counter]');
+ if (counter) {
+ const started = details?.containerStarted ?? 0;
+ const total = details?.containerTotal;
+ counter.textContent = (status === STATUS.IN_PROGRESS && total > 0 && started < total)
+ ? `${started}/${total}`
+ : '';
+ }
+
// Show/hide "Navigate to Console" button for account setup errors
const consoleBtn = row.querySelector('[data-install-console]');
if (consoleBtn) {
@@ -349,7 +358,7 @@
const normalizedDomain = (formState?.appDomain || '').trim() || 'localhost';
const normalizedHttpPort = (formState?.httpPort || '').trim() || '80';
const normalizedHttpsPort = (formState?.httpsPort || '').trim() || '443';
- const normalizedEmail = (formState?.emailCertificates || '').trim();
+ const normalizedEmail = (formState?.emailCertificates || '').trim() || (formState?.accountEmail || '').trim();
const normalizedAssistantKey = (formState?.assistantOpenAIKey || '').trim();
const normalizedAccountEmail = (formState?.accountEmail || '').trim();
const normalizedAccountPassword = (formState?.accountPassword || '').trim();
@@ -529,7 +538,7 @@
if (!state) return;
const row = ensureRow(step);
if (row) {
- updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message);
+ updateInstallRow(row, step, state.status || STATUS.IN_PROGRESS, state.message, state.details);
if (state.status === STATUS?.ERROR) {
updateInstallErrorDetails(row, {
message: state.message,
@@ -579,6 +588,9 @@
}
}
}
+ if (payload.status === STATUS.ERROR) {
+ showGlobalActions();
+ }
scheduleFallback();
};
@@ -616,6 +628,7 @@
const applySnapshot = (snapshot) => {
if (!snapshot || !snapshot.steps) return;
+ let hasErrors = false;
INSTALLATION_STEPS.forEach((step) => {
const detail = snapshot.steps[step.id];
if (!detail) return;
@@ -624,8 +637,14 @@
message: detail.message,
details: snapshot.details?.[step.id]
});
+ if (detail.status === STATUS.ERROR) {
+ hasErrors = true;
+ }
});
renderProgress();
+ if (hasErrors) {
+ showGlobalActions();
+ }
};
const checkAllCompleted = () => {
@@ -674,6 +693,7 @@
}
stopSyncedSpinnerRotation();
setUnloadGuard(false);
+ clearInstallLock?.();
};
const SSL_STEP = {
@@ -890,9 +910,22 @@
}
};
+ const isSnapshotTerminal = (snapshot) => {
+ if (!snapshot?.steps) return true;
+ const stepEntries = Object.values(snapshot.steps);
+ if (stepEntries.length === 0) return true;
+ const hasError = stepEntries.some((s) => s.status === STATUS.ERROR);
+ if (hasError) return true;
+ const allCompleted = INSTALLATION_STEPS.every((step) => {
+ const detail = snapshot.steps[step.id];
+ return detail && detail.status === STATUS.COMPLETED;
+ });
+ return allCompleted;
+ };
+
const resumeInstall = async (installId) => {
const snapshot = await fetchInstallStatus(installId);
- if (!snapshot) return false;
+ if (!snapshot || isSnapshotTerminal(snapshot)) return false;
activeInstall = {
installId,
controller: new AbortController(),
@@ -966,6 +999,60 @@
}
});
+ const globalActions = root.querySelector('[data-install-global-actions]');
+
+ const showGlobalActions = () => {
+ if (globalActions) {
+ globalActions.classList.remove('is-hidden');
+ }
+ };
+
+ const performReset = async (hard) => {
+ const installId = activeInstall?.installId || getInstallLock?.()?.installId || getStoredInstallId?.();
+
+ try {
+ const res = await fetch('/install/reset', {
+ method: 'POST',
+ headers: withCsrfHeader({ 'Content-Type': 'application/json' }),
+ body: JSON.stringify({ installId: installId || '', hard })
+ });
+ if (hard && !res.ok) {
+ const data = await res.json().catch(() => ({}));
+ showToast?.({
+ status: 'error',
+ title: 'Reset failed',
+ description: data?.message || 'Could not stop containers. Try running "docker compose down -v" manually.',
+ dismissible: true
+ });
+ return;
+ }
+ } catch (e) {
+ console.error('Reset request failed:', e);
+ }
+
+ clearInstallLock?.();
+ clearInstallId?.();
+ cleanupInstallFlow();
+ window.location.href = '/?step=1';
+ };
+
+ const startOverButton = root.querySelector('[data-install-start-over]');
+ if (startOverButton) {
+ startOverButton.addEventListener('click', () => performReset(false));
+ }
+
+ const hardResetButton = root.querySelector('[data-install-hard-reset]');
+ if (hardResetButton) {
+ hardResetButton.addEventListener('click', () => {
+ const confirmed = window.confirm(
+ 'This will stop all containers, remove all volumes (including database data, uploads, and certificates), and delete configuration files.\n\nThis action cannot be undone. Continue?'
+ );
+ if (confirmed) {
+ performReset(true);
+ }
+ });
+ }
+
// When the user switches back to this tab, check if installation
// finished while the tab was in the background.
document.addEventListener('visibilitychange', () => {
@@ -974,22 +1061,24 @@
}
});
+ const startFreshInstall = () => {
+ clearInstallId?.();
+ clearInstallLock?.();
+ const newInstallId = generateInstallId();
+ storeInstallId?.(newInstallId);
+ startInstallStream(newInstallId);
+ };
+
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);
+ startFreshInstall();
}
});
} else {
- const newInstallId = generateInstallId();
- storeInstallId?.(newInstallId);
- startInstallStream(newInstallId);
+ startFreshInstall();
}
};
diff --git a/app/views/install/installer/js/modules/state.js b/app/views/install/installer/js/modules/state.js
index 9fcf9969a8..3c7fcd2427 100644
--- a/app/views/install/installer/js/modules/state.js
+++ b/app/views/install/installer/js/modules/state.js
@@ -7,6 +7,8 @@
const INSTALL_LOCK_KEY = 'appwrite-install-lock';
const INSTALL_ID_KEY = 'appwrite-install-id';
+ const INSTALL_LOCK_LOCAL_KEY = 'appwrite-install-lock-backup';
+ const INSTALL_ID_LOCAL_KEY = 'appwrite-install-id-backup';
const formState = {
appDomain: null,
@@ -55,13 +57,24 @@
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;
- }
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object') return parsed;
+ }
+ } catch (error) {}
+
+ try {
+ const raw = localStorage.getItem(INSTALL_LOCK_LOCAL_KEY);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object') {
+ sessionStorage.setItem(INSTALL_LOCK_KEY, raw);
+ return parsed;
+ }
+ }
+ } catch (error) {}
+
+ return null;
};
const setInstallLock = (installId, payload) => {
@@ -79,6 +92,9 @@
try {
sessionStorage.setItem(INSTALL_LOCK_KEY, JSON.stringify(lock));
} catch (error) {}
+ try {
+ localStorage.setItem(INSTALL_LOCK_LOCAL_KEY, JSON.stringify(lock));
+ } catch (error) {}
if (document.body) {
document.body.dataset.installLocked = 'true';
}
@@ -89,6 +105,9 @@
try {
sessionStorage.removeItem(INSTALL_LOCK_KEY);
} catch (error) {}
+ try {
+ localStorage.removeItem(INSTALL_LOCK_LOCAL_KEY);
+ } catch (error) {}
if (document.body) {
delete document.body.dataset.installLocked;
}
@@ -121,22 +140,31 @@
const getStoredInstallId = () => {
try {
- return sessionStorage.getItem(INSTALL_ID_KEY);
- } catch (error) {
- return null;
- }
+ const val = sessionStorage.getItem(INSTALL_ID_KEY);
+ if (val) return val;
+ } catch (error) {}
+ try {
+ return localStorage.getItem(INSTALL_ID_LOCAL_KEY);
+ } catch (error) {}
+ return null;
};
const storeInstallId = (installId) => {
try {
sessionStorage.setItem(INSTALL_ID_KEY, installId);
} catch (error) {}
+ try {
+ localStorage.setItem(INSTALL_ID_LOCAL_KEY, installId);
+ } catch (error) {}
};
const clearInstallId = () => {
try {
sessionStorage.removeItem(INSTALL_ID_KEY);
} catch (error) {}
+ try {
+ localStorage.removeItem(INSTALL_ID_LOCAL_KEY);
+ } catch (error) {}
};
window.InstallerStepsState = {
diff --git a/app/views/install/installer/js/modules/ui.js b/app/views/install/installer/js/modules/ui.js
index bde4cb7c44..a41a657602 100644
--- a/app/views/install/installer/js/modules/ui.js
+++ b/app/views/install/installer/js/modules/ui.js
@@ -240,6 +240,9 @@
if (key === 'database') {
value = toDatabaseLabel(formState?.database);
}
+ if (key === 'emailCertificates' && !value) {
+ value = formState?.accountEmail;
+ }
if (value) {
node.textContent = value;
}
diff --git a/app/views/install/installer/js/steps.js b/app/views/install/installer/js/steps.js
index 2a71d075cc..c9430b7afd 100644
--- a/app/views/install/installer/js/steps.js
+++ b/app/views/install/installer/js/steps.js
@@ -390,10 +390,7 @@
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())) {
+ if (sslEmail && sslEmail.value.trim() && !isValidEmail?.(sslEmail.value.trim())) {
setFieldError?.(sslEmail, 'Please enter a valid email address');
valid = false;
}
diff --git a/app/views/install/installer/templates/steps/step-5.phtml b/app/views/install/installer/templates/steps/step-5.phtml
index 8fa810b259..cd5de5f4ab 100644
--- a/app/views/install/installer/templates/steps/step-5.phtml
+++ b/app/views/install/installer/templates/steps/step-5.phtml
@@ -30,6 +30,7 @@ $isUpgrade = $isUpgrade ?? false;
+
@@ -50,4 +51,13 @@ $isUpgrade = $isUpgrade ?? false;
+
+
+
+
+
diff --git a/composer.lock b/composer.lock
index de577e2d78..7a30e2f265 100644
--- a/composer.lock
+++ b/composer.lock
@@ -5216,16 +5216,16 @@
},
{
"name": "utopia-php/vcs",
- "version": "3.0.1",
+ "version": "3.1.0",
"source": {
"type": "git",
"url": "https://github.com/utopia-php/vcs.git",
- "reference": "0efe842d695acb4b184f5306a836169c771fbcea"
+ "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/utopia-php/vcs/zipball/0efe842d695acb4b184f5306a836169c771fbcea",
- "reference": "0efe842d695acb4b184f5306a836169c771fbcea",
+ "url": "https://api.github.com/repos/utopia-php/vcs/zipball/03b76ad5fd01bc50f809915bca6ff0745ea913af",
+ "reference": "03b76ad5fd01bc50f809915bca6ff0745ea913af",
"shasum": ""
},
"require": {
@@ -5259,9 +5259,9 @@
],
"support": {
"issues": "https://github.com/utopia-php/vcs/issues",
- "source": "https://github.com/utopia-php/vcs/tree/3.0.1"
+ "source": "https://github.com/utopia-php/vcs/tree/3.1.0"
},
- "time": "2026-03-23T15:58:31+00:00"
+ "time": "2026-03-24T08:49:14+00:00"
},
{
"name": "utopia-php/websocket",
@@ -5439,16 +5439,16 @@
"packages-dev": [
{
"name": "appwrite/sdk-generator",
- "version": "1.11.14",
+ "version": "1.12.1",
"source": {
"type": "git",
"url": "https://github.com/appwrite/sdk-generator.git",
- "reference": "ed4faf10fafa1930ed0be3dfe43e41561f2de75b"
+ "reference": "a724aa8db52f83ea35854a004837fa5ce990b736"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/ed4faf10fafa1930ed0be3dfe43e41561f2de75b",
- "reference": "ed4faf10fafa1930ed0be3dfe43e41561f2de75b",
+ "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/a724aa8db52f83ea35854a004837fa5ce990b736",
+ "reference": "a724aa8db52f83ea35854a004837fa5ce990b736",
"shasum": ""
},
"require": {
@@ -5484,9 +5484,9 @@
"description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms",
"support": {
"issues": "https://github.com/appwrite/sdk-generator/issues",
- "source": "https://github.com/appwrite/sdk-generator/tree/1.11.14"
+ "source": "https://github.com/appwrite/sdk-generator/tree/1.12.1"
},
- "time": "2026-03-20T10:55:13+00:00"
+ "time": "2026-03-24T05:18:43+00:00"
},
{
"name": "brianium/paratest",
diff --git a/docker-compose.yml b/docker-compose.yml
index f3cb982526..aa2bfdd16a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -254,7 +254,7 @@ services:
appwrite-console:
<<: *x-logging
container_name: appwrite-console
- image: appwrite/console:7.5.7
+ image: appwrite/console:7.8.26
restart: unless-stopped
networks:
- appwrite
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php
index 92a00651fe..69f7d4b072 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Complete.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Complete.php
@@ -48,9 +48,10 @@ class Complete extends Action
@touch(Server::INSTALLER_COMPLETE_FILE);
- if (!$sessionSecret && $installId !== '') {
- $data = $state->readProgressFile($installId);
- $details = $data['details'][Server::STEP_ACCOUNT_SETUP] ?? [];
+ $progressData = ($installId !== '') ? $state->readProgressFile($installId) : [];
+
+ if (!$sessionSecret) {
+ $details = $progressData['details'][Server::STEP_ACCOUNT_SETUP] ?? [];
if (!empty($details['sessionSecret'])) {
$sessionSecret = $details['sessionSecret'];
$sessionId = $sessionId ?: ($details['sessionId'] ?? '');
@@ -68,8 +69,11 @@ class Complete extends Action
$expires = $timestamp;
}
}
- $response->addCookie('a_session_console', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite);
- $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', '', $isHttps, true, $sameSite);
+ $appDomain = $progressData['payload']['appDomain'] ?? '';
+ $cookieDomain = $this->buildCookieDomain($appDomain ?: $request->getHostname());
+
+ $response->addCookie('a_session_console', $sessionSecret, $expires, '/', $cookieDomain, $isHttps, true, $sameSite);
+ $response->addCookie('a_session_console_legacy', $sessionSecret, $expires, '/', $cookieDomain, $isHttps, true, $sameSite);
if ($sessionId) {
$response->addHeader('X-Appwrite-Session', $sessionId);
}
@@ -79,4 +83,42 @@ class Complete extends Action
$response->json(['success' => true]);
}
+
+ /**
+ * Compute the cookie domain to match Appwrite's convention in general.php.
+ *
+ * For localhost and IP addresses the domain is left empty (host-only cookie).
+ * For real hostnames, the domain is prefixed with a dot so the cookie matches
+ * Appwrite's default `'.' . $request->getHostname()` behaviour and lives in
+ * the same cookie-jar slot — preventing stale ghost cookies after logout.
+ */
+ private function buildCookieDomain(string $raw): string
+ {
+ $hostname = $this->extractHostname($raw);
+ if ($hostname === '' || $hostname === 'localhost' || $hostname === '0.0.0.0' || $hostname === 'traefik') {
+ return '';
+ }
+ if (filter_var($hostname, FILTER_VALIDATE_IP) !== false) {
+ return '';
+ }
+ return '.' . $hostname;
+ }
+
+ /**
+ * Extract the bare hostname from an appDomain value, stripping any port
+ * suffix or IPv6 bracket notation.
+ */
+ private function extractHostname(string $domain): string
+ {
+ $domain = trim($domain);
+ if ($domain === '') {
+ return '';
+ }
+ if (str_starts_with($domain, '[')) {
+ $end = strpos($domain, ']');
+ return $end !== false ? substr($domain, 1, $end - 1) : '';
+ }
+ $parts = explode(':', $domain);
+ return count($parts) <= 2 ? strtolower($parts[0]) : strtolower($domain);
+ }
}
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Install.php b/src/Appwrite/Platform/Installer/Http/Installer/Install.php
index 0b2fa17c0d..e29222a703 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Install.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Install.php
@@ -35,7 +35,7 @@ class Install extends Action
->param('appDomain', '', new AppDomain(), 'Application domain (hostname, IP, or bracket IPv6 with optional port)')
->param('httpPort', 80, new Range(1, 65535), 'HTTP port')
->param('httpsPort', 443, new Range(1, 65535), 'HTTPS port')
- ->param('emailCertificates', '', new Email(), 'Email for SSL certificates')
+ ->param('emailCertificates', '', new Email(allowEmpty: true), 'Email for SSL certificates', true)
->param('opensslKey', '', new Text(64, 0), 'Secret API key', true)
->param('assistantOpenAIKey', '', new Text(256, 0), 'OpenAI API key for assistant', true)
->param('accountEmail', '', new Email(allowEmpty: true), 'Account email address', true)
@@ -90,6 +90,9 @@ class Install extends Action
$appDomain = trim($appDomain);
$emailCertificates = trim($emailCertificates);
+ if ($emailCertificates === '') {
+ $emailCertificates = trim($accountEmail);
+ }
$opensslKey = trim($opensslKey);
$assistantOpenAIKey = trim($assistantOpenAIKey);
@@ -140,6 +143,8 @@ class Install extends Action
@unlink(Server::INSTALLER_COMPLETE_FILE);
+ $state->clearStaleLockIfNeeded();
+
try {
$lockResult = $state->reserveGlobalLock($installId);
} catch (\Throwable $e) {
@@ -175,15 +180,23 @@ class Install extends Action
if (file_exists($existingPath)) {
$existing = $state->readProgressFile($installId);
if (!empty($existing['steps']) && $retryStep === null) {
- $state->updateGlobalLock($installId, Server::STATUS_ERROR);
- if ($wantsStream) {
- $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']);
- $swooleResponse->end();
+ $previousHadError = isset($existing['error']);
+ $allCompleted = !$previousHadError && $this->allStepsCompleted($existing['steps']);
+
+ if ($previousHadError || $allCompleted) {
+ @unlink($existingPath);
+ $existing = null;
} else {
- $response->setStatusCode(Response::STATUS_CODE_CONFLICT);
- $response->json(['success' => false, 'message' => 'Installation already started']);
+ $state->updateGlobalLock($installId, Server::STATUS_ERROR);
+ if ($wantsStream) {
+ $this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, ['message' => 'Installation already started']);
+ $swooleResponse->end();
+ } else {
+ $response->setStatusCode(Response::STATUS_CODE_CONFLICT);
+ $response->json(['success' => false, 'message' => 'Installation already started']);
+ }
+ return;
}
- return;
}
}
@@ -207,7 +220,8 @@ class Install extends Action
'_APP_ASSISTANT_OPENAI_API_KEY' => $assistantOpenAIKey,
];
- if ($this->hasPayload($existing)) {
+ $previousHadError = is_array($existing) && isset($existing['error']);
+ if ($this->hasPayload($existing) && !$previousHadError) {
$stored = $existing['payload'];
$inputValues = [
'httpPort' => (string) $httpPort,
@@ -368,8 +382,6 @@ class Install extends Action
$state->updateGlobalLock($installId, Server::STATUS_ERROR);
}
- @unlink(Server::INSTALLER_CONFIG_FILE);
-
if ($wantsStream) {
$this->writeSseEvent($swooleResponse, Server::STATUS_ERROR, [
'message' => $e->getMessage(),
@@ -392,6 +404,16 @@ class Install extends Action
return is_array($data) && isset($data['payload']) && is_array($data['payload']);
}
+ private function allStepsCompleted(array $steps): bool
+ {
+ foreach ($steps as $step) {
+ if (($step['status'] ?? '') !== Server::STATUS_COMPLETED) {
+ return false;
+ }
+ }
+ return true;
+ }
+
private function deriveNameFromEmail(string $email): string
{
$parts = explode('@', $email);
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Reset.php b/src/Appwrite/Platform/Installer/Http/Installer/Reset.php
new file mode 100644
index 0000000000..8e5b877473
--- /dev/null
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Reset.php
@@ -0,0 +1,110 @@
+setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
+ ->setHttpPath('/install/reset')
+ ->desc('Reset installation state')
+ ->param('installId', '', new Text(64, 0), 'Installation ID', true)
+ ->param('hard', false, new Boolean(true), 'Remove all data including volumes and config files', true)
+ ->inject('request')
+ ->inject('response')
+ ->inject('installerState')
+ ->inject('installerConfig')
+ ->callback($this->action(...));
+ }
+
+ public function action(string $installId, bool $hard, Request $request, Response $response, State $state, Config $config): void
+ {
+ if (!Validate::validateCsrf($request)) {
+ $response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
+ $response->json(['success' => false, 'message' => 'Invalid CSRF token']);
+ return;
+ }
+
+ $installId = $state->sanitizeInstallId($installId);
+
+ if ($installId !== '') {
+ @unlink($state->progressFilePath($installId));
+ $state->updateGlobalLock($installId, Server::STATUS_COMPLETED);
+ }
+
+ // Use direct clearStaleLock (not throttled) since reset is an
+ // explicit user action that should guarantee all stale state is gone.
+ $state->clearStaleLock();
+
+ if ($hard) {
+ $error = $this->performHardReset($config);
+ if ($error !== null) {
+ $response->setStatusCode(Response::STATUS_CODE_INTERNAL_SERVER_ERROR);
+ $response->json(['success' => false, 'message' => $error]);
+ return;
+ }
+ }
+
+ $response->json(['success' => true]);
+ }
+
+ private function performHardReset(Config $config): ?string
+ {
+ $isLocal = $config->isLocal();
+ $composeFileName = $isLocal ? 'docker-compose.web-installer.yml' : 'docker-compose.yml';
+ $envFileName = $isLocal ? '.env.web-installer' : '.env';
+ $path = $isLocal ? '/usr/src/code' : '/usr/src/code/appwrite';
+
+ $composeFile = $path . '/' . $composeFileName;
+
+ if (file_exists($composeFile)) {
+ $command = array_map(escapeshellarg(...), [
+ 'docker', 'compose',
+ '-f', $composeFile,
+ ...($isLocal ? ['--project-name', 'appwrite'] : []),
+ '--project-directory', $path,
+ 'down', '-v', '--remove-orphans',
+ ]);
+
+ $output = [];
+ @exec(implode(' ', $command) . ' 2>&1', $output, $exitCode);
+
+ if ($exitCode !== 0) {
+ return 'Failed to stop containers: ' . trim(implode("\n", $output));
+ }
+
+ @unlink($composeFile);
+ }
+
+ $envFile = $path . '/' . $envFileName;
+ if (file_exists($envFile)) {
+ @unlink($envFile);
+ }
+
+ @unlink(Server::INSTALLER_CONFIG_FILE);
+ @unlink(Server::INSTALLER_LOCK_FILE);
+
+ $tempDir = sys_get_temp_dir();
+ foreach ((array) glob($tempDir . '/appwrite-install-*.json') as $file) {
+ @unlink($file);
+ }
+
+ return null;
+ }
+}
diff --git a/src/Appwrite/Platform/Installer/Http/Installer/Status.php b/src/Appwrite/Platform/Installer/Http/Installer/Status.php
index e53a501f4c..d6ffa64c8f 100644
--- a/src/Appwrite/Platform/Installer/Http/Installer/Status.php
+++ b/src/Appwrite/Platform/Installer/Http/Installer/Status.php
@@ -28,6 +28,8 @@ class Status extends Action
public function action(string $installId, Response $response, State $state): void
{
+ $state->clearStaleLockIfNeeded();
+
$installId = $state->sanitizeInstallId($installId);
if ($installId === '') {
$response->setStatusCode(Response::STATUS_CODE_BAD_REQUEST);
diff --git a/src/Appwrite/Platform/Installer/Runtime/State.php b/src/Appwrite/Platform/Installer/Runtime/State.php
index 5552eb5632..75efd7027c 100644
--- a/src/Appwrite/Platform/Installer/Runtime/State.php
+++ b/src/Appwrite/Platform/Installer/Runtime/State.php
@@ -13,13 +13,15 @@ class State
private const string PATTERN_IPV6_WITH_PORT = '/^\[(.+)](?::(\d+))?$/';
private const int CONFIG_FILE_PERMISSION = 0600;
- private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 3600;
+ private const int GLOBAL_LOCK_TIMEOUT_SECONDS = 300;
+ private const int STALE_LOCK_CHECK_INTERVAL_SECONDS = 30;
private const int PORT_MIN = 1;
private const int PORT_MAX = 65535;
private array $paths;
private bool $bootstrapped = false;
+ private int $lastStaleLockClearAt = 0;
public function __construct(array $paths)
{
@@ -254,6 +256,16 @@ class State
}
}
+ public function clearStaleLockIfNeeded(): void
+ {
+ $now = time();
+ if ($now - $this->lastStaleLockClearAt < self::STALE_LOCK_CHECK_INTERVAL_SECONDS) {
+ return;
+ }
+ $this->lastStaleLockClearAt = $now;
+ $this->clearStaleLock();
+ }
+
public function reserveGlobalLock(string $installId): string
{
return (string) $this->withGlobalLock(function ($handle, $lock) use ($installId) {
diff --git a/src/Appwrite/Platform/Installer/Services/Http.php b/src/Appwrite/Platform/Installer/Services/Http.php
index 0de977b177..b410e67a26 100644
--- a/src/Appwrite/Platform/Installer/Services/Http.php
+++ b/src/Appwrite/Platform/Installer/Services/Http.php
@@ -5,6 +5,7 @@ namespace Appwrite\Platform\Installer\Services;
use Appwrite\Platform\Installer\Http\Installer\Certificate\Get as CertificateGet;
use Appwrite\Platform\Installer\Http\Installer\Complete;
use Appwrite\Platform\Installer\Http\Installer\Install;
+use Appwrite\Platform\Installer\Http\Installer\Reset;
use Appwrite\Platform\Installer\Http\Installer\Shutdown;
use Appwrite\Platform\Installer\Http\Installer\Status;
use Appwrite\Platform\Installer\Http\Installer\Validate;
@@ -22,6 +23,7 @@ class Http extends Service
$this->addAction(Validate::getName(), new Validate());
$this->addAction(Complete::getName(), new Complete());
$this->addAction(Shutdown::getName(), new Shutdown());
+ $this->addAction(Reset::getName(), new Reset());
$this->addAction(Install::getName(), new Install());
$this->addAction(CertificateGet::getName(), new CertificateGet());
}
diff --git a/src/Appwrite/Platform/Tasks/Install.php b/src/Appwrite/Platform/Tasks/Install.php
index af768444f2..eab6babc66 100644
--- a/src/Appwrite/Platform/Tasks/Install.php
+++ b/src/Appwrite/Platform/Tasks/Install.php
@@ -25,6 +25,7 @@ class Install extends Action
private const int HEALTH_CHECK_ATTEMPTS = 30;
private const int HEALTH_CHECK_DELAY_SECONDS = 1;
+ private const int PROC_CLOSE_TIMEOUT_SECONDS = 60;
private const string PATTERN_ENV_VAR_NAME = '/^[A-Z0-9_]+$/';
private const string PATTERN_DB_PASSWORD_VAR = '/^_APP_DB_.*_PASS$/';
@@ -169,9 +170,9 @@ class Install extends Action
}
}
- // Block database type changes on existing installations.
- // Only enforce if the existing config explicitly set _APP_DB_ADAPTER
- // (pre-1.9.0 installs never had this variable).
+ // Detect database type from existing installation.
+ // 1.9.0+ installs have _APP_DB_ADAPTER; pre-1.9.0 installs
+ // can be detected by the DB service name or _APP_DB_HOST.
$existingDatabase = null;
foreach ($compose->getServices() as $service) {
if (!$service) {
@@ -190,10 +191,15 @@ class Install extends Action
$existingDatabase = (new Env($rawEnv))->list()['_APP_DB_ADAPTER'] ?? null;
}
}
- if ($existingDatabase !== null && $existingDatabase !== $database) {
- Console::error("Cannot change database type from '{$existingDatabase}' to '{$database}'.");
- Console::error('Changing database types on an existing installation is not supported.');
- Console::exit(1);
+ if ($existingDatabase === null) {
+ $existingDatabase = $this->detectDatabaseFromCompose($compose);
+ }
+ if ($existingDatabase !== null) {
+ if ($existingDatabase !== $database) {
+ $database = $existingDatabase;
+ Console::info("Detected existing database: {$database}");
+ }
+ $vars['_APP_DB_ADAPTER']['default'] = $database;
}
}
@@ -210,7 +216,8 @@ class Install extends Action
Console::info('Open your browser at: http://localhost:' . InstallerServer::INSTALLER_WEB_PORT);
Console::info('Press Ctrl+C to cancel installation');
- $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars);
+ $detectedDb = ($existingInstallation && isset($existingDatabase)) ? $existingDatabase : null;
+ $this->startWebServer($defaultHttpPort, $defaultHttpsPort, $organization, $image, $noStart, $vars, $isUpgrade, $detectedDb);
return;
}
@@ -599,7 +606,12 @@ class Install extends Action
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->runDockerCompose($input, $isLocalInstall, $useExistingConfig, $isCLI, $progress, $isUpgrade);
+
+ if (!$isUpgrade) {
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
+ $this->updateProgress($progress, InstallerServer::STEP_ACCOUNT_SETUP, InstallerServer::STATUS_IN_PROGRESS, messageOverride: 'Creating Appwrite account...');
+ }
if (!$isLocalInstall) {
$this->connectInstallerToAppwriteNetwork();
@@ -607,10 +619,15 @@ class Install extends Action
$domain = $input['_APP_DOMAIN'] ?? 'localhost';
- // Wait for Appwrite API to be healthy before marking containers as ready
- $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, InstallerServer::STEP_DOCKER_CONTAINERS);
+ $healthStep = $isUpgrade ? InstallerServer::STEP_DOCKER_CONTAINERS : InstallerServer::STEP_ACCOUNT_SETUP;
+ if (!$isUpgrade) {
+ $currentStep = InstallerServer::STEP_ACCOUNT_SETUP;
+ }
+ $apiUrl = $this->waitForApiReady($domain, $httpPort, $isLocalInstall, $progress, $healthStep);
- $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
+ if ($isUpgrade) {
+ $this->updateProgress($progress, InstallerServer::STEP_DOCKER_CONTAINERS, InstallerServer::STATUS_COMPLETED, $messages);
+ }
if (!$isUpgrade) {
$this->createInitialAdminAccount($account, $progress, $apiUrl, $domain);
@@ -658,8 +675,9 @@ class Install extends Action
messageOverride: 'Creating Appwrite account'
);
- // Create the account — tolerate "already exists" so we can still
- // create a session (common when re-running the installer).
+ // Create the account — tolerate "already exists" and "console
+ // is restricted" errors so we can still create a session
+ // (common when re-running the installer or upgrading).
$userId = null;
try {
$userId = $this->makeApiCall('/v1/account', [
@@ -669,7 +687,10 @@ class Install extends Action
'name' => $name
], false, $apiUrl, $domain);
} catch (\Throwable $e) {
- if (\stripos($e->getMessage(), 'already exists') === false) {
+ $message = $e->getMessage();
+ $accountExists = \stripos($message, 'already exists') !== false
+ || \stripos($message, 'console is restricted') !== false;
+ if (!$accountExists) {
throw $e;
}
}
@@ -732,6 +753,8 @@ class Install extends Action
$name = $account['name'] ?? 'Admin';
$email = $account['email'] ?? 'admin@selfhosted.local';
+ $hostIp = gethostbyname($domain);
+
$payload = [
'action' => $type,
'account' => 'self-hosted',
@@ -744,6 +767,11 @@ class Install extends Action
'email' => $email,
'domain' => $domain,
'database' => $database,
+ 'hostIp' => $hostIp !== $domain ? $hostIp : null,
+ 'os' => php_uname('s') . ' ' . php_uname('r'),
+ 'arch' => php_uname('m'),
+ 'cpus' => ((int) trim((string) \shell_exec('nproc'))) ?: null,
+ 'ram' => (int) round(((float) trim((string) \shell_exec('grep MemTotal /proc/meminfo | awk \'{print $2}\''))) / 1024),
]),
];
@@ -766,7 +794,7 @@ class Install extends Action
* - host.docker.internal:{port} — reaches host-published ports from inside a container
* - localhost:{port} — works when running directly on the host (local dev)
*/
- private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_DOCKER_CONTAINERS): string
+ private function waitForApiReady(string $domain, string $httpPort, bool $isLocalInstall, ?callable $progress, string $step = InstallerServer::STEP_ACCOUNT_SETUP): string
{
$client = new Client();
$client
@@ -776,12 +804,16 @@ class Install extends Action
$healthPath = '/v1/health/version';
- // Local dev: reach Traefik via localhost on the host.
- // Docker: reach Appwrite directly via Docker internal DNS (network connect is guaranteed).
- $candidate = $isLocalInstall
- ? 'http://localhost:' . $httpPort . $healthPath
- : self::APPWRITE_API_URL . $healthPath;
- $candidates = [$candidate];
+ if ($isLocalInstall) {
+ $candidates = [
+ 'http://localhost:' . $httpPort . $healthPath,
+ ];
+ } else {
+ $candidates = [
+ self::APPWRITE_API_URL . $healthPath,
+ 'http://host.docker.internal:' . $httpPort . $healthPath,
+ ];
+ }
$lastErrors = [];
@@ -803,7 +835,7 @@ class Install extends Action
$progress(
$step,
InstallerServer::STATUS_IN_PROGRESS,
- 'Waiting for Appwrite to be ready (' . ($i + 1) . '/' . self::HEALTH_CHECK_ATTEMPTS . ')',
+ 'Waiting for Appwrite to be ready...',
[]
);
} catch (\Throwable) {
@@ -964,7 +996,7 @@ class Install extends Action
}
}
- protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI): void
+ protected function runDockerCompose(array $input, bool $isLocalInstall, bool $useExistingConfig, bool $isCLI, ?callable $progress = null, bool $isUpgrade = false): void
{
$env = '';
if (!$useExistingConfig) {
@@ -1004,8 +1036,28 @@ class Install extends Action
$command[] = '-d';
$command[] = '--remove-orphans';
$command[] = '--renew-anon-volumes';
- $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command)) . ' 2>&1';
- \exec($commandLine, $output, $exit);
+ $commandLine = $env . implode(' ', array_map(escapeshellarg(...), $command));
+
+ if ($progress) {
+ $totalServices = $this->countComposeServices($composeFile);
+ if ($totalServices > 0) {
+ $verb = $isUpgrade ? 'Restarting' : 'Starting';
+ try {
+ $progress(
+ InstallerServer::STEP_DOCKER_CONTAINERS,
+ InstallerServer::STATUS_IN_PROGRESS,
+ "$verb Docker containers...",
+ ['containerStarted' => 0, 'containerTotal' => $totalServices]
+ );
+ } catch (\Throwable) {
+ }
+ }
+ $result = $this->execWithContainerProgress($commandLine, $totalServices, $progress, $isUpgrade);
+ $output = $result['output'];
+ $exit = $result['exit'];
+ } else {
+ \exec($commandLine . ' 2>&1', $output, $exit);
+ }
if ($exit !== 0) {
$message = trim(implode("\n", $output));
@@ -1017,6 +1069,126 @@ class Install extends Action
}
}
+ private function countComposeServices(string $composeFile): int
+ {
+ $content = @file_get_contents($composeFile);
+ if ($content === false) {
+ return 0;
+ }
+ $count = preg_match_all('/^\s*container_name:/m', $content);
+ return $count !== false ? $count : 0;
+ }
+
+ private function execWithContainerProgress(string $commandLine, int $totalServices, callable $progress, bool $isUpgrade): array
+ {
+ $verb = $isUpgrade ? 'Restarting' : 'Starting';
+ $message = "$verb Docker containers...";
+ $started = 0;
+ $output = [];
+
+ $process = proc_open(
+ $commandLine . ' 2>&1',
+ [1 => ['pipe', 'w']],
+ $pipes
+ );
+
+ if (!is_resource($process)) {
+ return ['output' => [], 'exit' => 1];
+ }
+
+ stream_set_blocking($pipes[1], false);
+ $deadline = time() + self::PROC_CLOSE_TIMEOUT_SECONDS;
+ $buffer = '';
+
+ while (time() < $deadline) {
+ $status = proc_get_status($process);
+
+ $read = [$pipes[1]];
+ $write = null;
+ $except = null;
+ $changed = @stream_select($read, $write, $except, 1);
+
+ if ($changed > 0) {
+ $chunk = fread($pipes[1], 8192);
+ if ($chunk === false || $chunk === '') {
+ if (!$status['running']) {
+ break;
+ }
+ continue;
+ }
+ $buffer .= $chunk;
+ while (($pos = strpos($buffer, "\n")) !== false) {
+ $trimmed = rtrim(substr($buffer, 0, $pos), "\r");
+ $buffer = substr($buffer, $pos + 1);
+ $output[] = $trimmed;
+
+ if (str_contains($trimmed, 'Container') && (str_contains($trimmed, 'Started') || str_contains($trimmed, 'Running'))) {
+ $started = min($started + 1, $totalServices);
+ if ($totalServices > 0) {
+ try {
+ $progress(
+ InstallerServer::STEP_DOCKER_CONTAINERS,
+ InstallerServer::STATUS_IN_PROGRESS,
+ $message,
+ ['containerStarted' => $started, 'containerTotal' => $totalServices]
+ );
+ } catch (\Throwable) {
+ }
+ }
+ }
+ }
+ }
+
+ if (!$status['running'] && ($changed === 0 || feof($pipes[1]))) {
+ break;
+ }
+ }
+
+ if ($buffer !== '') {
+ $output[] = rtrim($buffer, "\r\n");
+ }
+
+ fclose($pipes[1]);
+
+ $exit = $this->procCloseWithTimeout($process, self::PROC_CLOSE_TIMEOUT_SECONDS);
+
+ return ['output' => $output, 'exit' => $exit];
+ }
+
+ /**
+ * Wait up to $timeoutSeconds for a process to exit, then kill it.
+ *
+ * proc_close() blocks indefinitely which can hang the installer if
+ * docker compose refuses to exit after all containers are running.
+ *
+ * @param resource $process A process resource from proc_open()
+ */
+ private function procCloseWithTimeout($process, int $timeoutSeconds): int
+ {
+ $deadline = time() + $timeoutSeconds;
+
+ while (time() < $deadline) {
+ $status = proc_get_status($process);
+ if (!$status['running']) {
+ $exitCode = $status['exitcode'];
+ $closeCode = proc_close($process);
+ return $exitCode !== -1 ? $exitCode : $closeCode;
+ }
+ usleep(250_000);
+ }
+
+ proc_terminate($process, SIGTERM);
+ usleep(500_000);
+
+ if (proc_get_status($process)['running']) {
+ proc_terminate($process, SIGKILL);
+ }
+
+ proc_close($process);
+
+ return 124;
+ }
+
protected function isLocalInstall(): bool
{
if ($this->isLocalInstall === null) {
@@ -1089,6 +1261,34 @@ class Install extends Action
$this->hostPath = $this->getInstallerHostPath();
}
+ /**
+ * Detect the database adapter from a pre-1.9.0 compose file by
+ * checking which DB service exists or reading _APP_DB_HOST.
+ */
+ private function detectDatabaseFromCompose(Compose $compose): ?string
+ {
+ $serviceNames = array_keys($compose->getServices());
+ $dbServices = ['mariadb', 'mongodb', 'postgresql'];
+ foreach ($dbServices as $db) {
+ if (in_array($db, $serviceNames, true)) {
+ return $db;
+ }
+ }
+
+ foreach ($compose->getServices() as $service) {
+ if (!$service) {
+ continue;
+ }
+ $env = $service->getEnvironment()->list();
+ $host = $env['_APP_DB_HOST'] ?? null;
+ if ($host !== null && in_array($host, $dbServices, true)) {
+ return $host;
+ }
+ }
+
+ return null;
+ }
+
protected function readExistingCompose(): string
{
$composeFile = $this->path . '/' . $this->getComposeFileName();
diff --git a/tests/unit/Platform/Modules/Installer/ModuleTest.php b/tests/unit/Platform/Modules/Installer/ModuleTest.php
index f3b4b9d9ae..0b7e7effcb 100644
--- a/tests/unit/Platform/Modules/Installer/ModuleTest.php
+++ b/tests/unit/Platform/Modules/Installer/ModuleTest.php
@@ -5,6 +5,7 @@ namespace Tests\Unit\Platform\Modules\Installer;
use Appwrite\Platform\Installer\Http\Installer\Complete;
use Appwrite\Platform\Installer\Http\Installer\Error;
use Appwrite\Platform\Installer\Http\Installer\Install;
+use Appwrite\Platform\Installer\Http\Installer\Reset;
use Appwrite\Platform\Installer\Http\Installer\Shutdown;
use Appwrite\Platform\Installer\Http\Installer\Status;
use Appwrite\Platform\Installer\Http\Installer\Validate;
@@ -41,12 +42,13 @@ class ModuleTest extends TestCase
$service = reset($services);
$actions = $service->getActions();
- $this->assertCount(7, $actions);
+ $this->assertCount(8, $actions);
$this->assertArrayHasKey('installerView', $actions);
$this->assertArrayHasKey('installerStatus', $actions);
$this->assertArrayHasKey('installerValidate', $actions);
$this->assertArrayHasKey('installerComplete', $actions);
$this->assertArrayHasKey('installerShutdown', $actions);
+ $this->assertArrayHasKey('installerReset', $actions);
$this->assertArrayHasKey('installerInstall', $actions);
$this->assertArrayHasKey('installerCertificateGet', $actions);
}
@@ -109,6 +111,18 @@ class ModuleTest extends TestCase
$this->assertActionInjects($action, ['request', 'response', 'swooleServer']);
}
+ public function testResetAction(): void
+ {
+ $action = $this->getAction('installerReset');
+
+ $this->assertEquals('installerReset', Reset::getName());
+ $this->assertEquals(Action::HTTP_REQUEST_METHOD_POST, $action->getHttpMethod());
+ $this->assertEquals('/install/reset', $action->getHttpPath());
+ $this->assertEquals(Action::TYPE_DEFAULT, $action->getType());
+ $this->assertActionParams($action, ['installId', 'hard']);
+ $this->assertActionInjects($action, ['request', 'response', 'installerState', 'installerConfig']);
+ }
+
public function testInstallAction(): void
{
$action = $this->getAction('installerInstall');
@@ -207,6 +221,7 @@ class ModuleTest extends TestCase
$this->assertEquals('installerValidate', Validate::getName());
$this->assertEquals('installerComplete', Complete::getName());
$this->assertEquals('installerShutdown', Shutdown::getName());
+ $this->assertEquals('installerReset', Reset::getName());
$this->assertEquals('installerInstall', Install::getName());
$this->assertEquals('installerError', Error::getName());
}
@@ -222,6 +237,7 @@ class ModuleTest extends TestCase
$this->assertInstanceOf(Validate::class, $actions['installerValidate']);
$this->assertInstanceOf(Complete::class, $actions['installerComplete']);
$this->assertInstanceOf(Shutdown::class, $actions['installerShutdown']);
+ $this->assertInstanceOf(Reset::class, $actions['installerReset']);
$this->assertInstanceOf(Install::class, $actions['installerInstall']);
}
@@ -240,7 +256,7 @@ class ModuleTest extends TestCase
public function testPostRoutesUsePostMethod(): void
{
- $postActions = ['installerValidate', 'installerComplete', 'installerShutdown', 'installerInstall'];
+ $postActions = ['installerValidate', 'installerComplete', 'installerShutdown', 'installerReset', 'installerInstall'];
foreach ($postActions as $name) {
$action = $this->getAction($name);
$this->assertEquals(