Setup.php 76 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558
  1. <?php
  2. declare(strict_types=1);
  3. namespace support;
  4. use Composer\Console\Application as ComposerApplication;
  5. use Composer\IO\IOInterface;
  6. use Composer\Script\Event;
  7. use Symfony\Component\Console\Cursor;
  8. use Symfony\Component\Console\Input\ArrayInput;
  9. use Symfony\Component\Console\Output\ConsoleOutput;
  10. use Symfony\Component\Console\Terminal;
  11. /**
  12. * create-project setup wizard: interactive locale, timezone and optional components selection, then runs composer require.
  13. */
  14. class Setup
  15. {
  16. // --- Optional component package names ---
  17. private const PACKAGE_CONSOLE = 'webman/console';
  18. private const PACKAGE_DATABASE = 'webman/database';
  19. private const PACKAGE_THINK_ORM = 'webman/think-orm';
  20. private const PACKAGE_REDIS = 'webman/redis';
  21. private const PACKAGE_ILLUMINATE_EVENTS = 'illuminate/events';
  22. private const PACKAGE_ILLUMINATE_PAGINATION = 'illuminate/pagination';
  23. private const PACKAGE_SYMFONY_VAR_DUMPER = 'symfony/var-dumper';
  24. private const PACKAGE_VALIDATION = 'webman/validation';
  25. private const PACKAGE_BLADE = 'webman/blade';
  26. private const PACKAGE_TWIG = 'twig/twig';
  27. private const PACKAGE_THINK_TEMPLATE = 'topthink/think-template';
  28. private const SETUP_TITLE = 'Webman Setup';
  29. // --- Timezone regions ---
  30. private const TIMEZONE_REGIONS = [
  31. 'Asia' => \DateTimeZone::ASIA,
  32. 'Europe' => \DateTimeZone::EUROPE,
  33. 'America' => \DateTimeZone::AMERICA,
  34. 'Africa' => \DateTimeZone::AFRICA,
  35. 'Australia' => \DateTimeZone::AUSTRALIA,
  36. 'Pacific' => \DateTimeZone::PACIFIC,
  37. 'Atlantic' => \DateTimeZone::ATLANTIC,
  38. 'Indian' => \DateTimeZone::INDIAN,
  39. 'Antarctica' => \DateTimeZone::ANTARCTICA,
  40. 'Arctic' => \DateTimeZone::ARCTIC,
  41. 'UTC' => \DateTimeZone::UTC,
  42. ];
  43. // --- Locale => default timezone ---
  44. private const LOCALE_DEFAULT_TIMEZONES = [
  45. 'zh_CN' => 'Asia/Shanghai',
  46. 'zh_TW' => 'Asia/Taipei',
  47. 'en' => 'UTC',
  48. 'ja' => 'Asia/Tokyo',
  49. 'ko' => 'Asia/Seoul',
  50. 'fr' => 'Europe/Paris',
  51. 'de' => 'Europe/Berlin',
  52. 'es' => 'Europe/Madrid',
  53. 'pt_BR' => 'America/Sao_Paulo',
  54. 'ru' => 'Europe/Moscow',
  55. 'vi' => 'Asia/Ho_Chi_Minh',
  56. 'tr' => 'Europe/Istanbul',
  57. 'id' => 'Asia/Jakarta',
  58. 'th' => 'Asia/Bangkok',
  59. ];
  60. // --- Locale options (localized display names) ---
  61. private const LOCALE_LABELS = [
  62. 'zh_CN' => '简体中文',
  63. 'zh_TW' => '繁體中文',
  64. 'en' => 'English',
  65. 'ja' => '日本語',
  66. 'ko' => '한국어',
  67. 'fr' => 'Français',
  68. 'de' => 'Deutsch',
  69. 'es' => 'Español',
  70. 'pt_BR' => 'Português (Brasil)',
  71. 'ru' => 'Русский',
  72. 'vi' => 'Tiếng Việt',
  73. 'tr' => 'Türkçe',
  74. 'id' => 'Bahasa Indonesia',
  75. 'th' => 'ไทย',
  76. ];
  77. // --- Multilingual messages (%s = placeholder) ---
  78. private const MESSAGES = [
  79. 'zh_CN' => [
  80. 'remove_package_question' => '发现以下已安装组件本次未选择,是否将其卸载 ?%s',
  81. 'removing_package' => '- 准备移除组件 %s',
  82. 'removing' => '卸载:',
  83. 'error_remove' => '卸载组件出错,请手动执行:composer remove %s',
  84. 'done_remove' => '已卸载组件。',
  85. 'skip' => '非交互模式,跳过安装向导。',
  86. 'default_choice' => ' (默认 %s)',
  87. 'timezone_prompt' => '时区 (默认 %s,输入可联想补全): ',
  88. 'timezone_title' => '时区设置 (默认 %s)',
  89. 'timezone_help' => '输入关键字Tab自动补全,可↑↓下选择:',
  90. 'timezone_region' => '选择时区区域',
  91. 'timezone_city' => '选择时区',
  92. 'timezone_invalid' => '无效的时区,已使用默认值 %s',
  93. 'timezone_input_prompt' => '输入时区或关键字:',
  94. 'timezone_pick_prompt' => '请输入数字编号或关键字:',
  95. 'timezone_no_match' => '未找到匹配的时区,请重试。',
  96. 'timezone_invalid_index' => '无效的编号,请重新输入。',
  97. 'yes' => '是',
  98. 'no' => '否',
  99. 'adding_package' => '- 添加依赖 %s',
  100. 'console_question' => '安装命令行组件 webman/console',
  101. 'db_question' => '数据库组件',
  102. 'db_none' => '不安装',
  103. 'db_invalid' => '请输入有效选项',
  104. 'redis_question' => '安装 Redis 组件 webman/redis',
  105. 'events_note' => ' (Redis 依赖 illuminate/events,已自动包含)',
  106. 'validation_question' => '安装验证器组件 webman/validation',
  107. 'template_question' => '模板引擎',
  108. 'template_none' => '不安装',
  109. 'no_components' => '未选择额外组件。',
  110. 'installing' => '即将安装:',
  111. 'running' => '执行:',
  112. 'error_install' => '安装可选组件时出错,请手动执行:composer require %s',
  113. 'done' => '可选组件安装完成。',
  114. 'summary_locale' => '语言:%s',
  115. 'summary_timezone' => '时区:%s',
  116. ],
  117. 'zh_TW' => [
  118. 'skip' => '非交互模式,跳過安裝嚮導。',
  119. 'default_choice' => ' (預設 %s)',
  120. 'timezone_prompt' => '時區 (預設 %s,輸入可聯想補全): ',
  121. 'timezone_title' => '時區設定 (預設 %s)',
  122. 'timezone_help' => '輸入關鍵字Tab自動補全,可↑↓上下選擇:',
  123. 'timezone_region' => '選擇時區區域',
  124. 'timezone_city' => '選擇時區',
  125. 'timezone_invalid' => '無效的時區,已使用預設值 %s',
  126. 'timezone_input_prompt' => '輸入時區或關鍵字:',
  127. 'timezone_pick_prompt' => '請輸入數字編號或關鍵字:',
  128. 'timezone_no_match' => '未找到匹配的時區,請重試。',
  129. 'timezone_invalid_index' => '無效的編號,請重新輸入。',
  130. 'yes' => '是',
  131. 'no' => '否',
  132. 'adding_package' => '- 新增依賴 %s',
  133. 'console_question' => '安裝命令列組件 webman/console',
  134. 'db_question' => '資料庫組件',
  135. 'db_none' => '不安裝',
  136. 'db_invalid' => '請輸入有效選項',
  137. 'redis_question' => '安裝 Redis 組件 webman/redis',
  138. 'events_note' => ' (Redis 依賴 illuminate/events,已自動包含)',
  139. 'validation_question' => '安裝驗證器組件 webman/validation',
  140. 'template_question' => '模板引擎',
  141. 'template_none' => '不安裝',
  142. 'no_components' => '未選擇額外組件。',
  143. 'installing' => '即將安裝:',
  144. 'running' => '執行:',
  145. 'error_install' => '安裝可選組件時出錯,請手動執行:composer require %s',
  146. 'done' => '可選組件安裝完成。',
  147. 'summary_locale' => '語言:%s',
  148. 'summary_timezone' => '時區:%s',
  149. ],
  150. 'en' => [
  151. 'skip' => 'Non-interactive mode, skipping setup wizard.',
  152. 'default_choice' => ' (default %s)',
  153. 'timezone_prompt' => 'Timezone (default=%s, type to autocomplete): ',
  154. 'timezone_title' => 'Timezone (default=%s)',
  155. 'timezone_help' => 'Type keyword then press Tab to autocomplete, use ↑↓ to choose:',
  156. 'timezone_region' => 'Select timezone region',
  157. 'timezone_city' => 'Select timezone',
  158. 'timezone_invalid' => 'Invalid timezone, using default %s',
  159. 'timezone_input_prompt' => 'Enter timezone or keyword:',
  160. 'timezone_pick_prompt' => 'Enter number or keyword:',
  161. 'timezone_no_match' => 'No matching timezone found, please try again.',
  162. 'timezone_invalid_index' => 'Invalid number, please try again.',
  163. 'yes' => 'yes',
  164. 'no' => 'no',
  165. 'adding_package' => '- Adding package %s',
  166. 'console_question' => 'Install console component webman/console',
  167. 'db_question' => 'Database component',
  168. 'db_none' => 'None',
  169. 'db_invalid' => 'Please enter a valid option',
  170. 'redis_question' => 'Install Redis component webman/redis',
  171. 'events_note' => ' (Redis requires illuminate/events, automatically included)',
  172. 'validation_question' => 'Install validator component webman/validation',
  173. 'template_question' => 'Template engine',
  174. 'template_none' => 'None',
  175. 'no_components' => 'No optional components selected.',
  176. 'installing' => 'Installing:',
  177. 'running' => 'Running:',
  178. 'error_install' => 'Failed to install. Try manually: composer require %s',
  179. 'done' => 'Optional components installed.',
  180. 'summary_locale' => 'Language: %s',
  181. 'summary_timezone' => 'Timezone: %s',
  182. ],
  183. 'ja' => [
  184. 'skip' => '非対話モードのため、セットアップウィザードをスキップします。',
  185. 'default_choice' => ' (デフォルト %s)',
  186. 'timezone_prompt' => 'タイムゾーン (デフォルト=%s、入力で補完): ',
  187. 'timezone_title' => 'タイムゾーン (デフォルト=%s)',
  188. 'timezone_help' => 'キーワード入力→Tabで補完、↑↓で選択:',
  189. 'timezone_region' => 'タイムゾーンの地域を選択',
  190. 'timezone_city' => 'タイムゾーンを選択',
  191. 'timezone_invalid' => '無効なタイムゾーンです。デフォルト %s を使用します',
  192. 'timezone_input_prompt' => 'タイムゾーンまたはキーワードを入力:',
  193. 'timezone_pick_prompt' => '番号またはキーワードを入力:',
  194. 'timezone_no_match' => '一致するタイムゾーンが見つかりません。再試行してください。',
  195. 'timezone_invalid_index' => '無効な番号です。もう一度入力してください。',
  196. 'yes' => 'はい',
  197. 'no' => 'いいえ',
  198. 'adding_package' => '- パッケージを追加 %s',
  199. 'console_question' => 'コンソールコンポーネント webman/console をインストール',
  200. 'db_question' => 'データベースコンポーネント',
  201. 'db_none' => 'インストールしない',
  202. 'db_invalid' => '有効なオプションを入力してください',
  203. 'redis_question' => 'Redis コンポーネント webman/redis をインストール',
  204. 'events_note' => ' (Redis は illuminate/events が必要です。自動的に含まれます)',
  205. 'validation_question' => 'バリデーションコンポーネント webman/validation をインストール',
  206. 'template_question' => 'テンプレートエンジン',
  207. 'template_none' => 'インストールしない',
  208. 'no_components' => 'オプションコンポーネントが選択されていません。',
  209. 'installing' => 'インストール中:',
  210. 'running' => '実行中:',
  211. 'error_install' => 'インストールに失敗しました。手動で実行してください:composer require %s',
  212. 'done' => 'オプションコンポーネントのインストールが完了しました。',
  213. 'summary_locale' => '言語:%s',
  214. 'summary_timezone' => 'タイムゾーン:%s',
  215. ],
  216. 'ko' => [
  217. 'skip' => '비대화형 모드입니다. 설치 마법사를 건너뜁니다.',
  218. 'default_choice' => ' (기본값 %s)',
  219. 'timezone_prompt' => '시간대 (기본값=%s, 입력하여 자동완성): ',
  220. 'timezone_title' => '시간대 (기본값=%s)',
  221. 'timezone_help' => '키워드 입력 후 Tab 자동완성, ↑↓로 선택:',
  222. 'timezone_region' => '시간대 지역 선택',
  223. 'timezone_city' => '시간대 선택',
  224. 'timezone_invalid' => '잘못된 시간대입니다. 기본값 %s 을(를) 사용합니다',
  225. 'timezone_input_prompt' => '시간대 또는 키워드 입력:',
  226. 'timezone_pick_prompt' => '번호 또는 키워드 입력:',
  227. 'timezone_no_match' => '일치하는 시간대를 찾을 수 없습니다. 다시 시도하세요.',
  228. 'timezone_invalid_index' => '잘못된 번호입니다. 다시 입력하세요.',
  229. 'yes' => '예',
  230. 'no' => '아니오',
  231. 'adding_package' => '- 패키지 추가 %s',
  232. 'console_question' => '콘솔 컴포넌트 webman/console 설치',
  233. 'db_question' => '데이터베이스 컴포넌트',
  234. 'db_none' => '설치 안 함',
  235. 'db_invalid' => '유효한 옵션을 입력하세요',
  236. 'redis_question' => 'Redis 컴포넌트 webman/redis 설치',
  237. 'events_note' => ' (Redis는 illuminate/events가 필요합니다. 자동으로 포함됩니다)',
  238. 'validation_question' => '검증 컴포넌트 webman/validation 설치',
  239. 'template_question' => '템플릿 엔진',
  240. 'template_none' => '설치 안 함',
  241. 'no_components' => '선택된 추가 컴포넌트가 없습니다.',
  242. 'installing' => '설치 예정:',
  243. 'running' => '실행 중:',
  244. 'error_install' => '설치에 실패했습니다. 수동으로 실행하세요: composer require %s',
  245. 'done' => '선택 컴포넌트 설치가 완료되었습니다.',
  246. 'summary_locale' => '언어: %s',
  247. 'summary_timezone' => '시간대: %s',
  248. ],
  249. 'fr' => [
  250. 'skip' => 'Mode non interactif, assistant d\'installation ignoré.',
  251. 'default_choice' => ' (défaut %s)',
  252. 'timezone_prompt' => 'Fuseau horaire (défaut=%s, tapez pour compléter) : ',
  253. 'timezone_title' => 'Fuseau horaire (défaut=%s)',
  254. 'timezone_help' => 'Tapez un mot-clé, Tab pour compléter, ↑↓ pour choisir :',
  255. 'timezone_region' => 'Sélectionnez la région du fuseau horaire',
  256. 'timezone_city' => 'Sélectionnez le fuseau horaire',
  257. 'timezone_invalid' => 'Fuseau horaire invalide, utilisation de %s par défaut',
  258. 'timezone_input_prompt' => 'Entrez un fuseau horaire ou un mot-clé :',
  259. 'timezone_pick_prompt' => 'Entrez un numéro ou un mot-clé :',
  260. 'timezone_no_match' => 'Aucun fuseau horaire correspondant, veuillez réessayer.',
  261. 'timezone_invalid_index' => 'Numéro invalide, veuillez réessayer.',
  262. 'yes' => 'oui',
  263. 'no' => 'non',
  264. 'adding_package' => '- Ajout du paquet %s',
  265. 'console_question' => 'Installer le composant console webman/console',
  266. 'db_question' => 'Composant base de données',
  267. 'db_none' => 'Aucun',
  268. 'db_invalid' => 'Veuillez entrer une option valide',
  269. 'redis_question' => 'Installer le composant Redis webman/redis',
  270. 'events_note' => ' (Redis nécessite illuminate/events, inclus automatiquement)',
  271. 'validation_question' => 'Installer le composant de validation webman/validation',
  272. 'template_question' => 'Moteur de templates',
  273. 'template_none' => 'Aucun',
  274. 'no_components' => 'Aucun composant optionnel sélectionné.',
  275. 'installing' => 'Installation en cours :',
  276. 'running' => 'Exécution :',
  277. 'error_install' => 'Échec de l\'installation. Essayez manuellement : composer require %s',
  278. 'done' => 'Composants optionnels installés.',
  279. 'summary_locale' => 'Langue : %s',
  280. 'summary_timezone' => 'Fuseau horaire : %s',
  281. ],
  282. 'de' => [
  283. 'skip' => 'Nicht-interaktiver Modus, Einrichtungsassistent übersprungen.',
  284. 'default_choice' => ' (Standard %s)',
  285. 'timezone_prompt' => 'Zeitzone (Standard=%s, Eingabe zur Vervollständigung): ',
  286. 'timezone_title' => 'Zeitzone (Standard=%s)',
  287. 'timezone_help' => 'Stichwort tippen, Tab ergänzt, ↑↓ auswählen:',
  288. 'timezone_region' => 'Zeitzone Region auswählen',
  289. 'timezone_city' => 'Zeitzone auswählen',
  290. 'timezone_invalid' => 'Ungültige Zeitzone, Standardwert %s wird verwendet',
  291. 'timezone_input_prompt' => 'Zeitzone oder Stichwort eingeben:',
  292. 'timezone_pick_prompt' => 'Nummer oder Stichwort eingeben:',
  293. 'timezone_no_match' => 'Keine passende Zeitzone gefunden, bitte erneut versuchen.',
  294. 'timezone_invalid_index' => 'Ungültige Nummer, bitte erneut eingeben.',
  295. 'yes' => 'ja',
  296. 'no' => 'nein',
  297. 'adding_package' => '- Paket hinzufügen %s',
  298. 'console_question' => 'Konsolen-Komponente webman/console installieren',
  299. 'db_question' => 'Datenbank-Komponente',
  300. 'db_none' => 'Keine',
  301. 'db_invalid' => 'Bitte geben Sie eine gültige Option ein',
  302. 'redis_question' => 'Redis-Komponente webman/redis installieren',
  303. 'events_note' => ' (Redis benötigt illuminate/events, automatisch eingeschlossen)',
  304. 'validation_question' => 'Validierungs-Komponente webman/validation installieren',
  305. 'template_question' => 'Template-Engine',
  306. 'template_none' => 'Keine',
  307. 'no_components' => 'Keine optionalen Komponenten ausgewählt.',
  308. 'installing' => 'Installation:',
  309. 'running' => 'Ausführung:',
  310. 'error_install' => 'Installation fehlgeschlagen. Manuell ausführen: composer require %s',
  311. 'done' => 'Optionale Komponenten installiert.',
  312. 'summary_locale' => 'Sprache: %s',
  313. 'summary_timezone' => 'Zeitzone: %s',
  314. ],
  315. 'es' => [
  316. 'skip' => 'Modo no interactivo, asistente de instalación omitido.',
  317. 'default_choice' => ' (predeterminado %s)',
  318. 'timezone_prompt' => 'Zona horaria (predeterminado=%s, escriba para autocompletar): ',
  319. 'timezone_title' => 'Zona horaria (predeterminado=%s)',
  320. 'timezone_help' => 'Escriba una palabra clave, Tab autocompleta, use ↑↓ para elegir:',
  321. 'timezone_region' => 'Seleccione la región de zona horaria',
  322. 'timezone_city' => 'Seleccione la zona horaria',
  323. 'timezone_invalid' => 'Zona horaria inválida, usando valor predeterminado %s',
  324. 'timezone_input_prompt' => 'Ingrese zona horaria o palabra clave:',
  325. 'timezone_pick_prompt' => 'Ingrese número o palabra clave:',
  326. 'timezone_no_match' => 'No se encontró zona horaria coincidente, intente de nuevo.',
  327. 'timezone_invalid_index' => 'Número inválido, intente de nuevo.',
  328. 'yes' => 'sí',
  329. 'no' => 'no',
  330. 'adding_package' => '- Agregando paquete %s',
  331. 'console_question' => 'Instalar componente de consola webman/console',
  332. 'db_question' => 'Componente de base de datos',
  333. 'db_none' => 'Ninguno',
  334. 'db_invalid' => 'Por favor ingrese una opción válida',
  335. 'redis_question' => 'Instalar componente Redis webman/redis',
  336. 'events_note' => ' (Redis requiere illuminate/events, incluido automáticamente)',
  337. 'validation_question' => 'Instalar componente de validación webman/validation',
  338. 'template_question' => 'Motor de plantillas',
  339. 'template_none' => 'Ninguno',
  340. 'no_components' => 'No se seleccionaron componentes opcionales.',
  341. 'installing' => 'Instalando:',
  342. 'running' => 'Ejecutando:',
  343. 'error_install' => 'Error en la instalación. Intente manualmente: composer require %s',
  344. 'done' => 'Componentes opcionales instalados.',
  345. 'summary_locale' => 'Idioma: %s',
  346. 'summary_timezone' => 'Zona horaria: %s',
  347. ],
  348. 'pt_BR' => [
  349. 'skip' => 'Modo não interativo, assistente de instalação ignorado.',
  350. 'default_choice' => ' (padrão %s)',
  351. 'timezone_prompt' => 'Fuso horário (padrão=%s, digite para autocompletar): ',
  352. 'timezone_title' => 'Fuso horário (padrão=%s)',
  353. 'timezone_help' => 'Digite uma palavra-chave, Tab autocompleta, use ↑↓ para escolher:',
  354. 'timezone_region' => 'Selecione a região do fuso horário',
  355. 'timezone_city' => 'Selecione o fuso horário',
  356. 'timezone_invalid' => 'Fuso horário inválido, usando padrão %s',
  357. 'timezone_input_prompt' => 'Digite fuso horário ou palavra-chave:',
  358. 'timezone_pick_prompt' => 'Digite número ou palavra-chave:',
  359. 'timezone_no_match' => 'Nenhum fuso horário encontrado, tente novamente.',
  360. 'timezone_invalid_index' => 'Número inválido, tente novamente.',
  361. 'yes' => 'sim',
  362. 'no' => 'não',
  363. 'adding_package' => '- Adicionando pacote %s',
  364. 'console_question' => 'Instalar componente de console webman/console',
  365. 'db_question' => 'Componente de banco de dados',
  366. 'db_none' => 'Nenhum',
  367. 'db_invalid' => 'Por favor, digite uma opção válida',
  368. 'redis_question' => 'Instalar componente Redis webman/redis',
  369. 'events_note' => ' (Redis requer illuminate/events, incluído automaticamente)',
  370. 'validation_question' => 'Instalar componente de validação webman/validation',
  371. 'template_question' => 'Motor de templates',
  372. 'template_none' => 'Nenhum',
  373. 'no_components' => 'Nenhum componente opcional selecionado.',
  374. 'installing' => 'Instalando:',
  375. 'running' => 'Executando:',
  376. 'error_install' => 'Falha na instalação. Tente manualmente: composer require %s',
  377. 'done' => 'Componentes opcionais instalados.',
  378. 'summary_locale' => 'Idioma: %s',
  379. 'summary_timezone' => 'Fuso horário: %s',
  380. ],
  381. 'ru' => [
  382. 'skip' => 'Неинтерактивный режим, мастер установки пропущен.',
  383. 'default_choice' => ' (по умолчанию %s)',
  384. 'timezone_prompt' => 'Часовой пояс (по умолчанию=%s, введите для автодополнения): ',
  385. 'timezone_title' => 'Часовой пояс (по умолчанию=%s)',
  386. 'timezone_help' => 'Введите ключевое слово, Tab для автодополнения, ↑↓ для выбора:',
  387. 'timezone_region' => 'Выберите регион часового пояса',
  388. 'timezone_city' => 'Выберите часовой пояс',
  389. 'timezone_invalid' => 'Неверный часовой пояс, используется значение по умолчанию %s',
  390. 'timezone_input_prompt' => 'Введите часовой пояс или ключевое слово:',
  391. 'timezone_pick_prompt' => 'Введите номер или ключевое слово:',
  392. 'timezone_no_match' => 'Совпадающий часовой пояс не найден, попробуйте снова.',
  393. 'timezone_invalid_index' => 'Неверный номер, попробуйте снова.',
  394. 'yes' => 'да',
  395. 'no' => 'нет',
  396. 'adding_package' => '- Добавление пакета %s',
  397. 'console_question' => 'Установить консольный компонент webman/console',
  398. 'db_question' => 'Компонент базы данных',
  399. 'db_none' => 'Не устанавливать',
  400. 'db_invalid' => 'Пожалуйста, введите допустимый вариант',
  401. 'redis_question' => 'Установить компонент Redis webman/redis',
  402. 'events_note' => ' (Redis требует illuminate/events, автоматически включён)',
  403. 'validation_question' => 'Установить компонент валидации webman/validation',
  404. 'template_question' => 'Шаблонизатор',
  405. 'template_none' => 'Не устанавливать',
  406. 'no_components' => 'Дополнительные компоненты не выбраны.',
  407. 'installing' => 'Установка:',
  408. 'running' => 'Выполнение:',
  409. 'error_install' => 'Ошибка установки. Выполните вручную: composer require %s',
  410. 'done' => 'Дополнительные компоненты установлены.',
  411. 'summary_locale' => 'Язык: %s',
  412. 'summary_timezone' => 'Часовой пояс: %s',
  413. ],
  414. 'vi' => [
  415. 'skip' => 'Chế độ không tương tác, bỏ qua trình hướng dẫn cài đặt.',
  416. 'default_choice' => ' (mặc định %s)',
  417. 'timezone_prompt' => 'Múi giờ (mặc định=%s, nhập để tự động hoàn thành): ',
  418. 'timezone_title' => 'Múi giờ (mặc định=%s)',
  419. 'timezone_help' => 'Nhập từ khóa, Tab để tự hoàn thành, dùng ↑↓ để chọn:',
  420. 'timezone_region' => 'Chọn khu vực múi giờ',
  421. 'timezone_city' => 'Chọn múi giờ',
  422. 'timezone_invalid' => 'Múi giờ không hợp lệ, sử dụng mặc định %s',
  423. 'timezone_input_prompt' => 'Nhập múi giờ hoặc từ khóa:',
  424. 'timezone_pick_prompt' => 'Nhập số thứ tự hoặc từ khóa:',
  425. 'timezone_no_match' => 'Không tìm thấy múi giờ phù hợp, vui lòng thử lại.',
  426. 'timezone_invalid_index' => 'Số không hợp lệ, vui lòng thử lại.',
  427. 'yes' => 'có',
  428. 'no' => 'không',
  429. 'adding_package' => '- Thêm gói %s',
  430. 'console_question' => 'Cài đặt thành phần console webman/console',
  431. 'db_question' => 'Thành phần cơ sở dữ liệu',
  432. 'db_none' => 'Không cài đặt',
  433. 'db_invalid' => 'Vui lòng nhập tùy chọn hợp lệ',
  434. 'redis_question' => 'Cài đặt thành phần Redis webman/redis',
  435. 'events_note' => ' (Redis cần illuminate/events, đã tự động bao gồm)',
  436. 'validation_question' => 'Cài đặt thành phần xác thực webman/validation',
  437. 'template_question' => 'Công cụ mẫu',
  438. 'template_none' => 'Không cài đặt',
  439. 'no_components' => 'Không có thành phần tùy chọn nào được chọn.',
  440. 'installing' => 'Đang cài đặt:',
  441. 'running' => 'Đang thực thi:',
  442. 'error_install' => 'Cài đặt thất bại. Thử thủ công: composer require %s',
  443. 'done' => 'Các thành phần tùy chọn đã được cài đặt.',
  444. 'summary_locale' => 'Ngôn ngữ: %s',
  445. 'summary_timezone' => 'Múi giờ: %s',
  446. ],
  447. 'tr' => [
  448. 'skip' => 'Etkileşimsiz mod, kurulum sihirbazı atlanıyor.',
  449. 'default_choice' => ' (varsayılan %s)',
  450. 'timezone_prompt' => 'Saat dilimi (varsayılan=%s, otomatik tamamlama için yazın): ',
  451. 'timezone_title' => 'Saat dilimi (varsayılan=%s)',
  452. 'timezone_help' => 'Anahtar kelime yazın, Tab tamamlar, ↑↓ ile seçin:',
  453. 'timezone_region' => 'Saat dilimi bölgesini seçin',
  454. 'timezone_city' => 'Saat dilimini seçin',
  455. 'timezone_invalid' => 'Geçersiz saat dilimi, varsayılan %s kullanılıyor',
  456. 'timezone_input_prompt' => 'Saat dilimi veya anahtar kelime girin:',
  457. 'timezone_pick_prompt' => 'Numara veya anahtar kelime girin:',
  458. 'timezone_no_match' => 'Eşleşen saat dilimi bulunamadı, tekrar deneyin.',
  459. 'timezone_invalid_index' => 'Geçersiz numara, tekrar deneyin.',
  460. 'yes' => 'evet',
  461. 'no' => 'hayır',
  462. 'adding_package' => '- Paket ekleniyor %s',
  463. 'console_question' => 'Konsol bileşeni webman/console yüklensin mi',
  464. 'db_question' => 'Veritabanı bileşeni',
  465. 'db_none' => 'Yok',
  466. 'db_invalid' => 'Lütfen geçerli bir seçenek girin',
  467. 'redis_question' => 'Redis bileşeni webman/redis yüklensin mi',
  468. 'events_note' => ' (Redis, illuminate/events gerektirir, otomatik olarak dahil edildi)',
  469. 'validation_question' => 'Doğrulama bileşeni webman/validation yüklensin mi',
  470. 'template_question' => 'Şablon motoru',
  471. 'template_none' => 'Yok',
  472. 'no_components' => 'İsteğe bağlı bileşen seçilmedi.',
  473. 'installing' => 'Yükleniyor:',
  474. 'running' => 'Çalıştırılıyor:',
  475. 'error_install' => 'Yükleme başarısız. Manuel olarak deneyin: composer require %s',
  476. 'done' => 'İsteğe bağlı bileşenler yüklendi.',
  477. 'summary_locale' => 'Dil: %s',
  478. 'summary_timezone' => 'Saat dilimi: %s',
  479. ],
  480. 'id' => [
  481. 'skip' => 'Mode non-interaktif, melewati wizard instalasi.',
  482. 'default_choice' => ' (default %s)',
  483. 'timezone_prompt' => 'Zona waktu (default=%s, ketik untuk melengkapi): ',
  484. 'timezone_title' => 'Zona waktu (default=%s)',
  485. 'timezone_help' => 'Ketik kata kunci, Tab untuk melengkapi, gunakan ↑↓ untuk memilih:',
  486. 'timezone_region' => 'Pilih wilayah zona waktu',
  487. 'timezone_city' => 'Pilih zona waktu',
  488. 'timezone_invalid' => 'Zona waktu tidak valid, menggunakan default %s',
  489. 'timezone_input_prompt' => 'Masukkan zona waktu atau kata kunci:',
  490. 'timezone_pick_prompt' => 'Masukkan nomor atau kata kunci:',
  491. 'timezone_no_match' => 'Zona waktu tidak ditemukan, silakan coba lagi.',
  492. 'timezone_invalid_index' => 'Nomor tidak valid, silakan coba lagi.',
  493. 'yes' => 'ya',
  494. 'no' => 'tidak',
  495. 'adding_package' => '- Menambahkan paket %s',
  496. 'console_question' => 'Instal komponen konsol webman/console',
  497. 'db_question' => 'Komponen database',
  498. 'db_none' => 'Tidak ada',
  499. 'db_invalid' => 'Silakan masukkan opsi yang valid',
  500. 'redis_question' => 'Instal komponen Redis webman/redis',
  501. 'events_note' => ' (Redis memerlukan illuminate/events, otomatis disertakan)',
  502. 'validation_question' => 'Instal komponen validasi webman/validation',
  503. 'template_question' => 'Mesin template',
  504. 'template_none' => 'Tidak ada',
  505. 'no_components' => 'Tidak ada komponen opsional yang dipilih.',
  506. 'installing' => 'Menginstal:',
  507. 'running' => 'Menjalankan:',
  508. 'error_install' => 'Instalasi gagal. Coba manual: composer require %s',
  509. 'done' => 'Komponen opsional terinstal.',
  510. 'summary_locale' => 'Bahasa: %s',
  511. 'summary_timezone' => 'Zona waktu: %s',
  512. ],
  513. 'th' => [
  514. 'skip' => 'โหมดไม่โต้ตอบ ข้ามตัวช่วยติดตั้ง',
  515. 'default_choice' => ' (ค่าเริ่มต้น %s)',
  516. 'timezone_prompt' => 'เขตเวลา (ค่าเริ่มต้น=%s พิมพ์เพื่อเติมอัตโนมัติ): ',
  517. 'timezone_title' => 'เขตเวลา (ค่าเริ่มต้น=%s)',
  518. 'timezone_help' => 'พิมพ์คีย์เวิร์ดแล้วกด Tab เพื่อเติมอัตโนมัติ ใช้ ↑↓ เพื่อเลือก:',
  519. 'timezone_region' => 'เลือกภูมิภาคเขตเวลา',
  520. 'timezone_city' => 'เลือกเขตเวลา',
  521. 'timezone_invalid' => 'เขตเวลาไม่ถูกต้อง ใช้ค่าเริ่มต้น %s',
  522. 'timezone_input_prompt' => 'ป้อนเขตเวลาหรือคำค้น:',
  523. 'timezone_pick_prompt' => 'ป้อนหมายเลขหรือคำค้น:',
  524. 'timezone_no_match' => 'ไม่พบเขตเวลาที่ตรงกัน กรุณาลองอีกครั้ง',
  525. 'timezone_invalid_index' => 'หมายเลขไม่ถูกต้อง กรุณาลองอีกครั้ง',
  526. 'yes' => 'ใช่',
  527. 'no' => 'ไม่',
  528. 'adding_package' => '- เพิ่มแพ็กเกจ %s',
  529. 'console_question' => 'ติดตั้งคอมโพเนนต์คอนโซล webman/console',
  530. 'db_question' => 'คอมโพเนนต์ฐานข้อมูล',
  531. 'db_none' => 'ไม่ติดตั้ง',
  532. 'db_invalid' => 'กรุณาป้อนตัวเลือกที่ถูกต้อง',
  533. 'redis_question' => 'ติดตั้งคอมโพเนนต์ Redis webman/redis',
  534. 'events_note' => ' (Redis ต้องการ illuminate/events รวมไว้โดยอัตโนมัติ)',
  535. 'validation_question' => 'ติดตั้งคอมโพเนนต์ตรวจสอบ webman/validation',
  536. 'template_question' => 'เทมเพลตเอนจิน',
  537. 'template_none' => 'ไม่ติดตั้ง',
  538. 'no_components' => 'ไม่ได้เลือกคอมโพเนนต์เสริม',
  539. 'installing' => 'กำลังติดตั้ง:',
  540. 'running' => 'กำลังดำเนินการ:',
  541. 'error_install' => 'ติดตั้งล้มเหลว ลองด้วยตนเอง: composer require %s',
  542. 'done' => 'คอมโพเนนต์เสริมติดตั้งเรียบร้อยแล้ว',
  543. 'summary_locale' => 'ภาษา: %s',
  544. 'summary_timezone' => 'เขตเวลา: %s',
  545. ],
  546. ];
  547. // --- Interrupt message (Ctrl+C) ---
  548. private const INTERRUPTED_MESSAGES = [
  549. 'zh_CN' => '安装中断,可运行 composer setup-webman 可重新设置。',
  550. 'zh_TW' => '安裝中斷,可運行 composer setup-webman 重新設置。',
  551. 'en' => 'Setup interrupted. Run "composer setup-webman" to restart setup.',
  552. 'ja' => 'セットアップが中断されました。composer setup-webman を実行して再設定できます。',
  553. 'ko' => '설치가 중단되었습니다. composer setup-webman 을 실행하여 다시 설정할 수 있습니다.',
  554. 'fr' => 'Installation interrompue. Exécutez « composer setup-webman » pour recommencer.',
  555. 'de' => 'Einrichtung abgebrochen. Führen Sie "composer setup-webman" aus, um neu zu starten.',
  556. 'es' => 'Instalación interrumpida. Ejecute "composer setup-webman" para reiniciar.',
  557. 'pt_BR' => 'Instalação interrompida. Execute "composer setup-webman" para reiniciar.',
  558. 'ru' => 'Установка прервана. Выполните «composer setup-webman» для повторной настройки.',
  559. 'vi' => 'Cài đặt bị gián đoạn. Chạy "composer setup-webman" để cài đặt lại.',
  560. 'tr' => 'Kurulum kesildi. Yeniden kurmak için "composer setup-webman" komutunu çalıştırın.',
  561. 'id' => 'Instalasi terganggu. Jalankan "composer setup-webman" untuk mengatur ulang.',
  562. 'th' => 'การติดตั้งถูกขัดจังหวะ เรียกใช้ "composer setup-webman" เพื่อตั้งค่าใหม่',
  563. ];
  564. // --- Signal handling state ---
  565. /** @var string|null Saved stty mode for terminal restoration on interrupt */
  566. private static ?string $sttyMode = null;
  567. /** @var string Current locale for interrupt message */
  568. private static string $interruptLocale = 'en';
  569. // ═══════════════════════════════════════════════════════════════
  570. // Entry
  571. // ═══════════════════════════════════════════════════════════════
  572. public static function run(Event $event): void
  573. {
  574. $io = $event->getIO();
  575. // Non-interactive mode: use English for skip message
  576. if (!$io->isInteractive()) {
  577. $io->write('<comment>' . self::MESSAGES['en']['skip'] . '</comment>');
  578. return;
  579. }
  580. try {
  581. self::doRun($event, $io);
  582. } catch (\Throwable $e) {
  583. $io->writeError('');
  584. $io->writeError('<error>Setup wizard error: ' . $e->getMessage() . '</error>');
  585. $io->writeError('<comment>Run "composer setup-webman" to retry.</comment>');
  586. }
  587. }
  588. private static function doRun(Event $event, IOInterface $io): void
  589. {
  590. $io->write('');
  591. // Register Ctrl+C handler
  592. self::registerInterruptHandler();
  593. // Banner title (must be before locale selection)
  594. self::renderTitle();
  595. // 1. Locale selection
  596. $locale = self::askLocale($io);
  597. self::$interruptLocale = $locale;
  598. $defaultTimezone = self::LOCALE_DEFAULT_TIMEZONES[$locale] ?? 'UTC';
  599. $msg = fn(string $key, string ...$args): string =>
  600. empty($args) ? self::MESSAGES[$locale][$key] : sprintf(self::MESSAGES[$locale][$key], ...$args);
  601. // Write locale config (update when not default)
  602. if ($locale !== 'zh_CN') {
  603. self::updateConfig($event, 'config/translation.php', "'locale'", $locale);
  604. }
  605. $io->write('');
  606. $io->write('');
  607. // 2. Timezone selection (default by locale)
  608. $timezone = self::askTimezone($io, $msg, $defaultTimezone);
  609. if ($timezone !== 'Asia/Shanghai') {
  610. self::updateConfig($event, 'config/app.php', "'default_timezone'", $timezone);
  611. }
  612. // 3. Optional components
  613. $packages = self::askComponents($io, $msg);
  614. // 4. Remove unselected components
  615. $removePackages = self::askRemoveComponents($event, $packages, $io, $msg);
  616. // 5. Summary
  617. $io->write('');
  618. $io->write('─────────────────────────────────────');
  619. $io->write('<info>' . $msg('summary_locale', self::LOCALE_LABELS[$locale]) . '</info>');
  620. $io->write('<info>' . $msg('summary_timezone', $timezone) . '</info>');
  621. // Remove unselected packages first to avoid dependency conflicts
  622. if ($removePackages !== []) {
  623. $io->write('');
  624. $io->write('<info>' . $msg('removing') . '</info>');
  625. $secondaryPackages = [
  626. self::PACKAGE_ILLUMINATE_EVENTS,
  627. self::PACKAGE_ILLUMINATE_PAGINATION,
  628. self::PACKAGE_SYMFONY_VAR_DUMPER,
  629. ];
  630. $displayRemovePackages = array_diff($removePackages, $secondaryPackages);
  631. foreach ($displayRemovePackages as $pkg) {
  632. $io->write(' - ' . $pkg);
  633. }
  634. $io->write('');
  635. self::runComposerRemove($removePackages, $io, $msg);
  636. }
  637. // Then install selected packages
  638. if ($packages !== []) {
  639. $io->write('');
  640. $io->write('<info>' . $msg('installing') . '</info> ' . implode(', ', $packages));
  641. $io->write('');
  642. self::runComposerRequire($packages, $io, $msg);
  643. } elseif ($removePackages === []) {
  644. $io->write('<info>' . $msg('no_components') . '</info>');
  645. }
  646. }
  647. private static function renderTitle(): void
  648. {
  649. $output = new ConsoleOutput();
  650. $terminalWidth = (new Terminal())->getWidth();
  651. if ($terminalWidth <= 0) {
  652. $terminalWidth = 80;
  653. }
  654. $text = ' ' . self::SETUP_TITLE . ' ';
  655. $minBoxWidth = 44;
  656. $maxBoxWidth = min($terminalWidth, 96);
  657. $boxWidth = min($maxBoxWidth, max($minBoxWidth, mb_strwidth($text) + 10));
  658. $innerWidth = $boxWidth - 2;
  659. $textWidth = mb_strwidth($text);
  660. $pad = max(0, $innerWidth - $textWidth);
  661. $left = intdiv($pad, 2);
  662. $right = $pad - $left;
  663. $line2 = '│' . str_repeat(' ', $left) . $text . str_repeat(' ', $right) . '│';
  664. $line1 = '┌' . str_repeat('─', $innerWidth) . '┐';
  665. $line3 = '└' . str_repeat('─', $innerWidth) . '┘';
  666. $output->writeln('');
  667. $output->writeln('<fg=blue;options=bold>' . $line1 . '</>');
  668. $output->writeln('<fg=blue;options=bold>' . $line2 . '</>');
  669. $output->writeln('<fg=blue;options=bold>' . $line3 . '</>');
  670. $output->writeln('');
  671. }
  672. // ═══════════════════════════════════════════════════════════════
  673. // Signal handling (Ctrl+C)
  674. // ═══════════════════════════════════════════════════════════════
  675. /**
  676. * Register Ctrl+C (SIGINT) handler to show a friendly message on interrupt.
  677. * Gracefully skipped when the required extensions are unavailable.
  678. */
  679. private static function registerInterruptHandler(): void
  680. {
  681. // Unix/Linux/Mac: pcntl extension with async signals for immediate delivery
  682. /*if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) {
  683. pcntl_async_signals(true);
  684. pcntl_signal(\SIGINT, [self::class, 'handleInterrupt']);
  685. return;
  686. }*/
  687. // Windows: sapi ctrl handler (PHP >= 7.4)
  688. if (function_exists('sapi_windows_set_ctrl_handler')) {
  689. sapi_windows_set_ctrl_handler(static function (int $event) {
  690. if ($event === \PHP_WINDOWS_EVENT_CTRL_C) {
  691. self::handleInterrupt();
  692. }
  693. });
  694. }
  695. }
  696. /**
  697. * Handle Ctrl+C: restore terminal, show tip, then exit.
  698. */
  699. private static function handleInterrupt(): void
  700. {
  701. // Restore terminal if in raw mode
  702. if (self::$sttyMode !== null && function_exists('shell_exec')) {
  703. @shell_exec('stty ' . self::$sttyMode);
  704. self::$sttyMode = null;
  705. }
  706. $output = new ConsoleOutput();
  707. $output->writeln('');
  708. $output->writeln('<comment>' . (self::INTERRUPTED_MESSAGES[self::$interruptLocale] ?? self::INTERRUPTED_MESSAGES['en']) . '</comment>');
  709. exit(1);
  710. }
  711. // ═══════════════════════════════════════════════════════════════
  712. // Interactive Menu System
  713. // ═══════════════════════════════════════════════════════════════
  714. /**
  715. * Check if terminal supports interactive features (arrow keys, ANSI colors).
  716. */
  717. private static function supportsInteractive(): bool
  718. {
  719. return function_exists('shell_exec') && Terminal::hasSttyAvailable();
  720. }
  721. /**
  722. * Display a selection menu with arrow key navigation (if supported) or text input fallback.
  723. *
  724. * @param IOInterface $io Composer IO
  725. * @param string $title Menu title
  726. * @param array $items Indexed array of ['tag' => string, 'label' => string]
  727. * @param int $default Default selected index (0-based)
  728. * @return int Selected index
  729. */
  730. private static function selectMenu(IOInterface $io, string $title, array $items, int $default = 0): int
  731. {
  732. // Append localized "default" hint to avoid ambiguity
  733. // (Template should contain a single %s placeholder for the default tag.)
  734. $defaultHintTemplate = null;
  735. if (isset(self::MESSAGES[self::$interruptLocale]['default_choice'])) {
  736. $defaultHintTemplate = self::MESSAGES[self::$interruptLocale]['default_choice'];
  737. }
  738. $defaultTag = $items[$default]['tag'] ?? '';
  739. if ($defaultHintTemplate && $defaultTag !== '') {
  740. $title .= sprintf($defaultHintTemplate, $defaultTag);
  741. } elseif ($defaultTag !== '') {
  742. // Fallback for early menus (e.g. locale selection) before locale is chosen.
  743. $title .= sprintf(' (default %s)', $defaultTag);
  744. }
  745. if (self::supportsInteractive()) {
  746. return self::arrowKeySelect($title, $items, $default);
  747. }
  748. return self::fallbackSelect($io, $title, $items, $default);
  749. }
  750. /**
  751. * Display a yes/no confirmation as a selection menu.
  752. *
  753. * @param IOInterface $io Composer IO
  754. * @param string $title Menu title
  755. * @param bool $default Default value (true = yes)
  756. * @return bool User's choice
  757. */
  758. private static function confirmMenu(IOInterface $io, string $title, bool $default = true): bool
  759. {
  760. $locale = self::$interruptLocale;
  761. $yes = self::MESSAGES[$locale]['yes'] ?? self::MESSAGES['en']['yes'] ?? 'yes';
  762. $no = self::MESSAGES[$locale]['no'] ?? self::MESSAGES['en']['no'] ?? 'no';
  763. $items = $default
  764. ? [['tag' => 'Y', 'label' => $yes], ['tag' => 'n', 'label' => $no]]
  765. : [['tag' => 'y', 'label' => $yes], ['tag' => 'N', 'label' => $no]];
  766. $defaultIndex = $default ? 0 : 1;
  767. return self::selectMenu($io, $title, $items, $defaultIndex) === 0;
  768. }
  769. /**
  770. * Interactive select with arrow key navigation, manual input and ANSI reverse-video highlighting.
  771. * Input area and option list highlighting are bidirectionally linked.
  772. * Requires stty (Unix-like terminals).
  773. */
  774. private static function arrowKeySelect(string $title, array $items, int $default): int
  775. {
  776. $output = new ConsoleOutput();
  777. $count = count($items);
  778. $selected = $default;
  779. $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items));
  780. $defaultTag = $items[$default]['tag'];
  781. $input = $defaultTag;
  782. // Print title and initial options
  783. $output->writeln('');
  784. $output->writeln('<fg=blue;options=bold>' . $title . '</>');
  785. self::drawMenuItems($output, $items, $selected, $maxTagWidth);
  786. $output->write('> ' . $input);
  787. // Enter raw mode
  788. self::$sttyMode = shell_exec('stty -g');
  789. shell_exec('stty -icanon -echo');
  790. try {
  791. while (!feof(STDIN)) {
  792. $c = fread(STDIN, 1);
  793. if (false === $c || '' === $c) {
  794. break;
  795. }
  796. // ── Backspace ──
  797. if ("\177" === $c || "\010" === $c) {
  798. if ('' !== $input) {
  799. $input = mb_substr($input, 0, -1);
  800. }
  801. $selected = self::findItemByTag($items, $input);
  802. $output->write("\033[{$count}A");
  803. self::drawMenuItems($output, $items, $selected, $maxTagWidth);
  804. $output->write("\033[2K\r> " . $input);
  805. continue;
  806. }
  807. // ── Escape sequences (arrow keys) ──
  808. if ("\033" === $c) {
  809. $seq = fread(STDIN, 2);
  810. if (isset($seq[1])) {
  811. $changed = false;
  812. if ('A' === $seq[1]) { // Up
  813. $selected = ($selected <= 0 ? $count : $selected) - 1;
  814. $changed = true;
  815. } elseif ('B' === $seq[1]) { // Down
  816. $selected = ($selected + 1) % $count;
  817. $changed = true;
  818. }
  819. if ($changed) {
  820. // Sync input with selected item's tag
  821. $input = $items[$selected]['tag'];
  822. $output->write("\033[{$count}A");
  823. self::drawMenuItems($output, $items, $selected, $maxTagWidth);
  824. $output->write("\033[2K\r> " . $input);
  825. }
  826. }
  827. continue;
  828. }
  829. // ── Enter: confirm selection ──
  830. if ("\n" === $c || "\r" === $c) {
  831. if ($selected < 0) {
  832. $selected = $default;
  833. }
  834. $output->write("\033[2K\r> <info>" . $items[$selected]['tag'] . ' ' . $items[$selected]['label'] . '</info>');
  835. $output->writeln('');
  836. break;
  837. }
  838. // ── Ignore other control characters ──
  839. if (ord($c) < 32) {
  840. continue;
  841. }
  842. // ── Printable character (with UTF-8 multi-byte support) ──
  843. if ("\x80" <= $c) {
  844. $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3];
  845. $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0);
  846. }
  847. $input .= $c;
  848. $selected = self::findItemByTag($items, $input);
  849. $output->write("\033[{$count}A");
  850. self::drawMenuItems($output, $items, $selected, $maxTagWidth);
  851. $output->write("\033[2K\r> " . $input);
  852. }
  853. } finally {
  854. if (self::$sttyMode !== null) {
  855. shell_exec('stty ' . self::$sttyMode);
  856. self::$sttyMode = null;
  857. }
  858. }
  859. return $selected < 0 ? $default : $selected;
  860. }
  861. /**
  862. * Fallback select for terminals without stty support. Uses plain text input.
  863. */
  864. private static function fallbackSelect(IOInterface $io, string $title, array $items, int $default): int
  865. {
  866. $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items));
  867. $defaultTag = $items[$default]['tag'];
  868. $io->write('');
  869. $io->write('<fg=blue;options=bold>' . $title . '</>');
  870. foreach ($items as $item) {
  871. $tag = str_pad($item['tag'], $maxTagWidth);
  872. $io->write(" [$tag] " . $item['label']);
  873. }
  874. while (true) {
  875. $io->write('> ', false);
  876. $line = fgets(STDIN);
  877. if ($line === false) {
  878. return $default;
  879. }
  880. $answer = trim($line);
  881. if ($answer === '') {
  882. $io->write('> <info>' . $items[$default]['tag'] . ' ' . $items[$default]['label'] . '</info>');
  883. return $default;
  884. }
  885. // Match by tag (case-insensitive)
  886. foreach ($items as $i => $item) {
  887. if (strcasecmp($item['tag'], $answer) === 0) {
  888. $io->write('> <info>' . $items[$i]['tag'] . ' ' . $items[$i]['label'] . '</info>');
  889. return $i;
  890. }
  891. }
  892. }
  893. }
  894. /**
  895. * Render menu items with optional ANSI reverse-video highlighting for the selected item.
  896. * When $selected is -1, no item is highlighted.
  897. */
  898. private static function drawMenuItems(ConsoleOutput $output, array $items, int $selected, int $maxTagWidth): void
  899. {
  900. foreach ($items as $i => $item) {
  901. $tag = str_pad($item['tag'], $maxTagWidth);
  902. $line = " [$tag] " . $item['label'];
  903. if ($i === $selected) {
  904. $output->writeln("\033[2K\r\033[7m" . $line . "\033[0m");
  905. } else {
  906. $output->writeln("\033[2K\r" . $line);
  907. }
  908. }
  909. }
  910. /**
  911. * Find item index by tag (case-insensitive exact match).
  912. * Returns -1 if no match found or input is empty.
  913. */
  914. private static function findItemByTag(array $items, string $input): int
  915. {
  916. if ($input === '') {
  917. return -1;
  918. }
  919. foreach ($items as $i => $item) {
  920. if (strcasecmp($item['tag'], $input) === 0) {
  921. return $i;
  922. }
  923. }
  924. return -1;
  925. }
  926. // ═══════════════════════════════════════════════════════════════
  927. // Locale selection
  928. // ═══════════════════════════════════════════════════════════════
  929. private static function askLocale(IOInterface $io): string
  930. {
  931. $locales = array_keys(self::LOCALE_LABELS);
  932. $items = [];
  933. foreach ($locales as $i => $code) {
  934. $items[] = ['tag' => (string) $i, 'label' => self::LOCALE_LABELS[$code] . " ($code)"];
  935. }
  936. $selected = self::selectMenu(
  937. $io,
  938. '语言 / Language / 言語 / 언어',
  939. $items,
  940. 0
  941. );
  942. return $locales[$selected];
  943. }
  944. // ═══════════════════════════════════════════════════════════════
  945. // Timezone selection
  946. // ═══════════════════════════════════════════════════════════════
  947. private static function askTimezone(IOInterface $io, callable $msg, string $default): string
  948. {
  949. if (self::supportsInteractive()) {
  950. return self::askTimezoneAutocomplete($msg, $default);
  951. }
  952. return self::askTimezoneSelect($io, $msg, $default);
  953. }
  954. /**
  955. * Option A: when stty is available, custom character-by-character autocomplete
  956. * (case-insensitive, substring match). Interaction: type to filter, hint on right;
  957. * ↑↓ change candidate, Tab accept, Enter confirm; empty input = use default.
  958. */
  959. private static function askTimezoneAutocomplete(callable $msg, string $default): string
  960. {
  961. $allTimezones = \DateTimeZone::listIdentifiers();
  962. $output = new ConsoleOutput();
  963. $cursor = new Cursor($output);
  964. $output->writeln('');
  965. $output->writeln('<fg=blue;options=bold>' . $msg('timezone_title', $default) . '</>');
  966. $output->writeln($msg('timezone_help'));
  967. $output->write('> ');
  968. self::$sttyMode = shell_exec('stty -g');
  969. shell_exec('stty -icanon -echo');
  970. // Auto-fill default timezone in the input area; user can edit it directly.
  971. $input = $default;
  972. $output->write($input);
  973. $ofs = 0;
  974. $matches = self::filterTimezones($allTimezones, $input);
  975. if (!empty($matches)) {
  976. $hint = $matches[$ofs % count($matches)];
  977. // Avoid duplicating hint when input already fully matches the only candidate.
  978. if (!(count($matches) === 1 && $hint === $input)) {
  979. $cursor->clearLineAfter();
  980. $cursor->savePosition();
  981. $output->write(' <fg=blue;options=bold>' . $hint . '</>');
  982. if (count($matches) > 1) {
  983. $output->write(' <info>(' . count($matches) . ' matches, ↑↓)</info>');
  984. }
  985. $cursor->restorePosition();
  986. }
  987. }
  988. try {
  989. while (!feof(STDIN)) {
  990. $c = fread(STDIN, 1);
  991. if (false === $c || '' === $c) {
  992. break;
  993. }
  994. // ── Backspace ──
  995. if ("\177" === $c || "\010" === $c) {
  996. if ('' !== $input) {
  997. $lastChar = mb_substr($input, -1);
  998. $input = mb_substr($input, 0, -1);
  999. $cursor->moveLeft(max(1, mb_strwidth($lastChar)));
  1000. }
  1001. $ofs = 0;
  1002. // ── Escape sequences (arrows) ──
  1003. } elseif ("\033" === $c) {
  1004. $seq = fread(STDIN, 2);
  1005. if (isset($seq[1]) && !empty($matches)) {
  1006. if ('A' === $seq[1]) {
  1007. $ofs = ($ofs - 1 + count($matches)) % count($matches);
  1008. } elseif ('B' === $seq[1]) {
  1009. $ofs = ($ofs + 1) % count($matches);
  1010. }
  1011. }
  1012. // ── Tab: accept current match ──
  1013. } elseif ("\t" === $c) {
  1014. if (isset($matches[$ofs])) {
  1015. self::replaceInput($output, $cursor, $input, $matches[$ofs]);
  1016. $input = $matches[$ofs];
  1017. $matches = [];
  1018. }
  1019. $cursor->clearLineAfter();
  1020. continue;
  1021. // ── Enter: confirm ──
  1022. } elseif ("\n" === $c || "\r" === $c) {
  1023. if (isset($matches[$ofs])) {
  1024. self::replaceInput($output, $cursor, $input, $matches[$ofs]);
  1025. $input = $matches[$ofs];
  1026. }
  1027. if ($input === '') {
  1028. $input = $default;
  1029. }
  1030. // Re-render user input with <comment> style
  1031. $cursor->moveToColumn(1);
  1032. $cursor->clearLine();
  1033. $output->write('> <info>' . $input . '</info>');
  1034. $output->writeln('');
  1035. break;
  1036. // ── Other control chars: ignore ──
  1037. } elseif (ord($c) < 32) {
  1038. continue;
  1039. // ── Printable character ──
  1040. } else {
  1041. if ("\x80" <= $c) {
  1042. $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3];
  1043. $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0);
  1044. }
  1045. $output->write($c);
  1046. $input .= $c;
  1047. $ofs = 0;
  1048. }
  1049. // Update match list
  1050. $matches = self::filterTimezones($allTimezones, $input);
  1051. // Show autocomplete hint
  1052. $cursor->clearLineAfter();
  1053. if (!empty($matches)) {
  1054. $hint = $matches[$ofs % count($matches)];
  1055. $cursor->savePosition();
  1056. $output->write(' <fg=blue;options=bold>' . $hint . '</>');
  1057. if (count($matches) > 1) {
  1058. $output->write(' <info>(' . count($matches) . ' matches, ↑↓)</info>');
  1059. }
  1060. $cursor->restorePosition();
  1061. }
  1062. }
  1063. } finally {
  1064. if (self::$sttyMode !== null) {
  1065. shell_exec('stty ' . self::$sttyMode);
  1066. self::$sttyMode = null;
  1067. }
  1068. }
  1069. $result = '' === $input ? $default : $input;
  1070. if (!in_array($result, $allTimezones, true)) {
  1071. $output->writeln('<comment>' . $msg('timezone_invalid', $default) . '</comment>');
  1072. return $default;
  1073. }
  1074. return $result;
  1075. }
  1076. /**
  1077. * Clear current input and replace with new text.
  1078. */
  1079. private static function replaceInput(ConsoleOutput $output, Cursor $cursor, string $oldInput, string $newInput): void
  1080. {
  1081. if ('' !== $oldInput) {
  1082. $cursor->moveLeft(mb_strwidth($oldInput));
  1083. }
  1084. $cursor->clearLineAfter();
  1085. $output->write($newInput);
  1086. }
  1087. /**
  1088. * Case-insensitive substring match for timezones.
  1089. */
  1090. private static function filterTimezones(array $timezones, string $input): array
  1091. {
  1092. if ('' === $input) {
  1093. return [];
  1094. }
  1095. $lower = mb_strtolower($input);
  1096. return array_values(array_filter(
  1097. $timezones,
  1098. fn(string $tz) => str_contains(mb_strtolower($tz), $lower)
  1099. ));
  1100. }
  1101. /**
  1102. * Find an exact timezone match (case-insensitive).
  1103. * Returns the correctly-cased system timezone name, or null if not found.
  1104. */
  1105. private static function findExactTimezone(array $allTimezones, string $input): ?string
  1106. {
  1107. $lower = mb_strtolower($input);
  1108. foreach ($allTimezones as $tz) {
  1109. if (mb_strtolower($tz) === $lower) {
  1110. return $tz;
  1111. }
  1112. }
  1113. return null;
  1114. }
  1115. /**
  1116. * Search timezones by keyword (substring) and similarity.
  1117. * Returns combined results: substring matches first, then similarity matches (>=50%).
  1118. *
  1119. * @param string[] $allTimezones All valid timezone identifiers
  1120. * @param string $keyword User input to search for
  1121. * @param int $limit Maximum number of results
  1122. * @return string[] Matched timezone identifiers
  1123. */
  1124. private static function searchTimezones(array $allTimezones, string $keyword, int $limit = 15): array
  1125. {
  1126. // 1. Substring matches (higher priority)
  1127. $substringMatches = self::filterTimezones($allTimezones, $keyword);
  1128. if (count($substringMatches) >= $limit) {
  1129. return array_slice($substringMatches, 0, $limit);
  1130. }
  1131. // 2. Similarity matches for remaining slots (normalized: strip _ and /)
  1132. $substringSet = array_flip($substringMatches);
  1133. $normalizedKeyword = str_replace(['_', '/'], ' ', mb_strtolower($keyword));
  1134. $similarityMatches = [];
  1135. foreach ($allTimezones as $tz) {
  1136. if (isset($substringSet[$tz])) {
  1137. continue;
  1138. }
  1139. $parts = explode('/', $tz);
  1140. $city = str_replace('_', ' ', mb_strtolower(end($parts)));
  1141. $normalizedTz = str_replace(['_', '/'], ' ', mb_strtolower($tz));
  1142. similar_text($normalizedKeyword, $city, $cityPercent);
  1143. similar_text($normalizedKeyword, $normalizedTz, $fullPercent);
  1144. $bestPercent = max($cityPercent, $fullPercent);
  1145. if ($bestPercent >= 50.0) {
  1146. $similarityMatches[] = ['tz' => $tz, 'score' => $bestPercent];
  1147. }
  1148. }
  1149. usort($similarityMatches, fn(array $a, array $b) => $b['score'] <=> $a['score']);
  1150. $results = $substringMatches;
  1151. foreach ($similarityMatches as $item) {
  1152. $results[] = $item['tz'];
  1153. if (count($results) >= $limit) {
  1154. break;
  1155. }
  1156. }
  1157. return $results;
  1158. }
  1159. /**
  1160. * Option B: when stty is not available (e.g. Windows), keyword search with numbered list.
  1161. * Flow: enter timezone/keyword → exact match uses it directly; otherwise show
  1162. * numbered results (substring + similarity) → pick by number or refine keyword.
  1163. */
  1164. private static function askTimezoneSelect(IOInterface $io, callable $msg, string $default): string
  1165. {
  1166. $allTimezones = \DateTimeZone::listIdentifiers();
  1167. $io->write('');
  1168. $io->write('<fg=blue;options=bold>' . $msg('timezone_title', $default) . '</>');
  1169. $io->write($msg('timezone_input_prompt'));
  1170. /** @var string[]|null Currently displayed search result list */
  1171. $currentList = null;
  1172. while (true) {
  1173. $io->write('> ', false);
  1174. $line = fgets(STDIN);
  1175. if ($line === false) {
  1176. return $default;
  1177. }
  1178. $answer = trim($line);
  1179. // Empty input → use default
  1180. if ($answer === '') {
  1181. $io->write('> <info>' . $default . '</info>');
  1182. return $default;
  1183. }
  1184. // If a numbered list is displayed and input is a pure number
  1185. if ($currentList !== null && ctype_digit($answer)) {
  1186. $idx = (int) $answer;
  1187. if (isset($currentList[$idx])) {
  1188. $io->write('> <info>' . $currentList[$idx] . '</info>');
  1189. return $currentList[$idx];
  1190. }
  1191. $io->write('<comment>' . $msg('timezone_invalid_index') . '</comment>');
  1192. continue;
  1193. }
  1194. // Exact case-insensitive match → return the correctly-cased system value
  1195. $exact = self::findExactTimezone($allTimezones, $answer);
  1196. if ($exact !== null) {
  1197. $io->write('> <info>' . $exact . '</info>');
  1198. return $exact;
  1199. }
  1200. // Keyword + similarity search
  1201. $results = self::searchTimezones($allTimezones, $answer);
  1202. if (empty($results)) {
  1203. $io->write('<comment>' . $msg('timezone_no_match') . '</comment>');
  1204. $currentList = null;
  1205. continue;
  1206. }
  1207. // Single result → use it directly
  1208. if (count($results) === 1) {
  1209. $io->write('> <info>' . $results[0] . '</info>');
  1210. return $results[0];
  1211. }
  1212. // Display numbered list
  1213. $currentList = $results;
  1214. $padWidth = strlen((string) (count($results) - 1));
  1215. foreach ($results as $i => $tz) {
  1216. $io->write(' [' . str_pad((string) $i, $padWidth) . '] ' . $tz);
  1217. }
  1218. $io->write($msg('timezone_pick_prompt'));
  1219. }
  1220. }
  1221. // ═══════════════════════════════════════════════════════════════
  1222. // Optional component selection
  1223. // ═══════════════════════════════════════════════════════════════
  1224. private static function askComponents(IOInterface $io, callable $msg): array
  1225. {
  1226. $packages = [];
  1227. $addPackage = static function (string $package) use (&$packages, $io, $msg): void {
  1228. if (in_array($package, $packages, true)) {
  1229. return;
  1230. }
  1231. $packages[] = $package;
  1232. $io->write($msg('adding_package', '<info>' . $package . '</info>'));
  1233. };
  1234. // Console (default: yes)
  1235. if (self::confirmMenu($io, $msg('console_question'), true)) {
  1236. $addPackage(self::PACKAGE_CONSOLE);
  1237. }
  1238. // Database
  1239. $dbItems = [
  1240. ['tag' => '0', 'label' => $msg('db_none')],
  1241. ['tag' => '1', 'label' => 'webman/database'],
  1242. ['tag' => '2', 'label' => 'webman/think-orm'],
  1243. ['tag' => '3', 'label' => 'webman/database && webman/think-orm'],
  1244. ];
  1245. $dbChoice = self::selectMenu($io, $msg('db_question'), $dbItems, 0);
  1246. if ($dbChoice === 1) {
  1247. $addPackage(self::PACKAGE_DATABASE);
  1248. } elseif ($dbChoice === 2) {
  1249. $addPackage(self::PACKAGE_THINK_ORM);
  1250. } elseif ($dbChoice === 3) {
  1251. $addPackage(self::PACKAGE_DATABASE);
  1252. $addPackage(self::PACKAGE_THINK_ORM);
  1253. }
  1254. // If webman/database is selected, add required dependencies automatically
  1255. if (in_array(self::PACKAGE_DATABASE, $packages, true)) {
  1256. $addPackage(self::PACKAGE_ILLUMINATE_PAGINATION);
  1257. $addPackage(self::PACKAGE_ILLUMINATE_EVENTS);
  1258. $addPackage(self::PACKAGE_SYMFONY_VAR_DUMPER);
  1259. }
  1260. // Redis (default: no)
  1261. if (self::confirmMenu($io, $msg('redis_question'), false)) {
  1262. $addPackage(self::PACKAGE_REDIS);
  1263. $addPackage(self::PACKAGE_ILLUMINATE_EVENTS);
  1264. }
  1265. // Validation (default: no)
  1266. if (self::confirmMenu($io, $msg('validation_question'), false)) {
  1267. $addPackage(self::PACKAGE_VALIDATION);
  1268. }
  1269. // Template engine
  1270. $tplItems = [
  1271. ['tag' => '0', 'label' => $msg('template_none')],
  1272. ['tag' => '1', 'label' => 'webman/blade'],
  1273. ['tag' => '2', 'label' => 'twig/twig'],
  1274. ['tag' => '3', 'label' => 'topthink/think-template'],
  1275. ];
  1276. $tplChoice = self::selectMenu($io, $msg('template_question'), $tplItems, 0);
  1277. if ($tplChoice === 1) {
  1278. $addPackage(self::PACKAGE_BLADE);
  1279. } elseif ($tplChoice === 2) {
  1280. $addPackage(self::PACKAGE_TWIG);
  1281. } elseif ($tplChoice === 3) {
  1282. $addPackage(self::PACKAGE_THINK_TEMPLATE);
  1283. }
  1284. return $packages;
  1285. }
  1286. // ═══════════════════════════════════════════════════════════════
  1287. // Config file update
  1288. // ═══════════════════════════════════════════════════════════════
  1289. /**
  1290. * Update a config value like 'key' => 'old_value' in the given file.
  1291. */
  1292. private static function updateConfig(Event $event, string $relativePath, string $key, string $newValue): void
  1293. {
  1294. $root = dirname($event->getComposer()->getConfig()->get('vendor-dir'));
  1295. $file = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath);
  1296. if (!is_readable($file)) {
  1297. return;
  1298. }
  1299. $content = file_get_contents($file);
  1300. if ($content === false) {
  1301. return;
  1302. }
  1303. $pattern = '/' . preg_quote($key, '/') . "\s*=>\s*'[^']*'/";
  1304. $replacement = $key . " => '" . $newValue . "'";
  1305. $newContent = preg_replace($pattern, $replacement, $content);
  1306. if ($newContent !== null && $newContent !== $content) {
  1307. file_put_contents($file, $newContent);
  1308. }
  1309. }
  1310. // ═══════════════════════════════════════════════════════════════
  1311. // Composer require
  1312. // ═══════════════════════════════════════════════════════════════
  1313. private static function runComposerRequire(array $packages, IOInterface $io, callable $msg): void
  1314. {
  1315. $io->write('<comment>' . $msg('running') . '</comment> composer require ' . implode(' ', $packages));
  1316. $io->write('');
  1317. $code = self::runComposerCommand('require', $packages);
  1318. if ($code !== 0) {
  1319. $io->writeError('<error>' . $msg('error_install', implode(' ', $packages)) . '</error>');
  1320. } else {
  1321. $io->write('<info>' . $msg('done') . '</info>');
  1322. }
  1323. }
  1324. private static function askRemoveComponents(Event $event, array $selectedPackages, IOInterface $io, callable $msg): array
  1325. {
  1326. $requires = $event->getComposer()->getPackage()->getRequires();
  1327. $allOptionalPackages = [
  1328. self::PACKAGE_CONSOLE,
  1329. self::PACKAGE_DATABASE,
  1330. self::PACKAGE_THINK_ORM,
  1331. self::PACKAGE_REDIS,
  1332. self::PACKAGE_ILLUMINATE_EVENTS,
  1333. self::PACKAGE_ILLUMINATE_PAGINATION,
  1334. self::PACKAGE_SYMFONY_VAR_DUMPER,
  1335. self::PACKAGE_VALIDATION,
  1336. self::PACKAGE_BLADE,
  1337. self::PACKAGE_TWIG,
  1338. self::PACKAGE_THINK_TEMPLATE,
  1339. ];
  1340. $secondaryPackages = [
  1341. self::PACKAGE_ILLUMINATE_EVENTS,
  1342. self::PACKAGE_ILLUMINATE_PAGINATION,
  1343. self::PACKAGE_SYMFONY_VAR_DUMPER,
  1344. ];
  1345. $installedOptionalPackages = [];
  1346. foreach ($allOptionalPackages as $pkg) {
  1347. if (isset($requires[$pkg])) {
  1348. $installedOptionalPackages[] = $pkg;
  1349. }
  1350. }
  1351. $allPackagesToRemove = array_diff($installedOptionalPackages, $selectedPackages);
  1352. if (count($allPackagesToRemove) === 0) {
  1353. return [];
  1354. }
  1355. $displayPackagesToRemove = array_diff($allPackagesToRemove, $secondaryPackages);
  1356. if (count($displayPackagesToRemove) === 0) {
  1357. return $allPackagesToRemove;
  1358. }
  1359. $pkgListStr = "";
  1360. foreach ($displayPackagesToRemove as $pkg) {
  1361. $pkgListStr .= "\n - {$pkg}";
  1362. }
  1363. $pkgListStr .= "\n";
  1364. $title = '<comment>' . $msg('remove_package_question', '') . '</comment>' . $pkgListStr;
  1365. if (self::confirmMenu($io, $title, false)) {
  1366. return $allPackagesToRemove;
  1367. }
  1368. return [];
  1369. }
  1370. private static function runComposerRemove(array $packages, IOInterface $io, callable $msg): void
  1371. {
  1372. $io->write('<comment>' . $msg('running') . '</comment> composer remove ' . implode(' ', $packages));
  1373. $io->write('');
  1374. $code = self::runComposerCommand('remove', $packages);
  1375. if ($code !== 0) {
  1376. $io->writeError('<error>' . $msg('error_remove', implode(' ', $packages)) . '</error>');
  1377. } else {
  1378. $io->write('<info>' . $msg('done_remove') . '</info>');
  1379. }
  1380. }
  1381. /**
  1382. * Run a Composer command (require/remove) in-process via Composer's Application API.
  1383. * No shell execution functions needed — works even when passthru/exec/shell_exec are disabled.
  1384. */
  1385. private static function runComposerCommand(string $command, array $packages): int
  1386. {
  1387. try {
  1388. // Already inside a user-initiated Composer session — suppress duplicate root/superuser warnings
  1389. $_SERVER['COMPOSER_ALLOW_SUPERUSER'] = '1';
  1390. if (function_exists('putenv')) {
  1391. putenv('COMPOSER_ALLOW_SUPERUSER=1');
  1392. }
  1393. $application = new ComposerApplication();
  1394. $application->setAutoExit(false);
  1395. return $application->run(
  1396. new ArrayInput([
  1397. 'command' => $command,
  1398. 'packages' => $packages,
  1399. '--no-interaction' => true,
  1400. '--update-with-all-dependencies' => true,
  1401. ]),
  1402. new ConsoleOutput()
  1403. );
  1404. } catch (\Throwable) {
  1405. return 1;
  1406. }
  1407. }
  1408. }