MIGRATE_STATE_VERSION, 'current_stage' => 'auth', 'completed' => [], 'stage_dir' => 'grav-2', ]; return $data; } function save_state(array $state): void { $json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($json === false) { throw new RuntimeException('Failed to encode state.'); } $tmp = MIGRATE_FLAG . '.tmp'; if (file_put_contents($tmp, $json) === false) { throw new RuntimeException('Failed to write state tmp file.'); } if (!rename($tmp, MIGRATE_FLAG)) { @unlink($tmp); throw new RuntimeException('Failed to replace state file.'); } @chmod(MIGRATE_FLAG, 0600); } function redact_state(array $state): array { unset($state['token']); return $state; } function advance_stage(array &$state, string $stage, array $data = []): void { $state[$stage] = $data + [$stage => true]; if (!in_array($stage, $state['completed'] ?? [], true)) { $state['completed'][] = $stage; } $idx = array_search($stage, STAGES, true); $state['current_stage'] = $idx !== false && isset(STAGES[$idx + 1]) ? STAGES[$idx + 1] : 'done'; save_state($state); } // --------------------------------------------------------------------------- // Auth (against the 1.x user/accounts/*.yaml files) // --------------------------------------------------------------------------- function user_accounts_dir(): string { return MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'accounts'; } function parse_minimal_yaml(string $content): array { if (function_exists('yaml_parse')) { $parsed = @yaml_parse($content); if (is_array($parsed)) { return $parsed; } } // Tiny fallback — handles the flat + nested-map subset used by account // files. Doesn't support arrays or multi-line strings, which is fine here. $lines = preg_split("/\r\n|\n|\r/", $content) ?: []; $root = []; $stack = [['indent' => -1, 'ref' => &$root]]; foreach ($lines as $line) { $stripped = rtrim($line); $t = ltrim($stripped); if ($t === '' || $t[0] === '#') { continue; } $indent = strlen($stripped) - strlen($t); $indent = strlen(str_replace("\t", ' ', substr($stripped, 0, $indent))); while (count($stack) > 1 && $indent <= $stack[count($stack) - 1]['indent']) { array_pop($stack); } if (!preg_match('/^([A-Za-z0-9_.-]+):\s*(.*)$/', $t, $m)) { continue; } $key = $m[1]; $val = trim($m[2]); $parent = &$stack[count($stack) - 1]['ref']; if ($val === '' || $val === '~' || strtolower($val) === 'null') { $parent[$key] = []; $stack[] = ['indent' => $indent, 'ref' => &$parent[$key]]; } else { if ($val === 'true') { $val = true; } elseif ($val === 'false') { $val = false; } elseif (preg_match('/^-?\d+$/', $val)) { $val = (int)$val; } elseif (preg_match('/^"(.*)"$/', $val, $mm) || preg_match("/^'(.*)'$/", $val, $mm)) { $val = $mm[1]; } $parent[$key] = $val; } unset($parent); } return $root; } function load_user_account(string $username): ?array { $path = user_accounts_dir() . DIRECTORY_SEPARATOR . $username . '.yaml'; if (!is_file($path)) { return null; } $raw = @file_get_contents($path); if ($raw === false) { return null; } $data = parse_minimal_yaml($raw); $data['_username'] = $username; return $data; } function account_is_super_admin(array $account): bool { // Grav stores super as access.admin.super: true $access = $account['access'] ?? null; if (is_array($access) && isset($access['admin']) && is_array($access['admin'])) { return !empty($access['admin']['super']); } return false; } function verify_credentials(string $username, string $password): array { $account = load_user_account($username); if (!$account) { throw new RuntimeException('Unknown user.'); } if (($account['state'] ?? 'enabled') !== 'enabled') { throw new RuntimeException('Account is disabled.'); } $hash = (string)($account['hashed_password'] ?? ''); if ($hash === '' || !password_verify($password, $hash)) { throw new RuntimeException('Invalid credentials.'); } if (!account_is_super_admin($account)) { throw new RuntimeException('A super-admin account is required to run migration.'); } return $account; } // --------------------------------------------------------------------------- // Session / CSRF // --------------------------------------------------------------------------- function start_wizard_session(): void { if (session_status() !== PHP_SESSION_ACTIVE) { session_name('GRAV_MIGRATE'); session_set_cookie_params([ 'lifetime' => 0, 'path' => dirname($_SERVER['SCRIPT_NAME'] ?? '/') ?: '/', 'httponly' => true, 'samesite' => 'Lax', ]); @session_start(); } } function require_token(array $state): void { start_wizard_session(); $supplied = $_GET['token'] ?? $_POST['token'] ?? $_SERVER['HTTP_X_MIGRATE_TOKEN'] ?? null; $sessionToken = $_SESSION['migrate_token'] ?? null; if ($supplied && hash_equals($state['token'], (string)$supplied)) { $_SESSION['migrate_token'] = $state['token']; return; } if ($sessionToken && hash_equals($state['token'], (string)$sessionToken)) { return; } throw new RuntimeException('Invalid or missing migration token.'); } function require_authenticated(array $state): void { start_wizard_session(); if (empty($_SESSION['migrate_user'])) { throw new RuntimeException('Authentication required.'); } } // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- function fmt_bytes(int $n): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $i = 0; $v = (float)$n; while ($v >= 1024 && $i < count($units) - 1) { $v /= 1024; $i++; } return sprintf('%.1f %s', $v, $units[$i]); } function disk_free_at(string $path): int { $free = @disk_free_space($path); return is_numeric($free) ? (int)$free : -1; } function dir_size(string $path): int { if (!is_dir($path)) { return 0; } $total = 0; $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS )); foreach ($it as $f) { if ($f->isFile()) { $total += $f->getSize(); } } return $total; } /** * Copy a directory tree. Symlinks are not followed; they're recorded in * $skipped for operator decision. Returns ['files'=>int,'skipped'=>string[]]. */ function fs_copy_tree(string $src, string $dst, array &$skipped = []): array { if (!is_dir($src)) { return ['files' => 0, 'skipped' => $skipped]; } if (!is_dir($dst) && !mkdir($dst, 0775, true) && !is_dir($dst)) { throw new RuntimeException("Cannot create destination: {$dst}"); } $files = 0; $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($it as $entry) { /** @var SplFileInfo $entry */ $rel = substr($entry->getPathname(), strlen($src) + 1); $target = $dst . DIRECTORY_SEPARATOR . $rel; if ($entry->isLink()) { $skipped[] = $entry->getPathname(); continue; } if ($entry->isDir()) { if (!is_dir($target) && !@mkdir($target, 0775, true) && !is_dir($target)) { throw new RuntimeException("Cannot mkdir {$target}"); } } elseif ($entry->isFile()) { $parent = dirname($target); if (!is_dir($parent) && !@mkdir($parent, 0775, true) && !is_dir($parent)) { throw new RuntimeException("Cannot mkdir {$parent}"); } if (!@copy($entry->getPathname(), $target)) { throw new RuntimeException("Cannot copy {$entry->getPathname()} → {$target}"); } $files++; } } return ['files' => $files, 'skipped' => $skipped]; } function fs_rmtree(string $path): void { if (!file_exists($path) && !is_link($path)) { return; } if (is_link($path) || !is_dir($path)) { @unlink($path); return; } $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST ); foreach ($it as $f) { if ($f->isLink() || $f->isFile()) { @unlink($f->getPathname()); } elseif ($f->isDir()) { @rmdir($f->getPathname()); } } @rmdir($path); } /** * Read a plugin/theme blueprint YAML and return its parsed map. */ function read_blueprint(string $path): ?array { $f = $path . DIRECTORY_SEPARATOR . 'blueprints.yaml'; if (!is_file($f)) { return null; } $raw = @file_get_contents($f); if ($raw === false) { return null; } return parse_minimal_yaml($raw); } /** * Mirror of Grav\Common\GPM\Local\Package::resolveCompatibility() for use * inside this standalone wizard. Reads blueprint + dep inference. */ function resolve_compatibility(?array $blueprint): array { if (!$blueprint) { return ['grav' => ['1.7'], 'api' => [], 'source' => 'default']; } $compat = $blueprint['compatibility'] ?? null; if (is_array($compat) && isset($compat['grav']) && is_array($compat['grav'])) { return [ 'grav' => array_map('strval', $compat['grav']), 'api' => isset($compat['api']) && is_array($compat['api']) ? array_map('strval', $compat['api']) : [], 'source' => 'blueprint', ]; } $deps = $blueprint['dependencies'] ?? []; if (is_array($deps)) { foreach ($deps as $dep) { if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { continue; } $v = (string)($dep['version'] ?? ''); if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $v, $m)) { continue; } if (version_compare($m[1], '2.0', '>=')) { return ['grav' => ['2.0'], 'api' => [], 'source' => 'inferred']; } if (version_compare($m[1], '1.8', '>=')) { return ['grav' => ['1.8'], 'api' => [], 'source' => 'inferred']; } return ['grav' => ['1.7'], 'api' => [], 'source' => 'inferred']; } } return ['grav' => ['1.7'], 'api' => [], 'source' => 'default']; } function fetch_curated_compat(string $slug): ?array { $url = 'https://getgrav.org/gpm/compatibility/v1/' . urlencode($slug); $ctx = stream_context_create([ 'http' => ['timeout' => 3, 'ignore_errors' => true, 'user_agent' => 'grav-migrate/1.0'], ]); $body = @file_get_contents($url, false, $ctx); if ($body === false || $body === '') { return null; } $data = json_decode($body, true); return is_array($data) ? $data : null; } function classify_plugin(array $compat, ?array $curated): string { $grav = $compat['grav'] ?? []; $curatedGrav = is_array($curated['grav'] ?? null) ? $curated['grav'] : []; if (in_array('2.0', $grav, true) || in_array('2.0', $curatedGrav, true)) { return 'works'; } if ($curated && isset($curated['grav']) && !in_array('2.0', $curatedGrav, true)) { return 'incompatible'; } if (in_array('1.7', $grav, true) || in_array('1.8', $grav, true)) { return 'needs_update'; } return 'unknown'; } function run_subprocess(array $cmd, ?string $cwd = null): array { $descriptors = [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ]; $proc = proc_open($cmd, $descriptors, $pipes, $cwd); if (!is_resource($proc)) { throw new RuntimeException('Failed to start subprocess: ' . implode(' ', $cmd)); } fclose($pipes[0]); $stdout = stream_get_contents($pipes[1]) ?: ''; $stderr = stream_get_contents($pipes[2]) ?: ''; fclose($pipes[1]); fclose($pipes[2]); $code = proc_close($proc); return ['code' => $code, 'stdout' => $stdout, 'stderr' => $stderr]; } function php_binary(): string { if (defined('PHP_BINARY') && PHP_BINARY) { return PHP_BINARY; } return 'php'; } // --------------------------------------------------------------------------- // Stage handlers // --------------------------------------------------------------------------- function action_state(array $state): array { return ['state' => redact_state($state)]; } function action_auth(array $state): array { if (in_array('auth', $state['completed'] ?? [], true)) { return ['already' => true]; } $username = trim((string)($_POST['username'] ?? '')); $password = (string)($_POST['password'] ?? ''); if ($username === '' || $password === '') { throw new RuntimeException('Username and password required.'); } $account = verify_credentials($username, $password); start_wizard_session(); $_SESSION['migrate_user'] = $account['_username']; advance_stage($state, 'auth', [ 'authenticated_user' => $account['_username'], 'authenticated_at' => time(), ]); return ['authenticated_user' => $account['_username']]; } function action_preflight(array $state): array { require_authenticated($state); $checks = []; $checks[] = [ 'name' => 'PHP version', 'pass' => version_compare(PHP_VERSION, MIGRATE_PHP_MIN, '>='), 'detail' => 'Running ' . PHP_VERSION . ', need ≥ ' . MIGRATE_PHP_MIN, ]; foreach (['zip', 'json', 'mbstring', 'fileinfo'] as $ext) { $checks[] = [ 'name' => "PHP ext: {$ext}", 'pass' => extension_loaded($ext), 'detail' => extension_loaded($ext) ? 'loaded' : 'missing', ]; } $writableRoot = is_writable(MIGRATE_WEBROOT); $checks[] = [ 'name' => 'Webroot writable', 'pass' => $writableRoot, 'detail' => MIGRATE_WEBROOT, ]; $userDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user'; $userSize = dir_size($userDir); $free = disk_free_at(MIGRATE_WEBROOT); $needed = max($userSize * 2, 200 * 1024 * 1024); // 2x user/ or 200MB min $checks[] = [ 'name' => 'Disk space', 'pass' => $free < 0 ? true : $free >= $needed, 'detail' => sprintf( 'user/ ≈ %s, free ≈ %s, need ≈ %s', fmt_bytes($userSize), $free < 0 ? 'unknown' : fmt_bytes($free), fmt_bytes($needed) ), ]; $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); $checks[] = [ 'name' => 'Stage dir available', 'pass' => !is_dir($stageDir), 'detail' => is_dir($stageDir) ? "{$stageDir} already exists" : 'ok', ]; $zipPath = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['staged_zip'] ?? 'tmp/grav-2.0-staged.zip'); $checks[] = [ 'name' => 'Staged 2.0 zip present', 'pass' => is_file($zipPath), 'detail' => is_file($zipPath) ? fmt_bytes((int)filesize($zipPath)) : "missing: {$zipPath}", ]; $passed = !in_array(false, array_column($checks, 'pass'), true); if ($passed) { advance_stage($state, 'preflight', ['passed' => true, 'checks' => $checks]); } return ['passed' => $passed, 'checks' => $checks]; } function action_snapshot(array $state): array { require_authenticated($state); $backupDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'backup'; if (!is_dir($backupDir) && !mkdir($backupDir, 0775, true) && !is_dir($backupDir)) { throw new RuntimeException('Cannot create backup/ directory.'); } $zipName = 'pre-migration-' . date('YmdHis') . '.zip'; $zipPath = $backupDir . DIRECTORY_SEPARATOR . $zipName; $userDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user'; if (!is_dir($userDir)) { throw new RuntimeException('user/ directory not found; nothing to back up.'); } $zip = new ZipArchive(); if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new RuntimeException("Cannot open backup zip for write: {$zipPath}"); } $base = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR; $fileCount = 0; $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($userDir, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST ); foreach ($it as $f) { $abs = $f->getPathname(); $rel = ltrim(str_replace($base, '', $abs), DIRECTORY_SEPARATOR); if ($f->isDir()) { $zip->addEmptyDir($rel); } elseif ($f->isFile()) { $zip->addFile($abs, $rel); $fileCount++; } } $zip->close(); $size = (int)@filesize($zipPath); advance_stage($state, 'snapshot', [ 'path' => 'backup/' . $zipName, 'size' => $size, 'files' => $fileCount, 'created_at' => time(), ]); return ['path' => 'backup/' . $zipName, 'size' => $size, 'files' => $fileCount]; } function action_stage(array $state): array { require_authenticated($state); $zipRel = $state['staged_zip'] ?? 'tmp/grav-2.0-staged.zip'; $zipPath = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . $zipRel; if (!is_file($zipPath)) { throw new RuntimeException("Staged zip missing: {$zipRel}"); } $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); if (is_dir($stageDir)) { throw new RuntimeException("Stage directory already exists: {$stageDir}"); } if (!mkdir($stageDir, 0775, true)) { throw new RuntimeException("Cannot create stage directory: {$stageDir}"); } $zip = new ZipArchive(); $opened = $zip->open($zipPath); if ($opened !== true) { throw new RuntimeException("Cannot open staged zip (code {$opened}): {$zipPath}"); } // Detect if zip contents are wrapped in a single top-level directory. $prefix = ''; if ($zip->numFiles > 0) { $first = (string)$zip->getNameIndex(0); if ($first !== '' && strpos($first, '/') !== false) { $candidate = substr($first, 0, strpos($first, '/') + 1); $wrapped = true; for ($i = 1, $n = min($zip->numFiles, 32); $i < $n; $i++) { $name = (string)$zip->getNameIndex($i); if ($name !== '' && strpos($name, $candidate) !== 0) { $wrapped = false; break; } } if ($wrapped) { $prefix = $candidate; } } } $extracted = 0; for ($i = 0, $n = $zip->numFiles; $i < $n; $i++) { $name = (string)$zip->getNameIndex($i); if ($name === '') { continue; } $rel = $prefix !== '' && strpos($name, $prefix) === 0 ? substr($name, strlen($prefix)) : $name; if ($rel === '' || $rel === false) { continue; } $dest = $stageDir . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel); if (substr($name, -1) === '/') { if (!is_dir($dest) && !@mkdir($dest, 0775, true)) { throw new RuntimeException("Cannot create dir {$dest}"); } } else { $parent = dirname($dest); if (!is_dir($parent) && !@mkdir($parent, 0775, true)) { throw new RuntimeException("Cannot create parent {$parent}"); } $stream = $zip->getStream($name); if (!$stream) { throw new RuntimeException("Cannot stream entry {$name}"); } $out = fopen($dest, 'wb'); if (!$out) { fclose($stream); throw new RuntimeException("Cannot open {$dest} for write"); } while (!feof($stream)) { $buf = fread($stream, 1 << 16); if ($buf === false) { break; } fwrite($out, $buf); } fclose($stream); fclose($out); $extracted++; } } $zip->close(); advance_stage($state, 'stage', [ 'stage_dir' => $state['stage_dir'] ?? 'grav-2', 'extracted_files' => $extracted, 'extracted_at' => time(), ]); return ['stage_dir' => $state['stage_dir'] ?? 'grav-2', 'extracted_files' => $extracted]; } /** * Copy user-side content (pages, data, accounts, config, themes, media) from * the source site into the staged grav-2/ tree. Plugins are intentionally * handled later in evaluate/install stages. */ function action_import(array $state): array { require_authenticated($state); $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); if (!is_dir($stageDir)) { throw new RuntimeException('Stage directory missing — run the stage step first.'); } $srcUser = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user'; $dstUser = $stageDir . DIRECTORY_SEPARATOR . 'user'; if (!is_dir($srcUser)) { throw new RuntimeException('Source user/ directory missing.'); } $subdirs = ['pages', 'data', 'accounts', 'config', 'themes', 'media', 'backups']; $summary = []; $skipped = []; foreach ($subdirs as $sub) { $src = $srcUser . DIRECTORY_SEPARATOR . $sub; $dst = $dstUser . DIRECTORY_SEPARATOR . $sub; if (!is_dir($src)) { continue; } $result = fs_copy_tree($src, $dst, $skipped); $summary[$sub] = $result['files']; } advance_stage($state, 'import', [ 'copied' => $summary, 'skipped_symlinks' => $skipped, 'imported_at' => time(), ]); return ['copied' => $summary, 'skipped_symlinks' => $skipped]; } /** * Walk source user/plugins/*, classify each by compatibility, and store the * inventory in state. Does NOT apply anything; decisions happen in install(). */ function action_evaluate(array $state): array { require_authenticated($state); $srcPlugins = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'plugins'; if (!is_dir($srcPlugins)) { advance_stage($state, 'evaluate', ['plugins' => []]); return ['plugins' => []]; } $plugins = []; foreach (new DirectoryIterator($srcPlugins) as $d) { if ($d->isDot() || !$d->isDir()) { continue; } $slug = $d->getFilename(); $path = $d->getPathname(); $bp = read_blueprint($path); $compat = resolve_compatibility($bp); $curated = fetch_curated_compat($slug); $class = classify_plugin($compat, $curated); $default = match ($class) { 'works' => 'import', 'needs_update' => 'reinstall', 'incompatible' => 'skip', default => 'skip', }; $plugins[$slug] = [ 'slug' => $slug, 'version' => $bp['version'] ?? null, 'symlink' => is_link($path), 'compatibility' => $compat, 'curated' => $curated, 'classification' => $class, 'default_action' => $default, ]; } ksort($plugins); advance_stage($state, 'evaluate', [ 'plugins' => $plugins, 'evaluated_at' => time(), ]); return ['plugins' => $plugins]; } /** * Apply per-plugin decisions (import/reinstall/skip) from the UI into the * staged tree. Decisions arrive as plugins[slug] = action in POST. */ function action_install(array $state): array { require_authenticated($state); $decisions = $_POST['plugins'] ?? null; if (!is_array($decisions)) { throw new RuntimeException('No plugin decisions submitted.'); } $known = $state['evaluate']['plugins'] ?? []; if (!$known) { throw new RuntimeException('Evaluate stage has no plugin inventory.'); } $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); $srcPlugins = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'plugins'; $dstPlugins = $stageDir . DIRECTORY_SEPARATOR . 'user' . DIRECTORY_SEPARATOR . 'plugins'; if (!is_dir($dstPlugins) && !mkdir($dstPlugins, 0775, true) && !is_dir($dstPlugins)) { throw new RuntimeException("Cannot create {$dstPlugins}"); } $gpmBin = $stageDir . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'gpm'; $results = []; $skipped = []; foreach ($known as $slug => $info) { $choice = (string)($decisions[$slug] ?? $info['default_action']); if (!in_array($choice, ['import', 'reinstall', 'skip'], true)) { $choice = 'skip'; } if ($choice === 'skip') { $results[$slug] = ['action' => 'skip']; continue; } if ($choice === 'import') { if ($info['symlink']) { $skipped[] = $slug; $results[$slug] = ['action' => 'import', 'skipped' => 'symlink']; continue; } $src = $srcPlugins . DIRECTORY_SEPARATOR . $slug; $dst = $dstPlugins . DIRECTORY_SEPARATOR . $slug; if (is_dir($dst)) { fs_rmtree($dst); } $dummy = []; $stat = fs_copy_tree($src, $dst, $dummy); $results[$slug] = ['action' => 'import', 'files' => $stat['files']]; continue; } // reinstall via the staged 2.0 gpm if (!is_file($gpmBin)) { $results[$slug] = ['action' => 'reinstall', 'error' => 'staged bin/gpm not found']; continue; } $proc = run_subprocess([php_binary(), $gpmBin, 'install', $slug, '--yes'], $stageDir); $results[$slug] = [ 'action' => 'reinstall', 'code' => $proc['code'], 'stderr' => trim((string)$proc['stderr']), ]; } advance_stage($state, 'install', [ 'results' => $results, 'skipped_symlinks' => $skipped, 'installed_at' => time(), ]); return ['results' => $results, 'skipped_symlinks' => $skipped]; } /** * Minimal health check on the staged Grav 2.0 site: run `bin/grav list` to * confirm the install bootstraps without fatal errors. */ function action_test(array $state): array { require_authenticated($state); $stageDir = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['stage_dir'] ?? 'grav-2'); $gravBin = $stageDir . DIRECTORY_SEPARATOR . 'bin' . DIRECTORY_SEPARATOR . 'grav'; if (!is_file($gravBin)) { throw new RuntimeException("Staged grav CLI missing: {$gravBin}"); } $proc = run_subprocess([php_binary(), $gravBin, 'list'], $stageDir); $ok = $proc['code'] === 0; advance_stage($state, 'test', [ 'ok' => $ok, 'code' => $proc['code'], 'stdout_excerpt' => substr((string)$proc['stdout'], 0, 2000), 'stderr_excerpt' => substr((string)$proc['stderr'], 0, 2000), 'tested_at' => time(), ]); return [ 'ok' => $ok, 'code' => $proc['code'], 'stdout' => substr((string)$proc['stdout'], 0, 4000), 'stderr' => substr((string)$proc['stderr'], 0, 4000), ]; } /** * Swap: move current webroot contents into an archive directory, then move * the staged grav-2/ contents up to webroot. Preserves migrate.php, the * .migrating flag, the backup/ directory, and tmp/. */ function action_promote(array $state): array { require_authenticated($state); $confirm = (string)($_POST['confirm'] ?? ''); if ($confirm !== 'PROMOTE') { throw new RuntimeException('Type PROMOTE to confirm.'); } $stageDir = ($state['stage_dir'] ?? 'grav-2'); $stagePath = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . $stageDir; if (!is_dir($stagePath)) { throw new RuntimeException("Stage directory missing: {$stagePath}"); } $archiveName = 'grav-1x-archive-' . date('YmdHis'); $archivePath = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . $archiveName; if (!mkdir($archivePath, 0775)) { throw new RuntimeException("Cannot create archive dir: {$archivePath}"); } // Entries preserved at webroot and NOT archived. $preserve = [ 'migrate.php', '.migrating', $stageDir, $archiveName, 'backup', 'tmp', ]; // Entries from the 1.x site we should archive. Walk top level only. $movedToArchive = []; foreach (new DirectoryIterator(MIGRATE_WEBROOT) as $e) { if ($e->isDot()) { continue; } $name = $e->getFilename(); if (in_array($name, $preserve, true)) { continue; } // Safety: don't archive other grav-1x-archive-* from previous runs. if (strpos($name, 'grav-1x-archive-') === 0) { continue; } $src = $e->getPathname(); $dst = $archivePath . DIRECTORY_SEPARATOR . $name; if (!@rename($src, $dst)) { throw new RuntimeException("Failed to archive {$name}"); } $movedToArchive[] = $name; } // Move staged contents up to webroot. $promoted = []; foreach (new DirectoryIterator($stagePath) as $e) { if ($e->isDot()) { continue; } $name = $e->getFilename(); $src = $e->getPathname(); $dst = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . $name; // If 2.0 ships a backup/ or tmp/, merge rather than clobber. if (is_dir($dst) && in_array($name, ['backup', 'tmp'], true)) { continue; } if (file_exists($dst)) { // Shouldn't happen for non-preserved names since we archived them, // but be defensive: move into archive instead of overwriting. @rename($dst, $archivePath . DIRECTORY_SEPARATOR . $name . '.conflict'); } if (!@rename($src, $dst)) { throw new RuntimeException("Failed to promote {$name} → webroot"); } $promoted[] = $name; } // Clean up the now-empty stage dir. @rmdir($stagePath); // Also move the staged zip into the archive for tidiness. $stagedZip = MIGRATE_WEBROOT . DIRECTORY_SEPARATOR . ($state['staged_zip'] ?? 'tmp/grav-2.0-staged.zip'); if (is_file($stagedZip)) { @rename($stagedZip, $archivePath . DIRECTORY_SEPARATOR . basename($stagedZip)); } advance_stage($state, 'promote', [ 'archive' => $archiveName, 'archived' => $movedToArchive, 'promoted' => $promoted, 'promoted_at' => time(), ]); return [ 'archive' => $archiveName, 'archived_count' => count($movedToArchive), 'promoted_count' => count($promoted), ]; } /** * Remove migrate.php and .migrating. Archive is left alone — operator deletes * when satisfied. */ function action_cleanup(array $state): array { require_authenticated($state); $confirm = (string)($_POST['confirm'] ?? ''); if ($confirm !== 'REMOVE') { throw new RuntimeException('Type REMOVE to confirm.'); } advance_stage($state, 'cleanup', ['cleaned_at' => time()]); // After advance_stage persists, actually nuke our own surfaces. $self = __FILE__; $flag = MIGRATE_FLAG; @unlink($flag); // Leave migrate.php deletion to a shutdown-time unlink so we finish the // response cleanly. register_shutdown_function(static function () use ($self) { @unlink($self); }); return ['removed' => ['.migrating', 'migrate.php (pending shutdown)']]; } // --------------------------------------------------------------------------- // Request dispatch // --------------------------------------------------------------------------- function send_json($payload, int $code = 200): void { http_response_code($code); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store'); echo json_encode($payload, JSON_UNESCAPED_SLASHES); exit; } function handle_request(): void { $method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; $action = $_GET['action'] ?? $_POST['action'] ?? null; try { $state = load_state(); } catch (Throwable $e) { if ($method === 'POST' || $action) { send_json(['ok' => false, 'error' => $e->getMessage()], 400); } render_error($e->getMessage()); return; } try { require_token($state); } catch (Throwable $e) { if ($method === 'POST' || $action) { send_json(['ok' => false, 'error' => $e->getMessage()], 403); } render_error($e->getMessage()); return; } if ($method !== 'POST' && $action !== 'state') { render_shell($state); return; } try { switch ($action) { case 'state': $res = action_state($state); break; case 'auth': $res = action_auth($state); break; case 'preflight': $res = action_preflight($state); break; case 'snapshot': $res = action_snapshot($state); break; case 'stage': $res = action_stage($state); break; case 'import': $res = action_import($state); break; case 'evaluate': $res = action_evaluate($state); break; case 'install': $res = action_install($state); break; case 'test': $res = action_test($state); break; case 'promote': $res = action_promote($state); break; case 'cleanup': $res = action_cleanup($state); break; default: throw new RuntimeException("Unknown action: " . (string)$action); } } catch (Throwable $e) { send_json(['ok' => false, 'error' => $e->getMessage()], 400); } // Re-load so the response always reflects persisted state. $fresh = load_state(); send_json(['ok' => true, 'result' => $res, 'state' => redact_state($fresh)]); } function render_error(string $message): void { http_response_code(400); header('Content-Type: text/html; charset=utf-8'); $msg = htmlspecialchars($message, ENT_QUOTES, 'UTF-8'); ?>
If you expected this wizard to run, check that .migrating exists at the webroot and that you reached this page through the redirect from the migrate-to-2 plugin.
Running standalone — your Grav 1.x install is not loaded.
Sign in with a super-admin account from your Grav 1.x site.
Verify the environment can host Grav 2.0.
Zip up user/ into backup/ so you can roll back if needed.
Extract the Grav 2.0 release into .
Copy pages, data, accounts, config, themes, and media from your 1.x site into the staged Grav 2.0.
Classify each plugin by its compatibility.grav flag, dependency inference, and the curated getgrav.org list.
| Plugin | Status | Grav | Action |
|---|---|---|---|
Return to the evaluate step to submit decisions.
Runs bin/grav list inside to confirm the staged install bootstraps.
Archives your Grav 1.x site to grav-1x-archive-YmdHis/ and moves the staged 2.0 install up to the webroot. This cuts over your live site.
You can skip this step and keep 2.0 running at indefinitely.
Removes migrate.php and .migrating from the webroot. Your archive directory is left alone — delete it manually once you're confident the migration is good.
All stages finished. You can remove this file when satisfied.