\DateTimeZone::ASIA, 'Europe' => \DateTimeZone::EUROPE, 'America' => \DateTimeZone::AMERICA, 'Africa' => \DateTimeZone::AFRICA, 'Australia' => \DateTimeZone::AUSTRALIA, 'Pacific' => \DateTimeZone::PACIFIC, 'Atlantic' => \DateTimeZone::ATLANTIC, 'Indian' => \DateTimeZone::INDIAN, 'Antarctica' => \DateTimeZone::ANTARCTICA, 'Arctic' => \DateTimeZone::ARCTIC, 'UTC' => \DateTimeZone::UTC, ]; // --- Locale => default timezone --- private const LOCALE_DEFAULT_TIMEZONES = [ 'zh_CN' => 'Asia/Shanghai', 'zh_TW' => 'Asia/Taipei', 'en' => 'UTC', 'ja' => 'Asia/Tokyo', 'ko' => 'Asia/Seoul', 'fr' => 'Europe/Paris', 'de' => 'Europe/Berlin', 'es' => 'Europe/Madrid', 'pt_BR' => 'America/Sao_Paulo', 'ru' => 'Europe/Moscow', 'vi' => 'Asia/Ho_Chi_Minh', 'tr' => 'Europe/Istanbul', 'id' => 'Asia/Jakarta', 'th' => 'Asia/Bangkok', ]; // --- Locale options (localized display names) --- private const LOCALE_LABELS = [ 'zh_CN' => '简体中文', 'zh_TW' => '繁體中文', 'en' => 'English', 'ja' => '日本語', 'ko' => '한국어', 'fr' => 'Français', 'de' => 'Deutsch', 'es' => 'Español', 'pt_BR' => 'Português (Brasil)', 'ru' => 'Русский', 'vi' => 'Tiếng Việt', 'tr' => 'Türkçe', 'id' => 'Bahasa Indonesia', 'th' => 'ไทย', ]; // --- Multilingual messages (%s = placeholder) --- private const MESSAGES = [ 'zh_CN' => [ 'remove_package_question' => '发现以下已安装组件本次未选择,是否将其卸载 ?%s', 'removing_package' => '- 准备移除组件 %s', 'removing' => '卸载:', 'error_remove' => '卸载组件出错,请手动执行:composer remove %s', 'done_remove' => '已卸载组件。', 'skip' => '非交互模式,跳过安装向导。', 'default_choice' => ' (默认 %s)', 'timezone_prompt' => '时区 (默认 %s,输入可联想补全): ', 'timezone_title' => '时区设置 (默认 %s)', 'timezone_help' => '输入关键字Tab自动补全,可↑↓下选择:', 'timezone_region' => '选择时区区域', 'timezone_city' => '选择时区', 'timezone_invalid' => '无效的时区,已使用默认值 %s', 'timezone_input_prompt' => '输入时区或关键字:', 'timezone_pick_prompt' => '请输入数字编号或关键字:', 'timezone_no_match' => '未找到匹配的时区,请重试。', 'timezone_invalid_index' => '无效的编号,请重新输入。', 'yes' => '是', 'no' => '否', 'adding_package' => '- 添加依赖 %s', 'console_question' => '安装命令行组件 webman/console', 'db_question' => '数据库组件', 'db_none' => '不安装', 'db_invalid' => '请输入有效选项', 'redis_question' => '安装 Redis 组件 webman/redis', 'events_note' => ' (Redis 依赖 illuminate/events,已自动包含)', 'validation_question' => '安装验证器组件 webman/validation', 'template_question' => '模板引擎', 'template_none' => '不安装', 'no_components' => '未选择额外组件。', 'installing' => '即将安装:', 'running' => '执行:', 'error_install' => '安装可选组件时出错,请手动执行:composer require %s', 'done' => '可选组件安装完成。', 'summary_locale' => '语言:%s', 'summary_timezone' => '时区:%s', ], 'zh_TW' => [ 'skip' => '非交互模式,跳過安裝嚮導。', 'default_choice' => ' (預設 %s)', 'timezone_prompt' => '時區 (預設 %s,輸入可聯想補全): ', 'timezone_title' => '時區設定 (預設 %s)', 'timezone_help' => '輸入關鍵字Tab自動補全,可↑↓上下選擇:', 'timezone_region' => '選擇時區區域', 'timezone_city' => '選擇時區', 'timezone_invalid' => '無效的時區,已使用預設值 %s', 'timezone_input_prompt' => '輸入時區或關鍵字:', 'timezone_pick_prompt' => '請輸入數字編號或關鍵字:', 'timezone_no_match' => '未找到匹配的時區,請重試。', 'timezone_invalid_index' => '無效的編號,請重新輸入。', 'yes' => '是', 'no' => '否', 'adding_package' => '- 新增依賴 %s', 'console_question' => '安裝命令列組件 webman/console', 'db_question' => '資料庫組件', 'db_none' => '不安裝', 'db_invalid' => '請輸入有效選項', 'redis_question' => '安裝 Redis 組件 webman/redis', 'events_note' => ' (Redis 依賴 illuminate/events,已自動包含)', 'validation_question' => '安裝驗證器組件 webman/validation', 'template_question' => '模板引擎', 'template_none' => '不安裝', 'no_components' => '未選擇額外組件。', 'installing' => '即將安裝:', 'running' => '執行:', 'error_install' => '安裝可選組件時出錯,請手動執行:composer require %s', 'done' => '可選組件安裝完成。', 'summary_locale' => '語言:%s', 'summary_timezone' => '時區:%s', ], 'en' => [ 'skip' => 'Non-interactive mode, skipping setup wizard.', 'default_choice' => ' (default %s)', 'timezone_prompt' => 'Timezone (default=%s, type to autocomplete): ', 'timezone_title' => 'Timezone (default=%s)', 'timezone_help' => 'Type keyword then press Tab to autocomplete, use ↑↓ to choose:', 'timezone_region' => 'Select timezone region', 'timezone_city' => 'Select timezone', 'timezone_invalid' => 'Invalid timezone, using default %s', 'timezone_input_prompt' => 'Enter timezone or keyword:', 'timezone_pick_prompt' => 'Enter number or keyword:', 'timezone_no_match' => 'No matching timezone found, please try again.', 'timezone_invalid_index' => 'Invalid number, please try again.', 'yes' => 'yes', 'no' => 'no', 'adding_package' => '- Adding package %s', 'console_question' => 'Install console component webman/console', 'db_question' => 'Database component', 'db_none' => 'None', 'db_invalid' => 'Please enter a valid option', 'redis_question' => 'Install Redis component webman/redis', 'events_note' => ' (Redis requires illuminate/events, automatically included)', 'validation_question' => 'Install validator component webman/validation', 'template_question' => 'Template engine', 'template_none' => 'None', 'no_components' => 'No optional components selected.', 'installing' => 'Installing:', 'running' => 'Running:', 'error_install' => 'Failed to install. Try manually: composer require %s', 'done' => 'Optional components installed.', 'summary_locale' => 'Language: %s', 'summary_timezone' => 'Timezone: %s', ], 'ja' => [ 'skip' => '非対話モードのため、セットアップウィザードをスキップします。', 'default_choice' => ' (デフォルト %s)', 'timezone_prompt' => 'タイムゾーン (デフォルト=%s、入力で補完): ', 'timezone_title' => 'タイムゾーン (デフォルト=%s)', 'timezone_help' => 'キーワード入力→Tabで補完、↑↓で選択:', 'timezone_region' => 'タイムゾーンの地域を選択', 'timezone_city' => 'タイムゾーンを選択', 'timezone_invalid' => '無効なタイムゾーンです。デフォルト %s を使用します', 'timezone_input_prompt' => 'タイムゾーンまたはキーワードを入力:', 'timezone_pick_prompt' => '番号またはキーワードを入力:', 'timezone_no_match' => '一致するタイムゾーンが見つかりません。再試行してください。', 'timezone_invalid_index' => '無効な番号です。もう一度入力してください。', 'yes' => 'はい', 'no' => 'いいえ', 'adding_package' => '- パッケージを追加 %s', 'console_question' => 'コンソールコンポーネント webman/console をインストール', 'db_question' => 'データベースコンポーネント', 'db_none' => 'インストールしない', 'db_invalid' => '有効なオプションを入力してください', 'redis_question' => 'Redis コンポーネント webman/redis をインストール', 'events_note' => ' (Redis は illuminate/events が必要です。自動的に含まれます)', 'validation_question' => 'バリデーションコンポーネント webman/validation をインストール', 'template_question' => 'テンプレートエンジン', 'template_none' => 'インストールしない', 'no_components' => 'オプションコンポーネントが選択されていません。', 'installing' => 'インストール中:', 'running' => '実行中:', 'error_install' => 'インストールに失敗しました。手動で実行してください:composer require %s', 'done' => 'オプションコンポーネントのインストールが完了しました。', 'summary_locale' => '言語:%s', 'summary_timezone' => 'タイムゾーン:%s', ], 'ko' => [ 'skip' => '비대화형 모드입니다. 설치 마법사를 건너뜁니다.', 'default_choice' => ' (기본값 %s)', 'timezone_prompt' => '시간대 (기본값=%s, 입력하여 자동완성): ', 'timezone_title' => '시간대 (기본값=%s)', 'timezone_help' => '키워드 입력 후 Tab 자동완성, ↑↓로 선택:', 'timezone_region' => '시간대 지역 선택', 'timezone_city' => '시간대 선택', 'timezone_invalid' => '잘못된 시간대입니다. 기본값 %s 을(를) 사용합니다', 'timezone_input_prompt' => '시간대 또는 키워드 입력:', 'timezone_pick_prompt' => '번호 또는 키워드 입력:', 'timezone_no_match' => '일치하는 시간대를 찾을 수 없습니다. 다시 시도하세요.', 'timezone_invalid_index' => '잘못된 번호입니다. 다시 입력하세요.', 'yes' => '예', 'no' => '아니오', 'adding_package' => '- 패키지 추가 %s', 'console_question' => '콘솔 컴포넌트 webman/console 설치', 'db_question' => '데이터베이스 컴포넌트', 'db_none' => '설치 안 함', 'db_invalid' => '유효한 옵션을 입력하세요', 'redis_question' => 'Redis 컴포넌트 webman/redis 설치', 'events_note' => ' (Redis는 illuminate/events가 필요합니다. 자동으로 포함됩니다)', 'validation_question' => '검증 컴포넌트 webman/validation 설치', 'template_question' => '템플릿 엔진', 'template_none' => '설치 안 함', 'no_components' => '선택된 추가 컴포넌트가 없습니다.', 'installing' => '설치 예정:', 'running' => '실행 중:', 'error_install' => '설치에 실패했습니다. 수동으로 실행하세요: composer require %s', 'done' => '선택 컴포넌트 설치가 완료되었습니다.', 'summary_locale' => '언어: %s', 'summary_timezone' => '시간대: %s', ], 'fr' => [ 'skip' => 'Mode non interactif, assistant d\'installation ignoré.', 'default_choice' => ' (défaut %s)', 'timezone_prompt' => 'Fuseau horaire (défaut=%s, tapez pour compléter) : ', 'timezone_title' => 'Fuseau horaire (défaut=%s)', 'timezone_help' => 'Tapez un mot-clé, Tab pour compléter, ↑↓ pour choisir :', 'timezone_region' => 'Sélectionnez la région du fuseau horaire', 'timezone_city' => 'Sélectionnez le fuseau horaire', 'timezone_invalid' => 'Fuseau horaire invalide, utilisation de %s par défaut', 'timezone_input_prompt' => 'Entrez un fuseau horaire ou un mot-clé :', 'timezone_pick_prompt' => 'Entrez un numéro ou un mot-clé :', 'timezone_no_match' => 'Aucun fuseau horaire correspondant, veuillez réessayer.', 'timezone_invalid_index' => 'Numéro invalide, veuillez réessayer.', 'yes' => 'oui', 'no' => 'non', 'adding_package' => '- Ajout du paquet %s', 'console_question' => 'Installer le composant console webman/console', 'db_question' => 'Composant base de données', 'db_none' => 'Aucun', 'db_invalid' => 'Veuillez entrer une option valide', 'redis_question' => 'Installer le composant Redis webman/redis', 'events_note' => ' (Redis nécessite illuminate/events, inclus automatiquement)', 'validation_question' => 'Installer le composant de validation webman/validation', 'template_question' => 'Moteur de templates', 'template_none' => 'Aucun', 'no_components' => 'Aucun composant optionnel sélectionné.', 'installing' => 'Installation en cours :', 'running' => 'Exécution :', 'error_install' => 'Échec de l\'installation. Essayez manuellement : composer require %s', 'done' => 'Composants optionnels installés.', 'summary_locale' => 'Langue : %s', 'summary_timezone' => 'Fuseau horaire : %s', ], 'de' => [ 'skip' => 'Nicht-interaktiver Modus, Einrichtungsassistent übersprungen.', 'default_choice' => ' (Standard %s)', 'timezone_prompt' => 'Zeitzone (Standard=%s, Eingabe zur Vervollständigung): ', 'timezone_title' => 'Zeitzone (Standard=%s)', 'timezone_help' => 'Stichwort tippen, Tab ergänzt, ↑↓ auswählen:', 'timezone_region' => 'Zeitzone Region auswählen', 'timezone_city' => 'Zeitzone auswählen', 'timezone_invalid' => 'Ungültige Zeitzone, Standardwert %s wird verwendet', 'timezone_input_prompt' => 'Zeitzone oder Stichwort eingeben:', 'timezone_pick_prompt' => 'Nummer oder Stichwort eingeben:', 'timezone_no_match' => 'Keine passende Zeitzone gefunden, bitte erneut versuchen.', 'timezone_invalid_index' => 'Ungültige Nummer, bitte erneut eingeben.', 'yes' => 'ja', 'no' => 'nein', 'adding_package' => '- Paket hinzufügen %s', 'console_question' => 'Konsolen-Komponente webman/console installieren', 'db_question' => 'Datenbank-Komponente', 'db_none' => 'Keine', 'db_invalid' => 'Bitte geben Sie eine gültige Option ein', 'redis_question' => 'Redis-Komponente webman/redis installieren', 'events_note' => ' (Redis benötigt illuminate/events, automatisch eingeschlossen)', 'validation_question' => 'Validierungs-Komponente webman/validation installieren', 'template_question' => 'Template-Engine', 'template_none' => 'Keine', 'no_components' => 'Keine optionalen Komponenten ausgewählt.', 'installing' => 'Installation:', 'running' => 'Ausführung:', 'error_install' => 'Installation fehlgeschlagen. Manuell ausführen: composer require %s', 'done' => 'Optionale Komponenten installiert.', 'summary_locale' => 'Sprache: %s', 'summary_timezone' => 'Zeitzone: %s', ], 'es' => [ 'skip' => 'Modo no interactivo, asistente de instalación omitido.', 'default_choice' => ' (predeterminado %s)', 'timezone_prompt' => 'Zona horaria (predeterminado=%s, escriba para autocompletar): ', 'timezone_title' => 'Zona horaria (predeterminado=%s)', 'timezone_help' => 'Escriba una palabra clave, Tab autocompleta, use ↑↓ para elegir:', 'timezone_region' => 'Seleccione la región de zona horaria', 'timezone_city' => 'Seleccione la zona horaria', 'timezone_invalid' => 'Zona horaria inválida, usando valor predeterminado %s', 'timezone_input_prompt' => 'Ingrese zona horaria o palabra clave:', 'timezone_pick_prompt' => 'Ingrese número o palabra clave:', 'timezone_no_match' => 'No se encontró zona horaria coincidente, intente de nuevo.', 'timezone_invalid_index' => 'Número inválido, intente de nuevo.', 'yes' => 'sí', 'no' => 'no', 'adding_package' => '- Agregando paquete %s', 'console_question' => 'Instalar componente de consola webman/console', 'db_question' => 'Componente de base de datos', 'db_none' => 'Ninguno', 'db_invalid' => 'Por favor ingrese una opción válida', 'redis_question' => 'Instalar componente Redis webman/redis', 'events_note' => ' (Redis requiere illuminate/events, incluido automáticamente)', 'validation_question' => 'Instalar componente de validación webman/validation', 'template_question' => 'Motor de plantillas', 'template_none' => 'Ninguno', 'no_components' => 'No se seleccionaron componentes opcionales.', 'installing' => 'Instalando:', 'running' => 'Ejecutando:', 'error_install' => 'Error en la instalación. Intente manualmente: composer require %s', 'done' => 'Componentes opcionales instalados.', 'summary_locale' => 'Idioma: %s', 'summary_timezone' => 'Zona horaria: %s', ], 'pt_BR' => [ 'skip' => 'Modo não interativo, assistente de instalação ignorado.', 'default_choice' => ' (padrão %s)', 'timezone_prompt' => 'Fuso horário (padrão=%s, digite para autocompletar): ', 'timezone_title' => 'Fuso horário (padrão=%s)', 'timezone_help' => 'Digite uma palavra-chave, Tab autocompleta, use ↑↓ para escolher:', 'timezone_region' => 'Selecione a região do fuso horário', 'timezone_city' => 'Selecione o fuso horário', 'timezone_invalid' => 'Fuso horário inválido, usando padrão %s', 'timezone_input_prompt' => 'Digite fuso horário ou palavra-chave:', 'timezone_pick_prompt' => 'Digite número ou palavra-chave:', 'timezone_no_match' => 'Nenhum fuso horário encontrado, tente novamente.', 'timezone_invalid_index' => 'Número inválido, tente novamente.', 'yes' => 'sim', 'no' => 'não', 'adding_package' => '- Adicionando pacote %s', 'console_question' => 'Instalar componente de console webman/console', 'db_question' => 'Componente de banco de dados', 'db_none' => 'Nenhum', 'db_invalid' => 'Por favor, digite uma opção válida', 'redis_question' => 'Instalar componente Redis webman/redis', 'events_note' => ' (Redis requer illuminate/events, incluído automaticamente)', 'validation_question' => 'Instalar componente de validação webman/validation', 'template_question' => 'Motor de templates', 'template_none' => 'Nenhum', 'no_components' => 'Nenhum componente opcional selecionado.', 'installing' => 'Instalando:', 'running' => 'Executando:', 'error_install' => 'Falha na instalação. Tente manualmente: composer require %s', 'done' => 'Componentes opcionais instalados.', 'summary_locale' => 'Idioma: %s', 'summary_timezone' => 'Fuso horário: %s', ], 'ru' => [ 'skip' => 'Неинтерактивный режим, мастер установки пропущен.', 'default_choice' => ' (по умолчанию %s)', 'timezone_prompt' => 'Часовой пояс (по умолчанию=%s, введите для автодополнения): ', 'timezone_title' => 'Часовой пояс (по умолчанию=%s)', 'timezone_help' => 'Введите ключевое слово, Tab для автодополнения, ↑↓ для выбора:', 'timezone_region' => 'Выберите регион часового пояса', 'timezone_city' => 'Выберите часовой пояс', 'timezone_invalid' => 'Неверный часовой пояс, используется значение по умолчанию %s', 'timezone_input_prompt' => 'Введите часовой пояс или ключевое слово:', 'timezone_pick_prompt' => 'Введите номер или ключевое слово:', 'timezone_no_match' => 'Совпадающий часовой пояс не найден, попробуйте снова.', 'timezone_invalid_index' => 'Неверный номер, попробуйте снова.', 'yes' => 'да', 'no' => 'нет', 'adding_package' => '- Добавление пакета %s', 'console_question' => 'Установить консольный компонент webman/console', 'db_question' => 'Компонент базы данных', 'db_none' => 'Не устанавливать', 'db_invalid' => 'Пожалуйста, введите допустимый вариант', 'redis_question' => 'Установить компонент Redis webman/redis', 'events_note' => ' (Redis требует illuminate/events, автоматически включён)', 'validation_question' => 'Установить компонент валидации webman/validation', 'template_question' => 'Шаблонизатор', 'template_none' => 'Не устанавливать', 'no_components' => 'Дополнительные компоненты не выбраны.', 'installing' => 'Установка:', 'running' => 'Выполнение:', 'error_install' => 'Ошибка установки. Выполните вручную: composer require %s', 'done' => 'Дополнительные компоненты установлены.', 'summary_locale' => 'Язык: %s', 'summary_timezone' => 'Часовой пояс: %s', ], 'vi' => [ 'skip' => 'Chế độ không tương tác, bỏ qua trình hướng dẫn cài đặt.', 'default_choice' => ' (mặc định %s)', 'timezone_prompt' => 'Múi giờ (mặc định=%s, nhập để tự động hoàn thành): ', 'timezone_title' => 'Múi giờ (mặc định=%s)', 'timezone_help' => 'Nhập từ khóa, Tab để tự hoàn thành, dùng ↑↓ để chọn:', 'timezone_region' => 'Chọn khu vực múi giờ', 'timezone_city' => 'Chọn múi giờ', 'timezone_invalid' => 'Múi giờ không hợp lệ, sử dụng mặc định %s', 'timezone_input_prompt' => 'Nhập múi giờ hoặc từ khóa:', 'timezone_pick_prompt' => 'Nhập số thứ tự hoặc từ khóa:', 'timezone_no_match' => 'Không tìm thấy múi giờ phù hợp, vui lòng thử lại.', 'timezone_invalid_index' => 'Số không hợp lệ, vui lòng thử lại.', 'yes' => 'có', 'no' => 'không', 'adding_package' => '- Thêm gói %s', 'console_question' => 'Cài đặt thành phần console webman/console', 'db_question' => 'Thành phần cơ sở dữ liệu', 'db_none' => 'Không cài đặt', 'db_invalid' => 'Vui lòng nhập tùy chọn hợp lệ', 'redis_question' => 'Cài đặt thành phần Redis webman/redis', 'events_note' => ' (Redis cần illuminate/events, đã tự động bao gồm)', 'validation_question' => 'Cài đặt thành phần xác thực webman/validation', 'template_question' => 'Công cụ mẫu', 'template_none' => 'Không cài đặt', 'no_components' => 'Không có thành phần tùy chọn nào được chọn.', 'installing' => 'Đang cài đặt:', 'running' => 'Đang thực thi:', 'error_install' => 'Cài đặt thất bại. Thử thủ công: composer require %s', 'done' => 'Các thành phần tùy chọn đã được cài đặt.', 'summary_locale' => 'Ngôn ngữ: %s', 'summary_timezone' => 'Múi giờ: %s', ], 'tr' => [ 'skip' => 'Etkileşimsiz mod, kurulum sihirbazı atlanıyor.', 'default_choice' => ' (varsayılan %s)', 'timezone_prompt' => 'Saat dilimi (varsayılan=%s, otomatik tamamlama için yazın): ', 'timezone_title' => 'Saat dilimi (varsayılan=%s)', 'timezone_help' => 'Anahtar kelime yazın, Tab tamamlar, ↑↓ ile seçin:', 'timezone_region' => 'Saat dilimi bölgesini seçin', 'timezone_city' => 'Saat dilimini seçin', 'timezone_invalid' => 'Geçersiz saat dilimi, varsayılan %s kullanılıyor', 'timezone_input_prompt' => 'Saat dilimi veya anahtar kelime girin:', 'timezone_pick_prompt' => 'Numara veya anahtar kelime girin:', 'timezone_no_match' => 'Eşleşen saat dilimi bulunamadı, tekrar deneyin.', 'timezone_invalid_index' => 'Geçersiz numara, tekrar deneyin.', 'yes' => 'evet', 'no' => 'hayır', 'adding_package' => '- Paket ekleniyor %s', 'console_question' => 'Konsol bileşeni webman/console yüklensin mi', 'db_question' => 'Veritabanı bileşeni', 'db_none' => 'Yok', 'db_invalid' => 'Lütfen geçerli bir seçenek girin', 'redis_question' => 'Redis bileşeni webman/redis yüklensin mi', 'events_note' => ' (Redis, illuminate/events gerektirir, otomatik olarak dahil edildi)', 'validation_question' => 'Doğrulama bileşeni webman/validation yüklensin mi', 'template_question' => 'Şablon motoru', 'template_none' => 'Yok', 'no_components' => 'İsteğe bağlı bileşen seçilmedi.', 'installing' => 'Yükleniyor:', 'running' => 'Çalıştırılıyor:', 'error_install' => 'Yükleme başarısız. Manuel olarak deneyin: composer require %s', 'done' => 'İsteğe bağlı bileşenler yüklendi.', 'summary_locale' => 'Dil: %s', 'summary_timezone' => 'Saat dilimi: %s', ], 'id' => [ 'skip' => 'Mode non-interaktif, melewati wizard instalasi.', 'default_choice' => ' (default %s)', 'timezone_prompt' => 'Zona waktu (default=%s, ketik untuk melengkapi): ', 'timezone_title' => 'Zona waktu (default=%s)', 'timezone_help' => 'Ketik kata kunci, Tab untuk melengkapi, gunakan ↑↓ untuk memilih:', 'timezone_region' => 'Pilih wilayah zona waktu', 'timezone_city' => 'Pilih zona waktu', 'timezone_invalid' => 'Zona waktu tidak valid, menggunakan default %s', 'timezone_input_prompt' => 'Masukkan zona waktu atau kata kunci:', 'timezone_pick_prompt' => 'Masukkan nomor atau kata kunci:', 'timezone_no_match' => 'Zona waktu tidak ditemukan, silakan coba lagi.', 'timezone_invalid_index' => 'Nomor tidak valid, silakan coba lagi.', 'yes' => 'ya', 'no' => 'tidak', 'adding_package' => '- Menambahkan paket %s', 'console_question' => 'Instal komponen konsol webman/console', 'db_question' => 'Komponen database', 'db_none' => 'Tidak ada', 'db_invalid' => 'Silakan masukkan opsi yang valid', 'redis_question' => 'Instal komponen Redis webman/redis', 'events_note' => ' (Redis memerlukan illuminate/events, otomatis disertakan)', 'validation_question' => 'Instal komponen validasi webman/validation', 'template_question' => 'Mesin template', 'template_none' => 'Tidak ada', 'no_components' => 'Tidak ada komponen opsional yang dipilih.', 'installing' => 'Menginstal:', 'running' => 'Menjalankan:', 'error_install' => 'Instalasi gagal. Coba manual: composer require %s', 'done' => 'Komponen opsional terinstal.', 'summary_locale' => 'Bahasa: %s', 'summary_timezone' => 'Zona waktu: %s', ], 'th' => [ 'skip' => 'โหมดไม่โต้ตอบ ข้ามตัวช่วยติดตั้ง', 'default_choice' => ' (ค่าเริ่มต้น %s)', 'timezone_prompt' => 'เขตเวลา (ค่าเริ่มต้น=%s พิมพ์เพื่อเติมอัตโนมัติ): ', 'timezone_title' => 'เขตเวลา (ค่าเริ่มต้น=%s)', 'timezone_help' => 'พิมพ์คีย์เวิร์ดแล้วกด Tab เพื่อเติมอัตโนมัติ ใช้ ↑↓ เพื่อเลือก:', 'timezone_region' => 'เลือกภูมิภาคเขตเวลา', 'timezone_city' => 'เลือกเขตเวลา', 'timezone_invalid' => 'เขตเวลาไม่ถูกต้อง ใช้ค่าเริ่มต้น %s', 'timezone_input_prompt' => 'ป้อนเขตเวลาหรือคำค้น:', 'timezone_pick_prompt' => 'ป้อนหมายเลขหรือคำค้น:', 'timezone_no_match' => 'ไม่พบเขตเวลาที่ตรงกัน กรุณาลองอีกครั้ง', 'timezone_invalid_index' => 'หมายเลขไม่ถูกต้อง กรุณาลองอีกครั้ง', 'yes' => 'ใช่', 'no' => 'ไม่', 'adding_package' => '- เพิ่มแพ็กเกจ %s', 'console_question' => 'ติดตั้งคอมโพเนนต์คอนโซล webman/console', 'db_question' => 'คอมโพเนนต์ฐานข้อมูล', 'db_none' => 'ไม่ติดตั้ง', 'db_invalid' => 'กรุณาป้อนตัวเลือกที่ถูกต้อง', 'redis_question' => 'ติดตั้งคอมโพเนนต์ Redis webman/redis', 'events_note' => ' (Redis ต้องการ illuminate/events รวมไว้โดยอัตโนมัติ)', 'validation_question' => 'ติดตั้งคอมโพเนนต์ตรวจสอบ webman/validation', 'template_question' => 'เทมเพลตเอนจิน', 'template_none' => 'ไม่ติดตั้ง', 'no_components' => 'ไม่ได้เลือกคอมโพเนนต์เสริม', 'installing' => 'กำลังติดตั้ง:', 'running' => 'กำลังดำเนินการ:', 'error_install' => 'ติดตั้งล้มเหลว ลองด้วยตนเอง: composer require %s', 'done' => 'คอมโพเนนต์เสริมติดตั้งเรียบร้อยแล้ว', 'summary_locale' => 'ภาษา: %s', 'summary_timezone' => 'เขตเวลา: %s', ], ]; // --- Interrupt message (Ctrl+C) --- private const INTERRUPTED_MESSAGES = [ 'zh_CN' => '安装中断,可运行 composer setup-webman 可重新设置。', 'zh_TW' => '安裝中斷,可運行 composer setup-webman 重新設置。', 'en' => 'Setup interrupted. Run "composer setup-webman" to restart setup.', 'ja' => 'セットアップが中断されました。composer setup-webman を実行して再設定できます。', 'ko' => '설치가 중단되었습니다. composer setup-webman 을 실행하여 다시 설정할 수 있습니다.', 'fr' => 'Installation interrompue. Exécutez « composer setup-webman » pour recommencer.', 'de' => 'Einrichtung abgebrochen. Führen Sie "composer setup-webman" aus, um neu zu starten.', 'es' => 'Instalación interrumpida. Ejecute "composer setup-webman" para reiniciar.', 'pt_BR' => 'Instalação interrompida. Execute "composer setup-webman" para reiniciar.', 'ru' => 'Установка прервана. Выполните «composer setup-webman» для повторной настройки.', 'vi' => 'Cài đặt bị gián đoạn. Chạy "composer setup-webman" để cài đặt lại.', 'tr' => 'Kurulum kesildi. Yeniden kurmak için "composer setup-webman" komutunu çalıştırın.', 'id' => 'Instalasi terganggu. Jalankan "composer setup-webman" untuk mengatur ulang.', 'th' => 'การติดตั้งถูกขัดจังหวะ เรียกใช้ "composer setup-webman" เพื่อตั้งค่าใหม่', ]; // --- Signal handling state --- /** @var string|null Saved stty mode for terminal restoration on interrupt */ private static ?string $sttyMode = null; /** @var string Current locale for interrupt message */ private static string $interruptLocale = 'en'; // ═══════════════════════════════════════════════════════════════ // Entry // ═══════════════════════════════════════════════════════════════ public static function run(Event $event): void { $io = $event->getIO(); // Non-interactive mode: use English for skip message if (!$io->isInteractive()) { $io->write('' . self::MESSAGES['en']['skip'] . ''); return; } try { self::doRun($event, $io); } catch (\Throwable $e) { $io->writeError(''); $io->writeError('Setup wizard error: ' . $e->getMessage() . ''); $io->writeError('Run "composer setup-webman" to retry.'); } } private static function doRun(Event $event, IOInterface $io): void { $io->write(''); // Register Ctrl+C handler self::registerInterruptHandler(); // Banner title (must be before locale selection) self::renderTitle(); // 1. Locale selection $locale = self::askLocale($io); self::$interruptLocale = $locale; $defaultTimezone = self::LOCALE_DEFAULT_TIMEZONES[$locale] ?? 'UTC'; $msg = fn(string $key, string ...$args): string => empty($args) ? self::MESSAGES[$locale][$key] : sprintf(self::MESSAGES[$locale][$key], ...$args); // Write locale config (update when not default) if ($locale !== 'zh_CN') { self::updateConfig($event, 'config/translation.php', "'locale'", $locale); } $io->write(''); $io->write(''); // 2. Timezone selection (default by locale) $timezone = self::askTimezone($io, $msg, $defaultTimezone); if ($timezone !== 'Asia/Shanghai') { self::updateConfig($event, 'config/app.php', "'default_timezone'", $timezone); } // 3. Optional components $packages = self::askComponents($io, $msg); // 4. Remove unselected components $removePackages = self::askRemoveComponents($event, $packages, $io, $msg); // 5. Summary $io->write(''); $io->write('─────────────────────────────────────'); $io->write('' . $msg('summary_locale', self::LOCALE_LABELS[$locale]) . ''); $io->write('' . $msg('summary_timezone', $timezone) . ''); // Remove unselected packages first to avoid dependency conflicts if ($removePackages !== []) { $io->write(''); $io->write('' . $msg('removing') . ''); $secondaryPackages = [ self::PACKAGE_ILLUMINATE_EVENTS, self::PACKAGE_ILLUMINATE_PAGINATION, self::PACKAGE_SYMFONY_VAR_DUMPER, ]; $displayRemovePackages = array_diff($removePackages, $secondaryPackages); foreach ($displayRemovePackages as $pkg) { $io->write(' - ' . $pkg); } $io->write(''); self::runComposerRemove($removePackages, $io, $msg); } // Then install selected packages if ($packages !== []) { $io->write(''); $io->write('' . $msg('installing') . ' ' . implode(', ', $packages)); $io->write(''); self::runComposerRequire($packages, $io, $msg); } elseif ($removePackages === []) { $io->write('' . $msg('no_components') . ''); } } private static function renderTitle(): void { $output = new ConsoleOutput(); $terminalWidth = (new Terminal())->getWidth(); if ($terminalWidth <= 0) { $terminalWidth = 80; } $text = ' ' . self::SETUP_TITLE . ' '; $minBoxWidth = 44; $maxBoxWidth = min($terminalWidth, 96); $boxWidth = min($maxBoxWidth, max($minBoxWidth, mb_strwidth($text) + 10)); $innerWidth = $boxWidth - 2; $textWidth = mb_strwidth($text); $pad = max(0, $innerWidth - $textWidth); $left = intdiv($pad, 2); $right = $pad - $left; $line2 = '│' . str_repeat(' ', $left) . $text . str_repeat(' ', $right) . '│'; $line1 = '┌' . str_repeat('─', $innerWidth) . '┐'; $line3 = '└' . str_repeat('─', $innerWidth) . '┘'; $output->writeln(''); $output->writeln('' . $line1 . ''); $output->writeln('' . $line2 . ''); $output->writeln('' . $line3 . ''); $output->writeln(''); } // ═══════════════════════════════════════════════════════════════ // Signal handling (Ctrl+C) // ═══════════════════════════════════════════════════════════════ /** * Register Ctrl+C (SIGINT) handler to show a friendly message on interrupt. * Gracefully skipped when the required extensions are unavailable. */ private static function registerInterruptHandler(): void { // Unix/Linux/Mac: pcntl extension with async signals for immediate delivery /*if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { pcntl_async_signals(true); pcntl_signal(\SIGINT, [self::class, 'handleInterrupt']); return; }*/ // Windows: sapi ctrl handler (PHP >= 7.4) if (function_exists('sapi_windows_set_ctrl_handler')) { sapi_windows_set_ctrl_handler(static function (int $event) { if ($event === \PHP_WINDOWS_EVENT_CTRL_C) { self::handleInterrupt(); } }); } } /** * Handle Ctrl+C: restore terminal, show tip, then exit. */ private static function handleInterrupt(): void { // Restore terminal if in raw mode if (self::$sttyMode !== null && function_exists('shell_exec')) { @shell_exec('stty ' . self::$sttyMode); self::$sttyMode = null; } $output = new ConsoleOutput(); $output->writeln(''); $output->writeln('' . (self::INTERRUPTED_MESSAGES[self::$interruptLocale] ?? self::INTERRUPTED_MESSAGES['en']) . ''); exit(1); } // ═══════════════════════════════════════════════════════════════ // Interactive Menu System // ═══════════════════════════════════════════════════════════════ /** * Check if terminal supports interactive features (arrow keys, ANSI colors). */ private static function supportsInteractive(): bool { return function_exists('shell_exec') && Terminal::hasSttyAvailable(); } /** * Display a selection menu with arrow key navigation (if supported) or text input fallback. * * @param IOInterface $io Composer IO * @param string $title Menu title * @param array $items Indexed array of ['tag' => string, 'label' => string] * @param int $default Default selected index (0-based) * @return int Selected index */ private static function selectMenu(IOInterface $io, string $title, array $items, int $default = 0): int { // Append localized "default" hint to avoid ambiguity // (Template should contain a single %s placeholder for the default tag.) $defaultHintTemplate = null; if (isset(self::MESSAGES[self::$interruptLocale]['default_choice'])) { $defaultHintTemplate = self::MESSAGES[self::$interruptLocale]['default_choice']; } $defaultTag = $items[$default]['tag'] ?? ''; if ($defaultHintTemplate && $defaultTag !== '') { $title .= sprintf($defaultHintTemplate, $defaultTag); } elseif ($defaultTag !== '') { // Fallback for early menus (e.g. locale selection) before locale is chosen. $title .= sprintf(' (default %s)', $defaultTag); } if (self::supportsInteractive()) { return self::arrowKeySelect($title, $items, $default); } return self::fallbackSelect($io, $title, $items, $default); } /** * Display a yes/no confirmation as a selection menu. * * @param IOInterface $io Composer IO * @param string $title Menu title * @param bool $default Default value (true = yes) * @return bool User's choice */ private static function confirmMenu(IOInterface $io, string $title, bool $default = true): bool { $locale = self::$interruptLocale; $yes = self::MESSAGES[$locale]['yes'] ?? self::MESSAGES['en']['yes'] ?? 'yes'; $no = self::MESSAGES[$locale]['no'] ?? self::MESSAGES['en']['no'] ?? 'no'; $items = $default ? [['tag' => 'Y', 'label' => $yes], ['tag' => 'n', 'label' => $no]] : [['tag' => 'y', 'label' => $yes], ['tag' => 'N', 'label' => $no]]; $defaultIndex = $default ? 0 : 1; return self::selectMenu($io, $title, $items, $defaultIndex) === 0; } /** * Interactive select with arrow key navigation, manual input and ANSI reverse-video highlighting. * Input area and option list highlighting are bidirectionally linked. * Requires stty (Unix-like terminals). */ private static function arrowKeySelect(string $title, array $items, int $default): int { $output = new ConsoleOutput(); $count = count($items); $selected = $default; $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items)); $defaultTag = $items[$default]['tag']; $input = $defaultTag; // Print title and initial options $output->writeln(''); $output->writeln('' . $title . ''); self::drawMenuItems($output, $items, $selected, $maxTagWidth); $output->write('> ' . $input); // Enter raw mode self::$sttyMode = shell_exec('stty -g'); shell_exec('stty -icanon -echo'); try { while (!feof(STDIN)) { $c = fread(STDIN, 1); if (false === $c || '' === $c) { break; } // ── Backspace ── if ("\177" === $c || "\010" === $c) { if ('' !== $input) { $input = mb_substr($input, 0, -1); } $selected = self::findItemByTag($items, $input); $output->write("\033[{$count}A"); self::drawMenuItems($output, $items, $selected, $maxTagWidth); $output->write("\033[2K\r> " . $input); continue; } // ── Escape sequences (arrow keys) ── if ("\033" === $c) { $seq = fread(STDIN, 2); if (isset($seq[1])) { $changed = false; if ('A' === $seq[1]) { // Up $selected = ($selected <= 0 ? $count : $selected) - 1; $changed = true; } elseif ('B' === $seq[1]) { // Down $selected = ($selected + 1) % $count; $changed = true; } if ($changed) { // Sync input with selected item's tag $input = $items[$selected]['tag']; $output->write("\033[{$count}A"); self::drawMenuItems($output, $items, $selected, $maxTagWidth); $output->write("\033[2K\r> " . $input); } } continue; } // ── Enter: confirm selection ── if ("\n" === $c || "\r" === $c) { if ($selected < 0) { $selected = $default; } $output->write("\033[2K\r> " . $items[$selected]['tag'] . ' ' . $items[$selected]['label'] . ''); $output->writeln(''); break; } // ── Ignore other control characters ── if (ord($c) < 32) { continue; } // ── Printable character (with UTF-8 multi-byte support) ── if ("\x80" <= $c) { $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3]; $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0); } $input .= $c; $selected = self::findItemByTag($items, $input); $output->write("\033[{$count}A"); self::drawMenuItems($output, $items, $selected, $maxTagWidth); $output->write("\033[2K\r> " . $input); } } finally { if (self::$sttyMode !== null) { shell_exec('stty ' . self::$sttyMode); self::$sttyMode = null; } } return $selected < 0 ? $default : $selected; } /** * Fallback select for terminals without stty support. Uses plain text input. */ private static function fallbackSelect(IOInterface $io, string $title, array $items, int $default): int { $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items)); $defaultTag = $items[$default]['tag']; $io->write(''); $io->write('' . $title . ''); foreach ($items as $item) { $tag = str_pad($item['tag'], $maxTagWidth); $io->write(" [$tag] " . $item['label']); } while (true) { $io->write('> ', false); $line = fgets(STDIN); if ($line === false) { return $default; } $answer = trim($line); if ($answer === '') { $io->write('> ' . $items[$default]['tag'] . ' ' . $items[$default]['label'] . ''); return $default; } // Match by tag (case-insensitive) foreach ($items as $i => $item) { if (strcasecmp($item['tag'], $answer) === 0) { $io->write('> ' . $items[$i]['tag'] . ' ' . $items[$i]['label'] . ''); return $i; } } } } /** * Render menu items with optional ANSI reverse-video highlighting for the selected item. * When $selected is -1, no item is highlighted. */ private static function drawMenuItems(ConsoleOutput $output, array $items, int $selected, int $maxTagWidth): void { foreach ($items as $i => $item) { $tag = str_pad($item['tag'], $maxTagWidth); $line = " [$tag] " . $item['label']; if ($i === $selected) { $output->writeln("\033[2K\r\033[7m" . $line . "\033[0m"); } else { $output->writeln("\033[2K\r" . $line); } } } /** * Find item index by tag (case-insensitive exact match). * Returns -1 if no match found or input is empty. */ private static function findItemByTag(array $items, string $input): int { if ($input === '') { return -1; } foreach ($items as $i => $item) { if (strcasecmp($item['tag'], $input) === 0) { return $i; } } return -1; } // ═══════════════════════════════════════════════════════════════ // Locale selection // ═══════════════════════════════════════════════════════════════ private static function askLocale(IOInterface $io): string { $locales = array_keys(self::LOCALE_LABELS); $items = []; foreach ($locales as $i => $code) { $items[] = ['tag' => (string) $i, 'label' => self::LOCALE_LABELS[$code] . " ($code)"]; } $selected = self::selectMenu( $io, '语言 / Language / 言語 / 언어', $items, 0 ); return $locales[$selected]; } // ═══════════════════════════════════════════════════════════════ // Timezone selection // ═══════════════════════════════════════════════════════════════ private static function askTimezone(IOInterface $io, callable $msg, string $default): string { if (self::supportsInteractive()) { return self::askTimezoneAutocomplete($msg, $default); } return self::askTimezoneSelect($io, $msg, $default); } /** * Option A: when stty is available, custom character-by-character autocomplete * (case-insensitive, substring match). Interaction: type to filter, hint on right; * ↑↓ change candidate, Tab accept, Enter confirm; empty input = use default. */ private static function askTimezoneAutocomplete(callable $msg, string $default): string { $allTimezones = \DateTimeZone::listIdentifiers(); $output = new ConsoleOutput(); $cursor = new Cursor($output); $output->writeln(''); $output->writeln('' . $msg('timezone_title', $default) . ''); $output->writeln($msg('timezone_help')); $output->write('> '); self::$sttyMode = shell_exec('stty -g'); shell_exec('stty -icanon -echo'); // Auto-fill default timezone in the input area; user can edit it directly. $input = $default; $output->write($input); $ofs = 0; $matches = self::filterTimezones($allTimezones, $input); if (!empty($matches)) { $hint = $matches[$ofs % count($matches)]; // Avoid duplicating hint when input already fully matches the only candidate. if (!(count($matches) === 1 && $hint === $input)) { $cursor->clearLineAfter(); $cursor->savePosition(); $output->write(' ' . $hint . ''); if (count($matches) > 1) { $output->write(' (' . count($matches) . ' matches, ↑↓)'); } $cursor->restorePosition(); } } try { while (!feof(STDIN)) { $c = fread(STDIN, 1); if (false === $c || '' === $c) { break; } // ── Backspace ── if ("\177" === $c || "\010" === $c) { if ('' !== $input) { $lastChar = mb_substr($input, -1); $input = mb_substr($input, 0, -1); $cursor->moveLeft(max(1, mb_strwidth($lastChar))); } $ofs = 0; // ── Escape sequences (arrows) ── } elseif ("\033" === $c) { $seq = fread(STDIN, 2); if (isset($seq[1]) && !empty($matches)) { if ('A' === $seq[1]) { $ofs = ($ofs - 1 + count($matches)) % count($matches); } elseif ('B' === $seq[1]) { $ofs = ($ofs + 1) % count($matches); } } // ── Tab: accept current match ── } elseif ("\t" === $c) { if (isset($matches[$ofs])) { self::replaceInput($output, $cursor, $input, $matches[$ofs]); $input = $matches[$ofs]; $matches = []; } $cursor->clearLineAfter(); continue; // ── Enter: confirm ── } elseif ("\n" === $c || "\r" === $c) { if (isset($matches[$ofs])) { self::replaceInput($output, $cursor, $input, $matches[$ofs]); $input = $matches[$ofs]; } if ($input === '') { $input = $default; } // Re-render user input with style $cursor->moveToColumn(1); $cursor->clearLine(); $output->write('> ' . $input . ''); $output->writeln(''); break; // ── Other control chars: ignore ── } elseif (ord($c) < 32) { continue; // ── Printable character ── } else { if ("\x80" <= $c) { $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3]; $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0); } $output->write($c); $input .= $c; $ofs = 0; } // Update match list $matches = self::filterTimezones($allTimezones, $input); // Show autocomplete hint $cursor->clearLineAfter(); if (!empty($matches)) { $hint = $matches[$ofs % count($matches)]; $cursor->savePosition(); $output->write(' ' . $hint . ''); if (count($matches) > 1) { $output->write(' (' . count($matches) . ' matches, ↑↓)'); } $cursor->restorePosition(); } } } finally { if (self::$sttyMode !== null) { shell_exec('stty ' . self::$sttyMode); self::$sttyMode = null; } } $result = '' === $input ? $default : $input; if (!in_array($result, $allTimezones, true)) { $output->writeln('' . $msg('timezone_invalid', $default) . ''); return $default; } return $result; } /** * Clear current input and replace with new text. */ private static function replaceInput(ConsoleOutput $output, Cursor $cursor, string $oldInput, string $newInput): void { if ('' !== $oldInput) { $cursor->moveLeft(mb_strwidth($oldInput)); } $cursor->clearLineAfter(); $output->write($newInput); } /** * Case-insensitive substring match for timezones. */ private static function filterTimezones(array $timezones, string $input): array { if ('' === $input) { return []; } $lower = mb_strtolower($input); return array_values(array_filter( $timezones, fn(string $tz) => str_contains(mb_strtolower($tz), $lower) )); } /** * Find an exact timezone match (case-insensitive). * Returns the correctly-cased system timezone name, or null if not found. */ private static function findExactTimezone(array $allTimezones, string $input): ?string { $lower = mb_strtolower($input); foreach ($allTimezones as $tz) { if (mb_strtolower($tz) === $lower) { return $tz; } } return null; } /** * Search timezones by keyword (substring) and similarity. * Returns combined results: substring matches first, then similarity matches (>=50%). * * @param string[] $allTimezones All valid timezone identifiers * @param string $keyword User input to search for * @param int $limit Maximum number of results * @return string[] Matched timezone identifiers */ private static function searchTimezones(array $allTimezones, string $keyword, int $limit = 15): array { // 1. Substring matches (higher priority) $substringMatches = self::filterTimezones($allTimezones, $keyword); if (count($substringMatches) >= $limit) { return array_slice($substringMatches, 0, $limit); } // 2. Similarity matches for remaining slots (normalized: strip _ and /) $substringSet = array_flip($substringMatches); $normalizedKeyword = str_replace(['_', '/'], ' ', mb_strtolower($keyword)); $similarityMatches = []; foreach ($allTimezones as $tz) { if (isset($substringSet[$tz])) { continue; } $parts = explode('/', $tz); $city = str_replace('_', ' ', mb_strtolower(end($parts))); $normalizedTz = str_replace(['_', '/'], ' ', mb_strtolower($tz)); similar_text($normalizedKeyword, $city, $cityPercent); similar_text($normalizedKeyword, $normalizedTz, $fullPercent); $bestPercent = max($cityPercent, $fullPercent); if ($bestPercent >= 50.0) { $similarityMatches[] = ['tz' => $tz, 'score' => $bestPercent]; } } usort($similarityMatches, fn(array $a, array $b) => $b['score'] <=> $a['score']); $results = $substringMatches; foreach ($similarityMatches as $item) { $results[] = $item['tz']; if (count($results) >= $limit) { break; } } return $results; } /** * Option B: when stty is not available (e.g. Windows), keyword search with numbered list. * Flow: enter timezone/keyword → exact match uses it directly; otherwise show * numbered results (substring + similarity) → pick by number or refine keyword. */ private static function askTimezoneSelect(IOInterface $io, callable $msg, string $default): string { $allTimezones = \DateTimeZone::listIdentifiers(); $io->write(''); $io->write('' . $msg('timezone_title', $default) . ''); $io->write($msg('timezone_input_prompt')); /** @var string[]|null Currently displayed search result list */ $currentList = null; while (true) { $io->write('> ', false); $line = fgets(STDIN); if ($line === false) { return $default; } $answer = trim($line); // Empty input → use default if ($answer === '') { $io->write('> ' . $default . ''); return $default; } // If a numbered list is displayed and input is a pure number if ($currentList !== null && ctype_digit($answer)) { $idx = (int) $answer; if (isset($currentList[$idx])) { $io->write('> ' . $currentList[$idx] . ''); return $currentList[$idx]; } $io->write('' . $msg('timezone_invalid_index') . ''); continue; } // Exact case-insensitive match → return the correctly-cased system value $exact = self::findExactTimezone($allTimezones, $answer); if ($exact !== null) { $io->write('> ' . $exact . ''); return $exact; } // Keyword + similarity search $results = self::searchTimezones($allTimezones, $answer); if (empty($results)) { $io->write('' . $msg('timezone_no_match') . ''); $currentList = null; continue; } // Single result → use it directly if (count($results) === 1) { $io->write('> ' . $results[0] . ''); return $results[0]; } // Display numbered list $currentList = $results; $padWidth = strlen((string) (count($results) - 1)); foreach ($results as $i => $tz) { $io->write(' [' . str_pad((string) $i, $padWidth) . '] ' . $tz); } $io->write($msg('timezone_pick_prompt')); } } // ═══════════════════════════════════════════════════════════════ // Optional component selection // ═══════════════════════════════════════════════════════════════ private static function askComponents(IOInterface $io, callable $msg): array { $packages = []; $addPackage = static function (string $package) use (&$packages, $io, $msg): void { if (in_array($package, $packages, true)) { return; } $packages[] = $package; $io->write($msg('adding_package', '' . $package . '')); }; // Console (default: yes) if (self::confirmMenu($io, $msg('console_question'), true)) { $addPackage(self::PACKAGE_CONSOLE); } // Database $dbItems = [ ['tag' => '0', 'label' => $msg('db_none')], ['tag' => '1', 'label' => 'webman/database'], ['tag' => '2', 'label' => 'webman/think-orm'], ['tag' => '3', 'label' => 'webman/database && webman/think-orm'], ]; $dbChoice = self::selectMenu($io, $msg('db_question'), $dbItems, 0); if ($dbChoice === 1) { $addPackage(self::PACKAGE_DATABASE); } elseif ($dbChoice === 2) { $addPackage(self::PACKAGE_THINK_ORM); } elseif ($dbChoice === 3) { $addPackage(self::PACKAGE_DATABASE); $addPackage(self::PACKAGE_THINK_ORM); } // If webman/database is selected, add required dependencies automatically if (in_array(self::PACKAGE_DATABASE, $packages, true)) { $addPackage(self::PACKAGE_ILLUMINATE_PAGINATION); $addPackage(self::PACKAGE_ILLUMINATE_EVENTS); $addPackage(self::PACKAGE_SYMFONY_VAR_DUMPER); } // Redis (default: no) if (self::confirmMenu($io, $msg('redis_question'), false)) { $addPackage(self::PACKAGE_REDIS); $addPackage(self::PACKAGE_ILLUMINATE_EVENTS); } // Validation (default: no) if (self::confirmMenu($io, $msg('validation_question'), false)) { $addPackage(self::PACKAGE_VALIDATION); } // Template engine $tplItems = [ ['tag' => '0', 'label' => $msg('template_none')], ['tag' => '1', 'label' => 'webman/blade'], ['tag' => '2', 'label' => 'twig/twig'], ['tag' => '3', 'label' => 'topthink/think-template'], ]; $tplChoice = self::selectMenu($io, $msg('template_question'), $tplItems, 0); if ($tplChoice === 1) { $addPackage(self::PACKAGE_BLADE); } elseif ($tplChoice === 2) { $addPackage(self::PACKAGE_TWIG); } elseif ($tplChoice === 3) { $addPackage(self::PACKAGE_THINK_TEMPLATE); } return $packages; } // ═══════════════════════════════════════════════════════════════ // Config file update // ═══════════════════════════════════════════════════════════════ /** * Update a config value like 'key' => 'old_value' in the given file. */ private static function updateConfig(Event $event, string $relativePath, string $key, string $newValue): void { $root = dirname($event->getComposer()->getConfig()->get('vendor-dir')); $file = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); if (!is_readable($file)) { return; } $content = file_get_contents($file); if ($content === false) { return; } $pattern = '/' . preg_quote($key, '/') . "\s*=>\s*'[^']*'/"; $replacement = $key . " => '" . $newValue . "'"; $newContent = preg_replace($pattern, $replacement, $content); if ($newContent !== null && $newContent !== $content) { file_put_contents($file, $newContent); } } // ═══════════════════════════════════════════════════════════════ // Composer require // ═══════════════════════════════════════════════════════════════ private static function runComposerRequire(array $packages, IOInterface $io, callable $msg): void { $io->write('' . $msg('running') . ' composer require ' . implode(' ', $packages)); $io->write(''); $code = self::runComposerCommand('require', $packages); if ($code !== 0) { $io->writeError('' . $msg('error_install', implode(' ', $packages)) . ''); } else { $io->write('' . $msg('done') . ''); } } private static function askRemoveComponents(Event $event, array $selectedPackages, IOInterface $io, callable $msg): array { $requires = $event->getComposer()->getPackage()->getRequires(); $allOptionalPackages = [ self::PACKAGE_CONSOLE, self::PACKAGE_DATABASE, self::PACKAGE_THINK_ORM, self::PACKAGE_REDIS, self::PACKAGE_ILLUMINATE_EVENTS, self::PACKAGE_ILLUMINATE_PAGINATION, self::PACKAGE_SYMFONY_VAR_DUMPER, self::PACKAGE_VALIDATION, self::PACKAGE_BLADE, self::PACKAGE_TWIG, self::PACKAGE_THINK_TEMPLATE, ]; $secondaryPackages = [ self::PACKAGE_ILLUMINATE_EVENTS, self::PACKAGE_ILLUMINATE_PAGINATION, self::PACKAGE_SYMFONY_VAR_DUMPER, ]; $installedOptionalPackages = []; foreach ($allOptionalPackages as $pkg) { if (isset($requires[$pkg])) { $installedOptionalPackages[] = $pkg; } } $allPackagesToRemove = array_diff($installedOptionalPackages, $selectedPackages); if (count($allPackagesToRemove) === 0) { return []; } $displayPackagesToRemove = array_diff($allPackagesToRemove, $secondaryPackages); if (count($displayPackagesToRemove) === 0) { return $allPackagesToRemove; } $pkgListStr = ""; foreach ($displayPackagesToRemove as $pkg) { $pkgListStr .= "\n - {$pkg}"; } $pkgListStr .= "\n"; $title = '' . $msg('remove_package_question', '') . '' . $pkgListStr; if (self::confirmMenu($io, $title, false)) { return $allPackagesToRemove; } return []; } private static function runComposerRemove(array $packages, IOInterface $io, callable $msg): void { $io->write('' . $msg('running') . ' composer remove ' . implode(' ', $packages)); $io->write(''); $code = self::runComposerCommand('remove', $packages); if ($code !== 0) { $io->writeError('' . $msg('error_remove', implode(' ', $packages)) . ''); } else { $io->write('' . $msg('done_remove') . ''); } } /** * Run a Composer command (require/remove) in-process via Composer's Application API. * No shell execution functions needed — works even when passthru/exec/shell_exec are disabled. */ private static function runComposerCommand(string $command, array $packages): int { try { // Already inside a user-initiated Composer session — suppress duplicate root/superuser warnings $_SERVER['COMPOSER_ALLOW_SUPERUSER'] = '1'; if (function_exists('putenv')) { putenv('COMPOSER_ALLOW_SUPERUSER=1'); } $application = new ComposerApplication(); $application->setAutoExit(false); return $application->run( new ArrayInput([ 'command' => $command, 'packages' => $packages, '--no-interaction' => true, '--update-with-all-dependencies' => true, ]), new ConsoleOutput() ); } catch (\Throwable) { return 1; } } }