diff --git a/system/migrate/migrate.php b/system/migrate/migrate.php new file mode 100644 index 000000000..a8445b8c4 --- /dev/null +++ b/system/migrate/migrate.php @@ -0,0 +1,1538 @@ + 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'); + ?> +Migration wizard error + + +

Migration wizard cannot start

+

+

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.

+ + + + + +Grav 2.0 Migration Wizard + + + + + + +
+ +
+
+
+
+

Grav 2.0 Migration Wizard

+

Running standalone — your Grav 1.x install is not loaded.

+
+
+
+ + +
    + +
+ + +
+ Something went wrong + +
+ + +
+

Authenticate

+

Sign in with a super-admin account from your Grav 1.x site.

+
+ + + +
+
+ + +
+

Pre-flight checks

+

Verify the environment can host Grav 2.0.

+ + +
Resolve the failing checks and re-run.
+
+ +
+
+ + +
+

Snapshot

+

Zip up user/ into backup/ so you can roll back if needed.

+ +
+ + +
+

Stage Grav 2.0

+

Extract the Grav 2.0 release into .

+ +
+ + +
+

Import content

+

Copy pages, data, accounts, config, themes, and media from your 1.x site into the staged Grav 2.0.

+ +
+
Copied:
+
    + +
+ +
+
+ + +
+

Evaluate plugins

+

Classify each plugin by its compatibility.grav flag, dependency inference, and the curated getgrav.org list.

+ + +
+ + +
+

Plugin install results

+ + +
+ + +
+

Health check

+

Runs bin/grav list inside to confirm the staged install bootstraps.

+ + +
+ + +
+

Promote to webroot

+

+ 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.

+ + + +
+ + +
+

Clean up

+

+ 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. +

+ + +
+ + +
+

Migration complete

+

All stages finished. You can remove this file when satisfied.

+
+ + +
+ State +

+  
+ +
+ + + + + +=')) { + return ['grav' => ['2.0'], 'api' => []]; + } + if (version_compare($m[1], '1.8', '>=')) { return ['grav' => ['1.8'], 'api' => []]; } diff --git a/system/src/Grav/Console/Gpm/IndexCommand.php b/system/src/Grav/Console/Gpm/IndexCommand.php index ca28a042b..d4456fa36 100644 --- a/system/src/Grav/Console/Gpm/IndexCommand.php +++ b/system/src/Grav/Console/Gpm/IndexCommand.php @@ -260,6 +260,9 @@ class IndexCommand extends GpmCommand if (in_array('1.8', $compat['grav'], true)) { $badges[] = '1.8'; } + if (in_array('2.0', $compat['grav'], true)) { + $badges[] = '2.0'; + } return implode(' ', $badges); } diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php index d2e89002a..2f0a45356 100644 --- a/system/src/Grav/Console/Gpm/InfoCommand.php +++ b/system/src/Grav/Console/Gpm/InfoCommand.php @@ -150,6 +150,9 @@ class InfoCommand extends GpmCommand if (in_array('1.8', $compat['grav'], true)) { $badges[] = '1.8'; } + if (in_array('2.0', $compat['grav'], true)) { + $badges[] = '2.0'; + } $compatStr = implode(' ', $badges); } else { $compatStr = '1.7'; diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php index b81b80768..061f4086d 100644 --- a/system/src/Grav/Console/Gpm/UpdateCommand.php +++ b/system/src/Grav/Console/Gpm/UpdateCommand.php @@ -198,6 +198,9 @@ class UpdateCommand extends GpmCommand if (in_array('1.8', $compat['grav'], true)) { $badges[] = '1.8'; } + if (in_array('2.0', $compat['grav'], true)) { + $badges[] = '2.0'; + } $compatStr = ' ' . implode(' ', $badges); } diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php index 99f1ad1c3..ec6a2a1c7 100644 --- a/system/src/Grav/Installer/Install.php +++ b/system/src/Grav/Installer/Install.php @@ -12,10 +12,13 @@ namespace Grav\Installer; use Composer\Autoload\ClassLoader; use Exception; use Grav\Common\Cache; +use Grav\Common\GPM\GPM; use Grav\Common\GPM\Installer; use Grav\Common\Grav; use Grav\Common\Plugins; +use RocketTheme\Toolbox\Compat\Yaml\Yaml; use RuntimeException; +use Throwable; use function class_exists; use function dirname; use function function_exists; @@ -132,6 +135,18 @@ final class Install /** @var bool|null */ private static $forceSafeUpgrade = null; + /** @var bool */ + private static $allowPendingOverride = false; + + /** @var bool */ + private static $allowIncompatibleOverride = false; + + /** @var callable|null */ + private $progressCallback = null; + + /** @var array|null */ + private $pendingPreflight = null; + /** * @param bool|null $state * @return void @@ -141,6 +156,25 @@ final class Install self::$forceSafeUpgrade = $state; } + /** + * Allow an upgrade run to proceed even when GPM-tracked plugin/theme + * updates are still pending. Toggled by SelfupgradeCommand after the + * operator confirms the override interactively. + */ + public static function allowPendingPackageOverride(?bool $state = true): void + { + self::$allowPendingOverride = $state === null ? false : (bool)$state; + } + + /** + * Allow an upgrade run to proceed with enabled plugins/themes that have + * not been marked compatible with the target Grav version. + */ + public static function allowIncompatibleOverride(?bool $state = true): void + { + self::$allowIncompatibleOverride = $state === null ? false : (bool)$state; + } + /** * @return array|null */ @@ -424,4 +458,520 @@ ERR; // Support install for Grav 1.6.0 - 1.6.20 by loading the original class from the older version of Grav. class_exists(\Grav\Console\Cli\CacheCommand::class, true); } + + // --------------------------------------------------------------------- + // Preflight — invoked by the SelfupgradeCommand on the target package's + // Install singleton before files are overlaid. Read-only detection: + // this surface MUST NOT mutate state. + // --------------------------------------------------------------------- + + private function ensureLocation(): void + { + if (null === $this->location) { + $path = realpath(__DIR__); + if ($path) { + $this->location = dirname($path, 4); + } + } + } + + public function setProgressCallback(?callable $callback): self + { + $this->progressCallback = $callback; + + return $this; + } + + private function relayProgress(string $stage, string $message, ?int $percent = null): void + { + if ($this->progressCallback) { + ($this->progressCallback)($stage, $message, $percent); + } + } + + public function generatePreflightReport(): array + { + $this->ensureLocation(); + $version = $this->getVersion(); + + $report = $this->runPreflightChecks($version ?: GRAV_VERSION); + $this->pendingPreflight = $report; + + return $report; + } + + public function getPreflightReport(): ?array + { + return $this->pendingPreflight; + } + + private function runPreflightChecks(string $targetVersion): array + { + $start = microtime(true); + $this->relayProgress('initializing', 'Running preflight checks...', null); + + $report = [ + 'warnings' => [], + 'psr_log_conflicts' => [], + 'monolog_conflicts' => [], + 'plugins_pending' => [], + 'incompatible_packages' => [], + 'is_major_minor_upgrade' => $this->isMajorMinorUpgrade($targetVersion), + 'blocking' => [], + ]; + + $report['plugins_pending'] = $this->detectPendingPackageUpdates(); + $report['psr_log_conflicts'] = $this->detectPsrLogConflicts(); + $report['monolog_conflicts'] = $this->detectMonologConflicts(); + + if ($report['plugins_pending']) { + if (self::$allowPendingOverride) { + $report['warnings'][] = 'Pending plugin/theme updates ignored for this upgrade run.'; + } elseif ($report['is_major_minor_upgrade']) { + $report['blocking'][] = 'Pending plugin/theme updates detected. Because this is a major Grav upgrade, update them before continuing.'; + } + } + + if ($report['is_major_minor_upgrade']) { + $report['incompatible_packages'] = $this->detectIncompatiblePackages($targetVersion); + + if (!empty($report['incompatible_packages']['blocking']) && !self::$allowIncompatibleOverride) { + $target = $report['incompatible_packages']['target']; + $report['blocking'][] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $target . '. Disable them before continuing.'; + } + } + + $elapsed = microtime(true) - $start; + $this->relayProgress('initializing', sprintf('Preflight checks complete in %.3fs.', $elapsed), null); + + return $report; + } + + private function isMajorMinorUpgrade(string $targetVersion): bool + { + [$currentMajor, $currentMinor] = array_map('intval', array_pad(explode('.', GRAV_VERSION), 2, 0)); + [$targetMajor, $targetMinor] = array_map('intval', array_pad(explode('.', $targetVersion), 2, 0)); + + return $currentMajor !== $targetMajor || $currentMinor !== $targetMinor; + } + + private function detectPendingPackageUpdates(): array + { + $pending = []; + + if (!class_exists(GPM::class)) { + return $pending; + } + + try { + $gpm = new GPM(); + } catch (Throwable $e) { + $this->relayProgress('warning', 'Unable to query GPM: ' . $e->getMessage(), null); + + return $pending; + } + + $repoPlugins = $this->packagesToArray($gpm->getRepositoryPlugins()); + $repoThemes = $this->packagesToArray($gpm->getRepositoryThemes()); + + $scanRoot = GRAV_ROOT ?: getcwd(); + + foreach ($this->scanLocalPackageVersions($scanRoot . '/user/plugins') as $slug => $version) { + $remote = $repoPlugins[$slug] ?? null; + if (!$this->isGpmPackagePublished($remote)) { + continue; + } + $remoteVersion = $this->resolveRemotePackageVersion($remote); + if (!$remoteVersion || !$version) { + continue; + } + if (!$this->isPluginEnabled($slug)) { + if (str_contains($version, 'dev-')) { + $this->relayProgress('warning', sprintf('Skipping dev plugin %s (%s).', $slug, $version), null); + continue; + } + } + + if (version_compare($remoteVersion, $version, '>')) { + $pending[$slug] = ['type' => 'plugins', 'current' => $version, 'available' => $remoteVersion]; + } + } + + foreach ($this->scanLocalPackageVersions($scanRoot . '/user/themes') as $slug => $version) { + $remote = $repoThemes[$slug] ?? null; + if (!$this->isGpmPackagePublished($remote)) { + if (str_contains($version, 'dev-')) { + $this->relayProgress('warning', sprintf('Skipping dev theme %s (%s).', $slug, $version), null); + continue; + } + } + $remoteVersion = $this->resolveRemotePackageVersion($remote); + if (!$remoteVersion || !$version) { + continue; + } + if (!$this->isThemeEnabled($slug)) { + continue; + } + + if (version_compare($remoteVersion, $version, '>')) { + $pending[$slug] = ['type' => 'themes', 'current' => $version, 'available' => $remoteVersion]; + } + } + + $this->relayProgress('initializing', sprintf('Detected %d updatable packages (including symlinks).', count($pending)), null); + + return $pending; + } + + private function scanLocalPackageVersions(string $path): array + { + $versions = []; + if (!is_dir($path)) { + return $versions; + } + + foreach (glob($path . '/*', GLOB_ONLYDIR) ?: [] as $dir) { + $slug = basename($dir); + $version = $this->readBlueprintVersion($dir) ?? $this->readComposerVersion($dir); + if ($version !== null) { + $versions[$slug] = $version; + } + } + + return $versions; + } + + private function readBlueprintVersion(string $dir): ?string + { + $file = $dir . '/blueprints.yaml'; + if (!is_file($file)) { + return null; + } + + try { + $contents = @file_get_contents($file); + if ($contents === false) { + return null; + } + $data = Yaml::parse($contents); + if (is_array($data) && isset($data['version'])) { + return (string)$data['version']; + } + } catch (Throwable $e) { + // ignore parse errors + } + + return null; + } + + private function readComposerVersion(string $dir): ?string + { + $file = $dir . '/composer.json'; + if (!is_file($file)) { + return null; + } + + $data = json_decode((string)@file_get_contents($file), true); + if (is_array($data) && isset($data['version'])) { + return (string)$data['version']; + } + + return null; + } + + private function packagesToArray($packages): array + { + if (!$packages) { + return []; + } + if (is_array($packages)) { + return $packages; + } + if ($packages instanceof \Traversable) { + return iterator_to_array($packages, true); + } + + return []; + } + + private function resolveRemotePackageVersion($package): ?string + { + if (!$package) { + return null; + } + if (is_array($package)) { + return $package['version'] ?? null; + } + if (is_object($package)) { + if (isset($package->version)) { + return (string)$package->version; + } + if (method_exists($package, 'offsetGet')) { + try { + return (string)$package->offsetGet('version'); + } catch (Throwable $e) { + return null; + } + } + } + + return null; + } + + private function isGpmPackagePublished($package): bool + { + if (is_object($package) && method_exists($package, 'getData')) { + $data = $package->getData(); + if ($data instanceof \Grav\Common\Data\Data) { + return $data->get('published') !== false; + } + } + if (is_array($package)) { + return array_key_exists('published', $package) ? $package['published'] !== false : true; + } + if (is_object($package) && property_exists($package, 'published')) { + return $package->published !== false; + } + + return true; + } + + private function detectPsrLogConflicts(): array + { + $conflicts = []; + foreach (glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: [] as $path) { + $composerFile = $path . '/composer.json'; + if (!is_file($composerFile)) { + continue; + } + $json = json_decode((string)@file_get_contents($composerFile), true); + if (!is_array($json)) { + continue; + } + $slug = basename($path); + if (!$this->isPluginEnabled($slug)) { + continue; + } + + $rawConstraint = $json['require']['psr/log'] ?? ($json['require-dev']['psr/log'] ?? null); + if (!$rawConstraint) { + continue; + } + + $constraint = strtolower((string)$rawConstraint); + $compatible = $constraint === '*' + || false !== strpos($constraint, '3') + || false !== strpos($constraint, '4') + || (false !== strpos($constraint, '>=') && preg_match('/>=\s*3/', $constraint)); + + if ($compatible) { + continue; + } + + $conflicts[$slug] = ['composer' => $composerFile, 'requires' => $rawConstraint]; + } + + return $conflicts; + } + + private function detectMonologConflicts(): array + { + $conflicts = []; + $pattern = '/->add(?:Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)\s*\(/i'; + + foreach (glob(GRAV_ROOT . '/user/plugins/*', GLOB_ONLYDIR) ?: [] as $path) { + $slug = basename($path); + if (!$this->isPluginEnabled($slug)) { + continue; + } + + $directory = new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS); + $filter = new \RecursiveCallbackFilterIterator($directory, static function ($current, $key, $iterator) { + if ($current->getFilename()[0] === '.') { + return false; + } + if ($iterator->hasChildren()) { + return !in_array($current->getFilename(), ['vendor', 'node_modules'], true); + } + + return $current->getExtension() === 'php'; + }); + $iterator = new \RecursiveIteratorIterator($filter); + + foreach ($iterator as $file) { + $contents = @file_get_contents($file->getPathname()); + if ($contents === false) { + continue; + } + if (preg_match($pattern, $contents, $match)) { + $relative = str_replace(GRAV_ROOT . '/', '', $file->getPathname()); + $conflicts[$slug][] = ['file' => $relative, 'method' => trim($match[0])]; + } + } + } + + return $conflicts; + } + + private function isPluginEnabled(string $slug): bool + { + $configPath = GRAV_ROOT . '/user/config/plugins/' . $slug . '.yaml'; + if (is_file($configPath)) { + try { + $contents = @file_get_contents($configPath); + if ($contents !== false) { + $data = Yaml::parse($contents); + if (is_array($data) && array_key_exists('enabled', $data)) { + return (bool)$data['enabled']; + } + } + } catch (Throwable $e) { + // ignore parse errors + } + } + + return true; + } + + private function isThemeEnabled(string $slug): bool + { + $configPath = GRAV_ROOT . '/user/config/system.yaml'; + if (is_file($configPath)) { + try { + $contents = @file_get_contents($configPath); + if ($contents !== false) { + $data = Yaml::parse($contents); + if (is_array($data)) { + $active = $data['pages']['theme'] ?? ($data['system']['pages']['theme'] ?? null); + if ($active !== null) { + return $active === $slug; + } + } + } + } catch (Throwable $e) { + // ignore parse errors + } + } + + return true; + } + + /** + * @return array{blocking: array, warnings: array, target: string} + */ + private function detectIncompatiblePackages(string $targetVersion): array + { + $parts = explode('.', $targetVersion); + $targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7'); + + $blocking = []; + $warnings = []; + $scanRoot = GRAV_ROOT ?: getcwd(); + + foreach (glob($scanRoot . '/user/plugins/*', GLOB_ONLYDIR) ?: [] as $dir) { + $slug = basename($dir); + $compat = $this->readBlueprintCompatibility($dir); + if (in_array($targetMajorMinor, $compat['grav'], true)) { + continue; + } + $version = $this->readBlueprintVersion($dir) ?? 'unknown'; + $enabled = $this->isPluginEnabled($slug); + $entry = [ + 'type' => 'plugin', + 'version' => $version, + 'compatibility' => $compat, + 'enabled' => $enabled, + ]; + if ($enabled) { + $blocking[$slug] = $entry; + } else { + $warnings[$slug] = $entry; + } + } + + foreach (glob($scanRoot . '/user/themes/*', GLOB_ONLYDIR) ?: [] as $dir) { + $slug = basename($dir); + $compat = $this->readBlueprintCompatibility($dir); + if (in_array($targetMajorMinor, $compat['grav'], true)) { + continue; + } + $version = $this->readBlueprintVersion($dir) ?? 'unknown'; + $active = $this->isThemeEnabled($slug); + $entry = [ + 'type' => 'theme', + 'version' => $version, + 'compatibility' => $compat, + 'enabled' => $active, + ]; + if ($active) { + $blocking[$slug] = $entry; + } else { + $warnings[$slug] = $entry; + } + } + + return ['blocking' => $blocking, 'warnings' => $warnings, 'target' => $targetMajorMinor]; + } + + /** + * @return array{grav: string[], api: string[]} + */ + private function readBlueprintCompatibility(string $dir): array + { + $file = $dir . '/blueprints.yaml'; + if (!is_file($file)) { + return ['grav' => [], 'api' => []]; + } + + try { + $contents = @file_get_contents($file); + if ($contents === false) { + return ['grav' => [], 'api' => []]; + } + $data = Yaml::parse($contents); + if (!is_array($data)) { + return ['grav' => [], 'api' => []]; + } + + if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) { + return [ + 'grav' => array_map('strval', $data['compatibility']['grav']), + 'api' => isset($data['compatibility']['api']) && is_array($data['compatibility']['api']) + ? array_map('strval', $data['compatibility']['api']) + : [], + ]; + } + + return $this->inferCompatibleVersions($data['dependencies'] ?? []); + } catch (Throwable $e) { + return ['grav' => [], 'api' => []]; + } + } + + /** + * @return array{grav: string[], api: string[]} + */ + private function inferCompatibleVersions(array $dependencies): array + { + foreach ($dependencies as $dep) { + if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') { + continue; + } + $version = $dep['version'] ?? ''; + if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) { + continue; + } + + if (version_compare($m[1], '2.0', '>=')) { + return ['grav' => ['2.0'], 'api' => []]; + } + if (version_compare($m[1], '1.8', '>=')) { + return ['grav' => ['1.8'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } + + return ['grav' => ['1.7'], 'api' => []]; + } }