1:    2:    3:    4:    5:    6:    7:    8:    9:   10:   11:   12:   13:   14:   15:   16:   17:   18:   19:   20:   21:   22:   23:   24:   25:   26:   27:   28:   29:   30:   31:   32:   33:   34:   35:   36:   37:   38:   39:   40:   41:   42:   43:   44:   45:   46:   47:   48:   49:   50:   51:   52:   53:   54:   55:   56:   57:   58:   59:   60:   61:   62:   63:   64:   65:   66:   67:   68:   69:   70:   71:   72:   73:   74:   75:   76:   77:   78:   79:   80:   81:   82:   83:   84:   85:   86:   87:   88:   89:   90:   91:   92:   93:   94:   95:   96:   97:   98:   99:  100:  101:  102:  103:  104:  105:  106:  107:  108:  109:  110:  111:  112:  113:  114:  115:  116:  117:  118:  119:  120:  121:  122:  123:  124:  125:  126:  127:  128:  129:  130:  131:  132:  133:  134:  135:  136:  137:  138:  139:  140:  141:  142:  143:  144:  145:  146:  147:  148:  149:  150:  151:  152:  153:  154:  155:  156:  157:  158:  159:  160:  161:  162:  163:  164:  165:  166:  167:  168:  169:  170:  171:  172:  173:  174:  175:  176:  177:  178:  179:  180:  181:  182:  183:  184:  185:  186:  187:  188:  189:  190:  191:  192:  193:  194:  195:  196:  197:  198:  199:  200:  201:  202:  203:  204:  205:  206:  207:  208:  209:  210:  211:  212:  213:  214:  215:  216:  217:  218:  219:  220:  221:  222:  223:  224:  225:  226:  227:  228:  229:  230:  231:  232:  233:  234:  235:  236:  237:  238:  239:  240:  241:  242:  243:  244:  245:  246:  247:  248:  249:  250:  251:  252:  253:  254:  255:  256:  257:  258:  259:  260:  261:  262:  263:  264:  265:  266:  267:  268:  269:  270:  271:  272:  273:  274:  275:  276:  277:  278:  279:  280:  281:  282:  283:  284:  285:  286:  287:  288:  289:  290:  291:  292:  293:  294:  295:  296:  297:  298:  299:  300:  301:  302:  303:  304:  305:  306:  307:  308:  309:  310:  311:  312:  313:  314:  315:  316:  317:  318:  319:  320:  321:  322:  323:  324:  325:  326:  327:  328:  329:  330:  331:  332:  333:  334:  335:  336:  337:  338:  339:  340:  341:  342:  343:  344:  345:  346:  347:  348:  349:  350:  351:  352:  353:  354:  355:  356:  357:  358:  359:  360:  361:  362:  363:  364:  365:  366:  367:  368:  369:  370:  371:  372:  373:  374:  375:  376:  377:  378:  379:  380:  381:  382:  383:  384:  385:  386:  387:  388:  389:  390:  391:  392:  393:  394:  395:  396:  397:  398:  399:  400:  401:  402:  403:  404:  405:  406:  407:  408:  409:  410:  411:  412:  413:  414:  415:  416:  417:  418:  419:  420:  421:  422:  423:  424:  425:  426:  427:  428:  429:  430:  431:  432:  433:  434:  435:  436:  437:  438:  439:  440:  441:  442:  443:  444:  445:  446:  447:  448:  449:  450:  451:  452:  453:  454:  455:  456:  457:  458:  459:  460:  461:  462:  463:  464:  465:  466:  467:  468:  469:  470:  471:  472:  473:  474:  475:  476:  477:  478:  479:  480:  481:  482:  483:  484:  485:  486:  487:  488:  489:  490:  491:  492:  493:  494:  495:  496:  497:  498:  499:  500:  501:  502:  503:  504:  505:  506:  507:  508:  509:  510:  511:  512:  513:  514:  515:  516:  517:  518:  519:  520:  521:  522:  523:  524:  525:  526:  527:  528:  529:  530:  531:  532:  533:  534:  535:  536:  537:  538:  539:  540:  541:  542:  543:  544:  545:  546:  547:  548:  549:  550:  551:  552:  553:  554:  555:  556:  557:  558:  559:  560:  561:  562:  563:  564:  565:  566:  567:  568:  569:  570:  571:  572:  573:  574:  575:  576:  577:  578:  579:  580:  581:  582:  583:  584:  585:  586:  587:  588:  589:  590:  591:  592:  593:  594:  595:  596:  597:  598:  599:  600:  601:  602:  603:  604:  605:  606:  607:  608:  609:  610:  611:  612:  613:  614:  615:  616:  617:  618:  619:  620:  621:  622:  623:  624:  625:  626:  627:  628:  629:  630:  631:  632:  633:  634:  635:  636:  637:  638:  639:  640:  641:  642:  643:  644:  645:  646:  647:  648:  649:  650:  651:  652:  653:  654:  655:  656:  657:  658:  659:  660:  661:  662:  663:  664:  665:  666:  667:  668:  669:  670:  671:  672:  673:  674:  675:  676:  677:  678:  679:  680:  681:  682:  683:  684:  685:  686:  687:  688:  689:  690:  691:  692:  693:  694:  695:  696:  697:  698:  699:  700:  701:  702:  703:  704:  705:  706:  707:  708:  709:  710:  711:  712:  713:  714:  715:  716:  717:  718:  719:  720:  721:  722:  723:  724:  725:  726:  727:  728:  729:  730:  731:  732:  733:  734:  735:  736:  737:  738:  739:  740:  741:  742:  743:  744:  745:  746:  747:  748:  749:  750:  751:  752:  753:  754:  755:  756:  757:  758:  759:  760:  761:  762:  763:  764:  765:  766:  767:  768:  769:  770:  771:  772:  773:  774:  775:  776:  777:  778:  779:  780:  781:  782:  783:  784:  785:  786:  787:  788:  789:  790:  791:  792:  793:  794:  795:  796:  797:  798:  799:  800:  801:  802:  803:  804:  805:  806:  807:  808:  809:  810:  811:  812:  813:  814:  815:  816:  817:  818:  819:  820:  821:  822:  823:  824:  825:  826:  827:  828:  829:  830:  831:  832:  833:  834:  835:  836:  837:  838:  839:  840:  841:  842:  843:  844:  845:  846:  847:  848:  849:  850:  851:  852:  853:  854:  855:  856:  857:  858:  859:  860:  861:  862:  863:  864:  865:  866:  867:  868:  869:  870:  871:  872:  873:  874:  875:  876:  877:  878:  879:  880:  881:  882:  883:  884:  885:  886:  887:  888:  889:  890:  891:  892:  893:  894:  895:  896:  897:  898:  899:  900:  901:  902:  903:  904:  905:  906:  907:  908:  909:  910:  911:  912:  913:  914:  915:  916:  917:  918:  919:  920:  921:  922:  923:  924:  925:  926:  927:  928:  929:  930:  931:  932:  933:  934:  935:  936:  937:  938:  939:  940:  941:  942:  943:  944:  945:  946:  947:  948:  949:  950:  951:  952:  953:  954:  955:  956:  957:  958:  959:  960:  961:  962:  963:  964:  965:  966:  967:  968:  969:  970:  971:  972:  973:  974:  975:  976:  977:  978:  979:  980:  981:  982:  983:  984:  985:  986:  987:  988:  989:  990:  991:  992:  993:  994:  995:  996:  997:  998:  999: 1000: 1001: 1002: 1003: 1004: 1005: 1006: 1007: 1008: 1009: 1010: 1011: 1012: 1013: 1014: 1015: 1016: 1017: 1018: 1019: 1020: 1021: 1022: 1023: 1024: 1025: 1026: 1027: 1028: 1029: 1030: 1031: 1032: 1033: 1034: 1035: 1036: 1037: 1038: 1039: 1040: 1041: 1042: 1043: 1044: 1045: 1046: 1047: 1048: 1049: 1050: 1051: 1052: 1053: 1054: 1055: 1056: 1057: 1058: 1059: 1060: 1061: 1062: 1063: 1064: 1065: 1066: 1067: 1068: 1069: 1070: 1071: 1072: 1073: 1074: 1075: 1076: 1077: 1078: 1079: 1080: 1081: 1082: 1083: 1084: 1085: 1086: 1087: 1088: 1089: 1090: 1091: 1092: 1093: 1094: 1095: 1096: 1097: 1098: 1099: 1100: 1101: 1102: 1103: 1104: 1105: 1106: 1107: 1108: 1109: 1110: 1111: 1112: 1113: 1114: 1115: 1116: 1117: 1118: 1119: 1120: 1121: 1122: 1123: 1124: 1125: 1126: 1127: 1128: 1129: 1130: 1131: 1132: 1133: 1134: 1135: 1136: 1137: 1138: 1139: 1140: 1141: 1142: 1143: 1144: 1145: 1146: 1147: 1148: 1149: 1150: 1151: 1152: 1153: 1154: 1155: 1156: 1157: 1158: 1159: 1160: 1161: 1162: 1163: 1164: 1165: 1166: 1167: 1168: 1169: 1170: 1171: 1172: 1173: 1174: 1175: 1176: 1177: 1178: 1179: 1180: 1181: 1182: 1183: 1184: 1185: 1186: 1187: 1188: 1189: 1190: 1191: 1192: 1193: 1194: 1195: 1196: 1197: 1198: 1199: 1200: 1201: 1202: 1203: 1204: 1205: 1206: 1207: 1208: 1209: 1210: 1211: 1212: 1213: 1214: 1215: 1216: 1217: 1218: 1219: 1220: 1221: 1222: 1223: 1224: 1225: 1226: 1227: 1228: 1229: 1230: 1231: 1232: 1233: 1234: 1235: 1236: 1237: 1238: 1239: 1240: 1241: 1242: 1243: 1244: 1245: 1246: 1247: 1248: 1249: 1250: 1251: 1252: 1253: 1254: 1255: 1256: 1257: 1258: 1259: 1260: 1261: 1262: 1263: 1264: 1265: 1266: 1267: 1268: 1269: 1270: 1271: 1272: 1273: 1274: 1275: 1276: 1277: 1278: 1279: 1280: 1281: 1282: 1283: 1284: 1285: 1286: 1287: 1288: 1289: 1290: 1291: 1292: 1293: 1294: 1295: 1296: 1297: 1298: 1299: 1300: 1301: 1302: 1303: 1304: 1305: 1306: 1307: 1308: 1309: 1310: 1311: 1312: 1313: 1314: 1315: 1316: 1317: 1318: 1319: 1320: 1321: 1322: 1323: 1324: 1325: 1326: 1327: 1328: 1329: 1330: 1331: 1332: 1333: 1334: 1335: 1336: 1337: 1338: 1339: 1340: 1341: 1342: 1343: 1344: 1345: 1346: 1347: 1348: 1349: 1350: 1351: 1352: 1353: 1354: 1355: 1356: 1357: 1358: 1359: 1360: 1361: 1362: 1363: 1364: 1365: 1366: 1367: 1368: 1369: 1370: 1371: 1372: 1373: 1374: 1375: 1376: 1377: 1378: 1379: 1380: 1381: 1382: 1383: 1384: 1385: 1386: 1387: 1388: 1389: 1390: 1391: 1392: 1393: 1394: 1395: 1396: 1397: 1398: 1399: 1400: 1401: 1402: 1403: 1404: 1405: 1406: 1407: 1408: 1409: 1410: 1411: 1412: 1413: 1414: 1415: 1416: 1417: 1418: 1419: 1420: 1421: 1422: 1423: 1424: 1425: 1426: 1427: 1428: 1429: 1430: 1431: 1432: 1433: 1434: 1435: 1436: 1437: 1438: 1439: 1440: 1441: 1442: 1443: 1444: 1445: 1446: 1447: 1448: 1449: 1450: 1451: 1452: 1453: 1454: 1455: 1456: 1457: 1458: 1459: 1460: 1461: 1462: 1463: 1464: 1465: 1466: 1467: 1468: 1469: 1470: 1471: 1472: 1473: 1474: 1475: 1476: 1477: 1478: 1479: 1480: 1481: 1482: 1483: 1484: 1485: 1486: 1487: 1488: 1489: 1490: 1491: 1492: 1493: 1494: 1495: 1496: 1497: 1498: 1499: 1500: 1501: 1502: 1503: 1504: 1505: 1506: 1507: 1508: 1509: 1510: 1511: 1512: 1513: 1514: 1515: 1516: 1517: 1518: 1519: 1520: 1521: 1522: 1523: 1524: 1525: 1526: 1527: 1528: 1529: 1530: 1531: 1532: 1533: 1534: 1535: 1536: 1537: 1538: 1539: 1540: 1541: 1542: 1543: 1544: 1545: 1546: 1547: 1548: 1549: 1550: 1551: 1552: 1553: 1554: 1555: 1556: 1557: 1558: 1559: 1560: 1561: 1562: 1563: 1564: 1565: 1566: 1567: 1568: 1569: 1570: 1571: 1572: 1573: 1574: 1575: 1576: 1577: 1578: 1579: 1580: 1581: 1582: 1583: 1584: 1585: 1586: 1587: 1588: 1589: 1590: 1591: 1592: 1593: 1594: 1595: 1596: 1597: 1598: 1599: 1600: 1601: 1602: 1603: 1604: 1605: 1606: 1607: 1608: 1609: 1610: 1611: 1612: 1613: 1614: 
<?php declare(strict_types=1);
    /**
     *  +------------------------------------------------------------+
     *  | apnscp                                                     |
     *  +------------------------------------------------------------+
     *  | Copyright (c) Apis Networks                                |
     *  +------------------------------------------------------------+
     *  | Licensed under Artistic License 2.0                        |
     *  +------------------------------------------------------------+
     *  | Author: Matt Saladna (msaladna@apisnetworks.com)           |
     *  +------------------------------------------------------------+
     */

    use Opcenter\Versioning;

    /**
     * WordPress management
     *
     * An interface to wp-cli
     *
     * @package core
     */
    class Wordpress_Module extends \Module\Support\Webapps
    {
        const APP_NAME = 'WordPress';
        const ASSET_SKIPLIST = '.wp-update-skip';

        // primary domain document root
        const WP_CLI = '/usr/share/pear/wp-cli.phar';

        // latest release
        const WP_CLI_URL = 'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar';

        const VERSION_CHECK_URL = 'https://api.wordpress.org/core/version-check/1.7/';
        const PLUGIN_VERSION_CHECK_URL = 'https://api.wordpress.org/plugins/info/1.0/%plugin%.json';
        const THEME_VERSION_CHECK_URL = 'https://api.wordpress.org/themes/info/1.2/?action=theme_information&request[slug]=%theme%&request[fields][versions]=1';
        const DEFAULT_VERSION_LOCK = 'none';

        protected $_aclList = array(
            'min' => array(
                'wp-content',
                '.htaccess',
                'wp-config.php'
            ),
            'max' => array(
                'wp-content/uploads',
                'wp-content/cache',
                'wp-content/wflogs',
                'wp-content/updraft'
            )
        );

        /**
         * @var array files detected by Wordpress when determining write-access
         */
        protected $controlFiles = [
            '/wp-admin/includes/file.php'
        ];

        /**
         * @var array list of plugin/theme types that cannot be updated manually
         */
        const NON_UPDATEABLE_TYPES = [
            'dropin',
            'must-use'
        ];

        /**
         * Install WordPress
         *
         * @param string $hostname domain or subdomain to install WordPress
         * @param string $path     optional path under hostname
         * @param array  $opts     additional install options
         * @return bool
         */
        public function install(string $hostname, string $path = '', array $opts = array()): bool
        {
            if (!$this->mysql_enabled()) {
                return error("MySQL must be enabled to install %s", ucwords($this->getInternalName()));
            }
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error("failed to detect document root for `%s'", $hostname);
            }

            if (!$this->parseInstallOptions($opts, $hostname, $path)) {
                return false;
            }

            $args = [
                'mode'    => 'download',
                'version' => $opts['version']
            ];

            $args['user'] = $opts['user'];

            $ret = $this->execCommand($docroot, 'core %(mode)s --version=%(version)s', $args);

            if (!$ret['success']) {
                return error("failed to download WP version `%s', error: %s",
                    $opts['version'],
                    coalesce($ret['stdout'], $ret['stderr'])
                );
            }

            $db = $this->_suggestDB($hostname);
            if (!$db) {
                return false;
            }

            $dbuser = $this->_suggestUser($db);
            if (!$dbuser) {
                return false;
            }
            $dbpass = $this->suggestPassword();
            $credentials = array(
                'db'       => $db,
                'user'     => $dbuser,
                'password' => $dbpass
            );

            if (!parent::setupDatabase($credentials)) {
                return false;
            }

            if (!$this->generateNewConfiguration($hostname, $docroot, $credentials)) {
                info('removing temporary files');
                $this->file_delete($docroot, true);
                $this->sql_delete_mysql_database($db);
                $this->sql_delete_mysql_user($dbuser, 'localhost');
                return false;
            }

            if (!isset($opts['title'])) {
                $opts['title'] = 'A Random Blog for a Random Reason';
            }

            if (!isset($opts['password'])) {
                $opts['password'] = $this->suggestPassword(16);
                info("autogenerated password `%s'", $opts['password']);
            }

            info("setting admin user to `%s'", $this->username);
            // fix situations when installed on global subdomain
            $fqdn = $this->web_normalize_hostname($hostname);
            $opts['url'] = rtrim($fqdn . '/' . $path, '/');
            $args = array(
                'email'    => $opts['email'],
                'mode'     => 'install',
                'url'      => $opts['url'],
                'title'    => $opts['title'],
                'user'     => $opts['user'],
                'password' => $opts['password'],
                'proto'    => !empty($opts['ssl']) ? 'https://' : 'http://'
            );
            $ret = $this->execCommand($docroot, 'core %(mode)s --admin_email=%(email)s --skip-email ' .
                '--url=%(proto)s%(url)s --title=%(title)s --admin_user=%(user)s ' .
                '--admin_password=%(password)s', $args);
            if (!$ret['success']) {
                return error('failed to create database structure: %s', coalesce($ret['stderr'], $ret['stdout']));
            }
            // by default, let's only open up ACLs to the bare minimum

            $params = array(
                'version'    => $this->get_version($hostname, $path),
                'hostname'   => $hostname,
                'path'       => $path,
                'type'       => 'wordpress',
                'autoupdate' => (bool)$opts['autoupdate'],
                'options'    => array_except($opts, ['version', 'password', 'user', 'title']),
            );

            if (!file_exists($this->domain_fs_path() . "/${docroot}/.htaccess")) {
                $template = '<IfModule mod_rewrite.c>' . "\n" .
                    'RewriteEngine On' . "\n" .
                    'RewriteBase /' . ltrim($path, '/') . "\n" .
                    'RewriteRule ^index\\.php$ - [L]' . "\n" .
                    'RewriteCond %{REQUEST_FILENAME} !-f' . "\n" .
                    'RewriteCond %{REQUEST_FILENAME} !-d' . "\n" .
                    'RewriteRule . /index.php [L]' . "\n" .
                    '</IfModule>' . "\n";
                $this->file_put_file_contents("${docroot}/.htaccess", $template);
            }
            $this->map('add', $docroot, $params);
            $this->fortify($hostname, $path, 'max');

            $ret = $this->execCommand($docroot, "rewrite structure '/%%postname%%/'");
            if (!$ret['success']) {
                return error('failed to set rewrite structure, error: %s', coalesce($ret['stderr'], $ret['stdout']));
            }

            if (array_get($opts, 'notify', true)) {
                \Lararia\Bootstrapper::minstrap();
                \Illuminate\Support\Facades\Mail::to($opts['email'])->
                send((new \Module\Support\Webapps\Mailer('install.wordpress', [
                    'login'    => $opts['user'],
                    'password' => $opts['password'],
                    'uri'      => rtrim($fqdn . '/' . $path, '/'),
                    'proto'    => empty($opts['ssl']) ? 'http://' : 'https://',
                    'appname'  => static::APP_NAME
                ]))->setAppName(static::APP_NAME));
            }

            if (!$opts['squash']) {
                parent::unsquash($docroot);
            }

            return info('WordPress installed - confirmation email with login info sent to %s', $opts['email']);
        }

        protected function execCommand($path = null, $cmd, array $args = array())
        {
            // client may override tz, propagate to bin
            $tz = date_default_timezone_get();
            $cli = 'php -d display_errors=' . (is_debug() ? 'on' : 'off') . ' -d mysqli.default_socket=' . escapeshellarg(ini_get('mysqli.default_socket')) .
                ' -d date.timezone=' . $tz . ' -d memory_limit=128m ' . self::WP_CLI;
            if (!is_array($args)) {
                $args = array_slice(func_get_args(), 2);
            }
            $user = $this->username;

            if (is_debug()) {
                $cmd = '--debug ' . $cmd;
            }

            if ($path) {
                $cmd = '--path=%(path)s --skip-packages ' . $cmd;
                $args['path'] = $path;
                $user = $this->getDocrootUser($path);
            }
            $cmd = $cli . ' ' . $cmd;
            // $from_email isn't always set, ensure WP can send via wp-includes/pluggable.php
            $ret = $this->pman_run($cmd, $args, ['SERVER_NAME' => $this->domain], ['user' => $user]);
            if (0 === strpos(coalesce($ret['stderr'], $ret['stdout']), 'Error:')) {
                // move stdout to stderr on error for consistency
                $ret['success'] = false;
                if (!$ret['stderr']) {
                    $ret['stderr'] = $ret['stdout'];
                }
            }

            return $ret;
        }

        protected function generateNewConfiguration($domain, $docroot, $dbcredentials, array $ftpcredentials = array())
        {
            // generate db
            if (!isset($ftpcredentials['user'])) {
                $ftpcredentials['user'] = $this->username . '@' . $this->domain;
            }
            if (!isset($ftpcredentials['host'])) {
                $ftpcredentials['host'] = 'localhost';
            }
            if (!isset($ftpcredentials['password'])) {
                $ftpcredentials['password'] = '';
            }

            $xtraphp = '<<EOF ' . "\n" .
                '// defer updates to CP' . "\n" .
                "define('WP_AUTO_UPDATE_CORE', false); " . "\n" .
                "define('FTP_USER',%(ftpuser)s);" . "\n" .
                "define('FTP_HOST', %(ftphost)s);" . "\n" .
                ($ftpcredentials['password'] ?
                    "define('FTP_PASS', %(ftppass)s);" : '') . "\n" .
                "define('FS_METHOD', false); " . "\n" .
                "define('WP_POST_REVISIONS', 5);" . "\n" .
                'EOF';
            $args = array(
                'mode'     => 'config',
                'db'       => $dbcredentials['db'],
                'password' => $dbcredentials['password'],
                'user'     => $dbcredentials['user'],
                'ftpuser'  => $ftpcredentials['user'],
                'ftphost'  => 'localhost',
                'ftppass'  => $ftpcredentials['password'],
            );


            $ret = $this->execCommand($docroot,
                'core %(mode)s --dbname=%(db)s --dbpass=%(password)s --dbuser=%(user)s --dbhost=localhost --extra-php ' . $xtraphp,
                $args);
            if (!$ret['success']) {
                return error('failed to generate configuration, error: %s', coalesce($ret['stderr'], $ret['stdout']));
            }

            return true;
        }

        /**
         * Get installed version
         *
         * @param string $hostname
         * @param string $path
         * @return string version number
         */
        public function get_version(string $hostname, string $path = ''): ?string
        {
            if (!$this->valid($hostname, $path)) {
                return null;
            }
            $docroot = $this->getAppRoot($hostname, $path);
            $ret = $this->execCommand($docroot, 'core version');
            if (!$ret['success']) {
                return null;
            }

            return trim($ret['stdout']);

        }

        /**
         * Location is a valid WP install
         *
         * @param string $hostname or $docroot
         * @param string $path
         * @return bool
         */
        public function valid(string $hostname, string $path = ''): bool
        {
            if ($hostname[0] === '/') {
                $docroot = $hostname;
            } else {
                $docroot = $this->getAppRoot($hostname, $path);
                if (!$docroot) {
                    return false;
                }
            }

            return $this->file_exists($docroot . '/wp-config.php') || $this->file_exists($docroot . '/wp-config-sample.php');
        }

        /**
         * Restrict write-access by the app
         *
         * @param string $hostname
         * @param string $path
         * @param string $mode
         * @return bool
         */
        public function fortify(string $hostname, string $path = '', string $mode = 'max'): bool
        {
            if (!parent::fortify($hostname, $path, $mode)) {
                return false;
            }
            $docroot = $this->getAppRoot($hostname, $path);
            if ($mode === 'min') {
                // allow direct access on min to squelch FTP dialog
                $this->shareOwnershipSystemCheck($docroot);
            } else {
                // flipping from min to max, reset file check
                $this->assertOwnershipSystemCheck($docroot);
            }

            return true;
        }

        /**
         * Share ownership of a WordPress install allowing WP write-access in min fortification
         *
         * @param string $docroot
         * @return int num files changed
         */
        protected function shareOwnershipSystemCheck(string $docroot): int
        {
            $changed = 0;
            $options = $this->getOptions($docroot);
            if (!array_get($options, 'fortify', 'min')) {
                return $changed;
            }
            $user = array_get($options, 'user', $this->getDocrootUser($docroot));
            $webuser = $this->web_get_user($docroot);
            foreach ($this->controlFiles as $file) {
                $path = $docroot . $file;
                if (!file_exists($this->domain_fs_path() . $path)) {
                    continue;
                }
                $this->file_chown($path, $webuser);
                $this->file_set_acls($path, $user, 6);
                $changed++;
            }

            return $changed;
        }

        /**
         * Change ownership over to WordPress admin
         *
         * @param string $docroot
         * @return int num files changed
         */
        protected function assertOwnershipSystemCheck(string $docroot): int
        {
            $changed = 0;
            $options = $this->getOptions($docroot);
            $user = array_get($options, 'user', $this->getDocrootUser($docroot));
            foreach ($this->controlFiles as $file) {
                $path = $docroot . $file;
                if (!file_exists($this->domain_fs_path() . $path)) {
                    continue;
                }
                $this->file_chown($path, $user);
                $changed++;
            }

            return $changed;
        }

        /**
         * Enumerate plugin states
         *
         * @param string      $hostname
         * @param string      $path
         * @param string|null $plugin optional plugin
         * @return array|bool
         */
        public function plugin_status(string $hostname, string $path = '', string $plugin = null)
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            $matches = $this->assetListWrapper($docroot, 'plugin', [
                'name',
                'status',
                'version',
                'update_version'
            ]);

            if (!$matches) {
                return false;
            }

            $pluginmeta = [];
            foreach ($matches as $match) {
                if (\in_array($match['status'], self::NON_UPDATEABLE_TYPES , true)) {
                    continue;
                }
                $name = $match['name'];
                $version = $match['version'];
                if (!$versions = $this->pluginVersions($name)) {
                    // commercial plugin
                    if (empty($match['update_version'])) {
                        $match['update_version'] = $match['version'];
                    }

                    $versions = [$match['version'], $match['update_version']];
                }
                $pluginmeta[$name] = [
                    'version' => $version,
                    'next'    => Versioning::nextVersion($versions, $version),
                    'max'     => $this->pluginInfo($name)['version'] ?? end($versions)
                ];
                // dev version may be present
                $pluginmeta[$name]['current'] = version_compare((string)array_get($pluginmeta, "${name}.max",
                    '99999999.999'), (string)$version, '<=') ?:
                    (bool)Versioning::current($versions, $version);
            }

            return $plugin ? $pluginmeta[$plugin] ?? error("unknown plugin `%s'", $plugin) : $pluginmeta;
        }

        protected function assetListWrapper(string $approot, string $type, array $fields): ?array {
            $ret = $this->execCommand($approot,
                $type . ' list --format=json --fields=%s', [implode(',', $fields)]);
            if (!$ret['success']) {
                error('failed to get %s status: %s', $type, coalesce($ret['stderr'], $ret['stdout']));
                return null;
            }

            if (null === ($matches = json_decode($ret['stdout'], true))) {
                error('Failed to decode %s output', $type);
                return null;
            }

            return $matches;
        }

        protected function pluginVersions(string $plugin): ?array
        {
            $info = $this->pluginInfo($plugin);
            if (!$info || empty($info['versions'])) {
                return null;
            }
            array_forget($info, 'versions.trunk');

            return array_keys($info['versions']);
        }

        /**
         * Get information about a plugin
         *
         * @param string $plugin
         * @return array
         */
        protected function pluginInfo(string $plugin): array
        {
            $cache = \Cache_Super_Global::spawn();
            $key = 'wp.pinfo-' . $plugin;
            if (false !== ($data = $cache->get($key))) {
                return $data;
            }
            $url = str_replace('%plugin%', $plugin, static::PLUGIN_VERSION_CHECK_URL);
            $info = [];
            if (false !== ($contents = file_get_contents($url))) {
                $info = (array)json_decode($contents, true);
                if (isset($info['versions'])) {
                    uksort($info['versions'], 'version_compare');
                }
            } else {
                info("Plugin `%s' detected as commercial. Using transient data.", $plugin);
            }
            $cache->set($key, $info, 86400);

            return $info;
        }

        /**
         * Install and activate plugin
         *
         * @param string $hostname domain or subdomain of wp install
         * @param string $path     optional path component of wp install
         * @param string $plugin   plugin name
         * @param string $version  optional plugin version
         * @return bool
         */
        public function install_plugin(
            string $hostname,
            string $path = '',
            string $plugin,
            string $version = ''
        ): bool {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            $args = array(
                'plugin' => $plugin
            );
            $cmd = 'plugin install %(plugin)s --activate';
            if ($version) {
                $cmd .= ' --version=%(version)s';
                $args['version'] = $version;
            }

            $ret = $this->execCommand($docroot, $cmd, $args);
            if (!$ret['success']) {
                return error("failed to install plugin `%s': %s", $plugin, coalesce($ret['stderr'], $ret['stdout']));
            }
            info("installed plugin `%s'", $plugin);

            return true;
        }

        /**
         * Uninstall a plugin
         *
         * @param string $hostname
         * @param string $path
         * @param string $plugin plugin name
         * @param bool   $force  delete even if plugin activated
         * @return bool
         */
        public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            $args = array(
                'plugin' => $plugin
            );
            $cmd = 'plugin uninstall %(plugin)s';
            if ($force) {
                $cmd .= ' --deactivate';
            }
            $ret = $this->execCommand($docroot, $cmd, $args);

            if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
                return error("failed to uninstall plugin `%s': %s", $plugin, coalesce($ret['stderr'], $ret['stdout']));
            }
            info("uninstalled plugin `%s'", $plugin);

            return true;
        }

        /**
         * Disable plugin
         *
         * @param string $hostname
         * @param string $path
         * @param string $plugin
         * @return bool
         */
        public function disable_plugin(string $hostname, string $path, string $plugin): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            return $this->assetManagerWrapper($docroot, 'plugin', 'deactivate', $plugin);
        }

        /**
         * Enable plugin
         *
         * @param string $hostname
         * @param string $path
         * @param string $plugin
         * @return bool
         */
        public function enable_plugin(string $hostname, string $path, string $plugin): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            return $this->assetManagerWrapper($docroot, 'plugin', 'activate', $plugin);
        }

        /**
         * Disable theme
         *
         * @param string $hostname
         * @param string $path
         * @param string $plugin
         * @return bool
         */
        public function disable_theme(string $hostname, string $path, string $plugin): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            return $this->assetManagerWrapper($docroot, 'theme', 'deactivate', $plugin);
        }

        /**
         * Enable theme
         *
         * @param string $hostname
         * @param string $path
         * @param string $plugin
         * @return bool
         */
        public function enable_theme(string $hostname, string $path, string $plugin): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            return $this->assetManagerWrapper($docroot, 'theme', 'activate', $plugin);
        }

        private function assetManagerWrapper(string $docroot, string $type, string $mode, string $asset): bool
        {
            $ret = $this->execCommand($docroot, '%s %s %s', [$type, $mode, $asset]);

            return $ret['success'] ?: error("Failed to %(mode)s `%(asset)s': %(err)s", [
                'mode' => $mode, 'asset' => $asset, 'err' => coalesce($ret['stderr'], $ret['stdout'])
            ]);
        }




        /**
         * Remove a Wordpress theme
         *
         * @param string $hostname
         * @param string $path
         * @param string $theme
         * @param bool   $force unused
         * @return bool
         */
        public function uninstall_theme(string $hostname, string $path = '', string $theme, bool $force = false): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            $args = array(
                'theme' => $theme
            );
            if ($force) {
                warn("Force parameter unused - deactivate theme first through WP panel if necessary");
            }
            $cmd = 'theme uninstall %(theme)s';
            $ret = $this->execCommand($docroot, $cmd, $args);

            if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
                return error("failed to uninstall plugin `%s': %s", $theme, coalesce($ret['stderr'], $ret['stdout']));
            }
            info("uninstalled theme `%s'", $theme);

            return true;
        }

        /**
         * Recovery mode to disable all plugins
         *
         * @param string $hostname subdomain or domain of WP
         * @param string $path     optional path
         * @return bool
         */
        public function disable_all_plugins(string $hostname, string $path = ''): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('failed to determine path');
            }

            $ret = $this->execCommand($docroot, 'plugin deactivate --all --skip-plugins');
            if (!$ret['success']) {
                return error('failed to deactivate all plugins: %s', coalesce($ret['stderr'], $ret['stdout']));
            }

            return info('plugin deactivation successful: %s', $ret['stdout']);
        }

        /**
         * Uninstall WP from a location
         *
         * @param        $hostname
         * @param string $path
         * @param string $delete "all", "db", or "files"
         * @return bool
         */
        public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool
        {
            return parent::uninstall($hostname, $path, $delete);
        }

        /**
         * Get database configuration for a blog
         *
         * @param string $hostname domain or subdomain of wp blog
         * @param string $path     optional path
         * @return array|bool
         */
        public function db_config(string $hostname, string $path = '')
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('failed to determine WP');
            }
            $code = 'ob_start(); register_shutdown_function(static function() { global $table_prefix; file_put_contents("php://fd/3", serialize(array("user" => DB_USER, "password" => DB_PASSWORD, "db" => DB_NAME, "host" => DB_HOST, "prefix" => $table_prefix))); ob_get_level() && ob_clean(); die(); }); include("./wp-config.php"); die();';
            $cmd = 'cd %(path)s && php -d mysqli.default_socket=%(socket)s -r %(code)s 3>&1-';
            $ret = $this->pman_run($cmd,
                array(
                    'path'   => $docroot,
                    'code'   => $code,
                    'socket' => ini_get('mysqli.default_socket')
                )
            );

            if (!$ret['success']) {
                return error("failed to obtain WP configuration for `%s'", $docroot);
            }

            return \Util_PHP::unserialize(trim($ret['stdout']));
        }

        /**
         * Change WP admin credentials
         *
         * $fields is a hash whose indices match wp_update_user
         * common fields include: user_pass, user_login, and user_nicename
         *
         * @link https://codex.wordpress.org/Function_Reference/wp_update_user
         *
         * @param string $hostname
         * @param string $path
         * @param array  $fields
         * @return bool
         */
        public function change_admin(string $hostname, string $path = '', array $fields): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return warn('failed to change administrator information');
            }
            $admin = $this->get_admin($hostname, $path);

            if (!$admin) {
                return error('cannot determine admin of WP install');
            }

            if (isset($fields['user_login'])) {
                return error('user login field cannot be changed in WP');
            }

            $args = array(
                'user' => $admin
            );
            $cmd = 'user update %(user)s';
            foreach ($fields as $k => $v) {
                $cmd .= ' --' . $k . '=%(' . $k . ')s';
                $args[$k] = $v;
            }

            $ret = $this->execCommand($docroot, $cmd, $args);
            if (!$ret['success']) {
                return error("failed to update admin `%s', error: %s",
                    $admin,
                    coalesce($ret['stderr'], $ret['stdout'])
                );
            }


            if (isset($fields['user_pass'])) {
                info("user `%s' password changed", $admin);
            }

            return $ret['success'];
        }

        /**
         * Get the primary admin for a WP instance
         *
         * @param string      $hostname
         * @param null|string $path
         * @return string admin or false on failure
         */
        public function get_admin(string $hostname, string $path = ''): ?string
        {
            $docroot = $this->getAppRoot($hostname, $path);
            $ret = $this->execCommand($docroot, 'user list --role=administrator --field=user_login');
            if (!$ret['success'] || !$ret['stdout']) {
                warn('failed to enumerate WP administrative users');

                return null;
            }

            return strtok($ret['stdout'], "\r\n");
        }

        /**
         * Update core, plugins, and themes atomically
         *
         * @param string $hostname subdomain or domain
         * @param string $path     optional path under hostname
         * @param string $version
         * @return bool
         */
        public function update_all(string $hostname, string $path = '', string $version = null): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (is_dir($this->domain_fs_path($docroot . '/wp-content/upgrade'))) {
                // ensure upgrade/ is writeable. WP may create the directory if permissions allow
                // during a self-directed upgrade
                $ctx = null;
                $stat = $this->file_stat($docroot);
                if (!$stat || !$this->file_set_acls($docroot . '/wp-content/upgrade', [
                        [$stat['owner'] => 'rwx'],
                        [$stat['owner'] => 'drwx']
                    ], File_Module::ACL_MODE_RECURSIVE)) {
                    warn("Failed to apply ACLs for %s/wp-content/upgrade. WP update may fail", $docroot);
                }
            }
            $ret = ($this->update_themes($hostname, $path) && $this->update_plugins($hostname, $path) &&
                    $this->update($hostname, $path, $version)) || error('failed to update all components');
            parent::setInfo($this->getAppRoot($hostname, $path), [
                'version' => $this->get_version($hostname, $path),
                'failed'  => !$ret
            ]);

            return $ret;
        }

        /**
         * Get next asset version
         *
         * @param string $name
         * @param array  $assetInfo
         * @param string $lock
         * @param string $type theme or plugin
         * @return null|string
         */
        private function getNextVersionFromAsset(string $name, array $assetInfo, string $lock, string $type): ?string
        {
            if (!isset($assetInfo['version'])) {
                error("Unable to query version for %s `%s', ignoring. Asset info: %s",
                    ucwords($type),
                    $name,
                    var_export($assetInfo, true)
                );

                return null;
            }

            $version = $assetInfo['version'];
            $versions = $this->{$type . 'Versions'}($name) ?? [$assetInfo['version'], $assetInfo['max']];
            $next = $this->windThroughVersions($version, $lock, $versions);
            if ($next === null && end($versions) !== $version) {
                info("%s `%s' already at maximal version `%s' for lock spec `%s'. " .
                    'Newer versions available. Manually upgrade or disable version lock to ' .
                    'update this component.',
                    ucwords($type), $name, $version, $lock
                );
            }

            return $next;
        }

        /**
         * Move pointer through versions finding the next suitable candidate
         *
         * @param string      $cur
         * @param null|string $lock
         * @param array       $versions
         * @return string|null
         */
        private function windThroughVersions(string $cur, ?string $lock, array $versions): ?string
        {
            $maximal = $tmp = $cur;
            do {
                $tmp = $maximal;
                $maximal = Versioning::nextSemanticVersion(
                    $tmp,
                    $versions,
                    $lock
                );
            } while ($maximal && $tmp !== $maximal);

            if ($maximal === $cur) {
                return null;
            }

            return $maximal;
        }

        /**
         * Update WordPress themes
         *
         * @param string $hostname subdomain or domain
         * @param string $path     optional path under hostname
         * @param array  $themes
         * @return bool
         */
        public function update_themes(string $hostname, string $path = '', array $themes = array()): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('update failed');
            }
            $flags = [];
            $lock = $this->getVersionLock($docroot);
            $skiplist = $this->getSkiplist($docroot, 'theme');

            if (!$skiplist && !$themes && !$lock) {
                $flags[] = implode(',', array_map('escapeshellarg', $skiplist));
                $ret = $this->execCommand($docroot, 'theme update --all ' . implode(' ', $flags));
                if (!$ret['success']) {
                    return error("theme update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
                }

                return $ret['success'];
            }

            $status = 1;
            if (false === ($allthemeinfo = $this->theme_status($hostname, $path))) {
                return false;
            }
            $themes = $themes ?: array_keys($allthemeinfo);
            foreach ($themes as $theme) {
                $version = null;
                $name = $theme['name'] ?? $theme;
                $themeInfo = $allthemeinfo[$name];
                if (isset($skiplist[$name]) || $themeInfo['current']) {
                    continue;
                }

                if (isset($theme['version'])) {
                    $version = $theme['version'];
                } else if ($lock && !($version = $this->getNextVersionFromAsset($name, $themeInfo, $lock, 'theme'))) {
                    // see if 'next' will satisfy the requirement
                    continue;
                }

                $cmd = 'theme update %(name)s';
                $args = [
                    'name' => $name
                ];

                // @XXX plugin update --version=X.Y.Z NAME
                // will fail if plugin is remotely hosted, check maximal version against upgrade version and
                // omit version flag as needed
                if ($version && $version !== $themeInfo['max']) {
                    $cmd .= ' --version=%(version)s';
                    $args['version'] = $version;
                }
                $cmd .= ' ' . implode(' ', $flags);
                $ret = $this->execCommand($docroot, $cmd, $args);
                if (!$ret['success']) {
                    error("failed to update theme `%s': %s", $name, coalesce($ret['stderr'], $ret['stdout']));
                }
                $status &= $ret['success'];
            }

            return (bool)$status;
        }

        /**
         * Get update protection list
         *
         * @param string $docroot
         * @param string|null $type
         * @return array
         */
        protected function getSkiplist(string $docroot, ?string $type)
        {
            if ($type !== null && $type !== 'plugin' && $type !== 'theme') {
                error("Unrecognized skiplist type `%s'", $type);

                return [];
            }
            $skiplist = $this->skiplistContents($docroot);

            return array_flip(array_filter(array_map(static function ($line) use ($type) {
                if (false !== ($pos = strpos($line, ':'))) {
                    if (!$type || strpos($line, $type . ':') === 0) {
                        return substr($line, $pos + 1);
                    }

                    return;
                }

                return $line;
            }, $skiplist)));
        }

        private function skiplistContents(string $approot): array
        {
            $skipfile = $this->domain_fs_path($approot . '/' . self::ASSET_SKIPLIST);
            if (!file_exists($skipfile)) {
                return [];
            }

            return (array)file($skipfile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
        }

        /**
         * Update WordPress plugins
         *
         * @param string $hostname domain or subdomain
         * @param string $path     optional path within host
         * @param array  $plugins
         * @return bool
         */
        public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('update failed');
            }
            $flags = [];
            $lock = $this->getVersionLock($docroot);
            $skiplist = $this->getSkiplist($docroot, 'plugin');

            if (!$plugins && !$skiplist && !$lock) {
                $flags[] = implode(',', array_map('escapeshellarg', $skiplist));
                $ret = $this->execCommand($docroot, 'plugin update --all ' . implode(' ', $flags));
                if (!$ret['success']) {
                    return error("plugin update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
                }

                return $ret['success'];
            }

            $status = 1;
            if (false === ($allplugininfo = $this->plugin_status($hostname, $path))) {
                return false;
            }
            $plugins = $plugins ?: array_keys($allplugininfo);
            foreach ($plugins as $plugin) {

                $version = null;
                $name = $plugin['name'] ?? $plugin;
                $pluginInfo = $allplugininfo[$name];
                if (isset($skiplist[$name]) || $pluginInfo['current']) {
                    continue;
                }

                if (isset($plugin['version'])) {
                    $version = $plugin['version'];
                } else if ($lock && !($version = $this->getNextVersionFromAsset($name, $pluginInfo, $lock, 'plugin'))) {
                    // see if 'next' will satisfy the requirement
                    continue;
                }

                $cmd = 'plugin update %(name)s';
                $args = [
                    'name' => $name
                ];
                // @XXX plugin update --version=X.Y.Z NAME
                // will fail if plugin is remotely hosted, check maximal version against upgrade version and
                // omit version flag as needed
                if ($version && $version !== $pluginInfo['max']) {
                    $cmd .= ' --version=%(version)s';
                    $args['version'] = $version;
                }
                $cmd .= ' ' . implode(' ', $flags);
                $ret = $this->execCommand($docroot, $cmd, $args);
                if (!$ret['success']) {
                    error("failed to update plugin `%s': %s", $name, coalesce($ret['stderr'], $ret['stdout']));
                }
                $status &= $ret['success'];
            }

            return (bool)$status;
        }

        /**
         * Update WordPress to latest version
         *
         * @param string $hostname domain or subdomain under which WP is installed
         * @param string $path     optional subdirectory
         * @param string $version
         * @return bool
         */
        public function update(string $hostname, string $path = '', string $version = null): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('update failed');
            }
            $this->assertOwnershipSystemCheck($docroot);

            $cmd = 'core update';
            $args = [];

            if ($version) {
                if (!is_scalar($version) || strcspn($version, '.0123456789')) {
                    return error('invalid version number, %s', $version);
                }
                $cmd .= ' --version=%(version)s';
                $args['version'] = $version;

                $ret = $this->execCommand($docroot, 'option get WPLANG');
                if (trim($ret['stdout']) === 'en') {
                    // issue seen with Softaculous installing under "en" locale, which generates
                    // an invalid update URI
                    warn('Bugged WPLANG setting. Changing en to en_US');
                    $this->execCommand($docroot, 'site switch-language en_US');
                }
            }

            $oldversion = $this->get_version($hostname, $path);
            $ret = $this->execCommand($docroot, $cmd, $args);

            if (!$ret['success']) {
                $output = coalesce($ret['stderr'], $ret['stdout']);
                if (0 === strpos($output, 'Error: Download failed.')) {
                    return warn('Failed to fetch update - retry update later: %s', $output);
                }

                return error("update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
            }

            // Sanity check as WP-CLI is known to fail while producing a 0 exit code
            if ($oldversion === $this->get_version($hostname, $path) &&
                !$this->is_current($oldversion, Versioning::asMajor($oldversion))) {
                return error('Failed to update WordPress - old version is same as new version - %s! ' .
                    'Diagnostics: (stderr) %s (stdout) %s', $oldversion, $ret['stderr'], $ret['stdout']);
            }

            info('updating WP database if necessary');
            $ret = $this->execCommand($docroot, 'core update-db');
            $this->shareOwnershipSystemCheck($docroot);

            if (!$ret['success']) {
                return warn('failed to update WP database - ' .
                    'login to WP admin panel to manually perform operation');
            }

            return $ret['success'];
        }

        /**
         * Check if version is latest or get latest version
         *
         * @param null|string $version    app version
         * @param string|null $branchcomp optional branch to compare against
         * @return int|string
         */
        public function is_current(string $version = null, string $branchcomp = null)
        {
            return parent::is_current($version, $branchcomp);

        }

        /**
         * Get theme status
         *
         * Sample response:
         * [
         *  hestia => [
         *      version => 1.1.50
         *      next => 1.1.51
         *      current => false
         *      max => 1.1.66
         *  ]
         * ]
         *
         * @param string      $hostname
         * @param string      $path
         * @param string|null $theme
         * @return array|bool
         */
        public function theme_status(string $hostname, string $path = '', string $theme = null)
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            $matches = $this->assetListWrapper($docroot, 'theme', [
                'name',
                'status',
                'version',
                'update_version'
            ]);

            if (!$matches) {
                return false;
            }

            $themes = [];
            foreach ($matches as $match) {
                if (\in_array($match['status'], self::NON_UPDATEABLE_TYPES, true)) {
                    continue;
                }
                $name = $match['name'];
                $version = $match['version'];
                if (!$versions = $this->themeVersions($name)) {
                    // commercial themes
                    if (empty($match['update_version'])) {
                        $match['update_version'] = $match['version'];
                    }

                    $versions = [$match['version'], $match['update_version']];
                }

                $themes[$name] = [
                    'version' => $version,
                    'next'    => Versioning::nextVersion($versions, $version),
                    'max'     => $this->themeInfo($name)['version'] ?? end($versions)
                ];
                // dev version may be present
                $themes[$name]['current'] = version_compare((string)array_get($themes, "${name}.max",
                    '99999999.999'), (string)$version, '<=') ?:
                    (bool)Versioning::current($versions, $version);
            }

            return $theme ? $themes[$theme] ?? error("unknown theme `%s'", $theme) : $themes;
        }

        /**
         * Get theme versions
         *
         * @param string $theme
         * @return null|array
         */
        protected function themeVersions($theme): ?array
        {
            $info = $this->themeInfo($theme);
            if (!$info || empty($info['versions'])) {
                return null;
            }
            array_forget($info, 'versions.trunk');

            return array_keys($info['versions']);
        }

        /**
         * Get theme information
         *
         * @param string $theme
         * @return array|null
         */
        protected function themeInfo(string $theme): ?array
        {
            $cache = \Cache_Super_Global::spawn();
            $key = 'wp.tinfo-' . $theme;
            if (false !== ($data = $cache->get($key))) {
                return $data;
            }
            $url = str_replace('%theme%', $theme, static::THEME_VERSION_CHECK_URL);
            $info = [];
            if (false !== ($contents = file_get_contents($url))) {
                $info = (array)json_decode($contents, true);
                if (isset($info['versions'])) {
                    uksort($info['versions'], 'version_compare');
                }
            } else {
                info("Theme `%s' detected as commercial. Using transient data.", $theme);
            }
            $cache->set($key, $info, 86400);

            return $info;
        }

        public function install_theme(string $hostname, string $path = '', string $theme, string $version = null): bool
        {
            $docroot = $this->getAppRoot($hostname, $path);
            if (!$docroot) {
                return error('invalid WP location');
            }

            $args = array(
                'theme' => $theme
            );
            $cmd = 'theme install %(theme)s --activate';
            if ($version) {
                $cmd .= ' --version=%(version)s';
                $args['version'] = $version;
            }
            $ret = $this->execCommand($docroot, $cmd, $args);
            if (!$ret['success']) {
                return error("failed to install theme `%s': %s", $theme, coalesce($ret['stderr'], $ret['stdout']));
            }
            info("installed theme `%s'", $theme);

            return true;
        }

        /**
         * Web application supports fortification
         *
         * @param string|null $mode optional mode (min, max)
         * @return bool
         */
        public function has_fortification(string $mode = null): bool
        {
            return parent::has_fortification($mode);
        }

        /**
         * Relax permissions to allow write-access
         *
         * @param string $hostname
         * @param string $path
         * @return bool
         * @internal param string $mode
         */
        public function unfortify(string $hostname, string $path = ''): bool
        {
            return parent::unfortify($hostname, $path);
        }

        /**
         * Install wp-cli if necessary
         *
         * @return bool
         * @throws \Exception
         */
        public function _housekeeping()
        {
            if (file_exists(self::WP_CLI) && filemtime(self::WP_CLI) < filemtime(__FILE__)) {
                unlink(self::WP_CLI);
            }

            if (!file_exists(self::WP_CLI)) {
                $url = self::WP_CLI_URL;
                $tmp = tempnam(storage_path('tmp'), 'wp-cli') . '.phar';
                $res = Util_HTTP::download($url, $tmp);
                if (!$res) {
                    file_exists($tmp) && unlink($tmp);

                    return error('failed to install wp-cli module');
                }
                try {
                    (new \Phar($tmp))->getSignature();
                    rename($tmp, self::WP_CLI) && chmod(self::WP_CLI, 0755);
                    info('downloaded wp-cli');
                } catch (\UnexpectedValueException $e) {
                    return error("WP-CLI signature failed, ignoring update");
                } finally {
                    if (file_exists($tmp)) {
                        unlink($tmp);
                    }
                }
                // older platforms
                $local = $this->service_template_path('siteinfo') . self::WP_CLI;
                if (!file_exists($local) && !copy(self::WP_CLI, $local)) {
                    return false;
                }
                chmod($local, 0755);

            }

            if (is_dir($this->service_template_path('siteinfo'))) {
                $link = $this->service_template_path('siteinfo') . '/usr/bin/wp-cli';
                $local = $this->service_template_path('siteinfo') . self::WP_CLI;
                if (!is_link($link) || realpath($link) !== realpath($local)) {
                    is_link($link) && unlink($link);
                    $referent = $this->file_convert_absolute_relative($link, $local);

                    return symlink($referent, $link);
                }
            }

            return true;
        }

        /**
         * Get all available WordPress versions
         *
         * @return array versions descending
         */
        public function get_versions(): array
        {
            $versions = $this->_getVersions();

            return array_reverse(array_column($versions, 'version'));
        }

        public function next_version(string $version, string $maximalbranch = '99999999.99999999.99999999'): ?string
        {
            return parent::next_version($version, $maximalbranch);
        }

        /**
         * Reconfigure a WordPress instance
         *
         * @param            $field
         * @param string     $attribute
         * @param array      $new
         * @param array|null $old
         */
        public function reconfigure(string $field, string $attribute, array $new, array $old = null)
        {

        }

        public function get_configuration($field)
        {

        }

        protected function _mapFiles(array $files, string $docroot): array
        {
            if (file_exists($this->domain_fs_path($docroot . '/wp-content'))) {
                return parent::_mapFiles($files, $docroot);
            }
            $path = $tmp = $docroot;
            // WP can allow relocation of assets, look for them
            $ret = $this->pman_run('cd %(docroot)s && php -r %(code)s', [
                'docroot' => $docroot,
                'code'    => 'set_error_handler(function() { echo defined("WP_CONTENT_DIR") ? constant("WP_CONTENT_DIR") : dirname(__FILE__); die(); }); include("./wp-config.php"); trigger_error("");define("ABS_PATH", "/dev/null");'
            ], null, ['user' => $this->getDocrootUser($docroot)]);

            if ($ret['success']) {
                $tmp = $ret['stdout'];
                if (0 === strpos($tmp, $this->domain_fs_path() . '/')) {
                    $tmp = $this->file_unmake_path($tmp);
                }
            }

            if ($path !== $tmp) {
                $relpath = $this->file_convert_absolute_relative($docroot . '/wp-content/', $tmp);
                foreach ($files as $k => $f) {
                    if (0 !== strpos($f, 'wp-content/')) {
                        continue;
                    }
                    $f = $relpath . substr($f, strlen('wp-content'));
                    $files[$k] = $f;
                }
            }

            return parent::_mapFiles($files, $docroot);
        }

        /**
         * Get latest WP release
         *
         * @return string
         */
        protected function _getLastestVersion()
        {
            $versions = $this->_getVersions();
            if (!$versions) {
                return null;
            }

            return $versions[0]['version'];
        }

        /**
         * Get all current major versions
         *
         * @return array
         */
        protected function _getVersions()
        {
            $key = 'wp.versions';
            $cache = Cache_Super_Global::spawn();
            if (false !== ($ver = $cache->get($key))) {
                return $ver;
            }
            $url = self::VERSION_CHECK_URL;
            $context = stream_context_create(['http' => ['timeout' => 5]]);
            $contents = file_get_contents($url, false, $context);
            if (!$contents) {
                return array();
            }
            $versions = json_decode($contents, true);
            $versions = $versions['offers'];
            if (isset($versions[1]['version'], $versions[0]['version'])
                && $versions[0]['version'] === $versions[1]['version']) {
                // WordPress sends most current + version tree
                array_shift($versions);
            }
            $cache->set($key, $versions, 43200);

            return $versions;
        }


        /**
         * Get basic summary of assets
         *
         * @param string $hostname
         * @param string $path
         * @return array
         */
        public function asset_summary(string $hostname, string $path = ''): array
        {
            if (!$approot = $this->getAppRoot($hostname, $path)) {
                return [];
            }

            $plugin = $this->assetListWrapper($approot, 'plugin', ['name', 'status', 'version', 'description', 'update_version']);
            $theme = $this->assetListWrapper($approot, 'theme', ['name', 'status', 'version', 'description', 'update_version']);
            $skippedtheme = $this->getSkiplist($approot, 'theme');
            $skippedplugin = $this->getSkiplist($approot, 'plugin');
            $merged = [];
            foreach (['plugin', 'theme'] as $type) {
                $skipped = ${'skipped' . $type};
                $assets = ${$type};
                usort($assets, static function ($a1, $a2) {
                    return strnatcmp($a1['name'], $a2['name']);
                });
                foreach ($assets as &$asset) {
                    if (\in_array($asset['status'], self::NON_UPDATEABLE_TYPES, true)) {
                        continue;
                    }
                    $name = $asset['name'];
                    $asset['skipped'] = isset($skipped[$name]);
                    $asset['active'] = $asset['status'] !== 'inactive';
                    $asset['type'] = $type;
                    $merged[] = $asset;
                }
                unset($asset);
            }
            return $merged;
        }

        /**
         * Skip updating an asset
         *
         * @param string      $hostname
         * @param string      $path
         * @param string      $name
         * @param string|null $type
         * @return bool
         */
        public function skip_asset(string $hostname, string $path = '', string $name, ?string $type): bool
        {
            if (!$approot = $this->getAppRoot($hostname, $path)) {
                return error("App root for `%s'/`%s' does not exist", $hostname, $path);
            }

            $assets = $this->getSkiplist($approot, $type);
            $assets[] = $type . ($type ? ':' : '') . $name;

            return $this->file_put_file_contents("${approot}/" . self::ASSET_SKIPLIST, implode("\n", $assets));
        }

        /**
         * Permit updates of an asset
         *
         * @param string      $hostname
         * @param string      $path
         * @param string      $name
         * @param string|null $type
         * @return bool
         */
        public function unskip_asset(string $hostname, string $path = '', string $name, ?string $type): bool
        {
            if (!$approot = $this->getAppRoot($hostname, $path)) {
                return error("App root for `%s'/`%s' does not exist", $hostname, $path);
            }

            $assets = $this->getSkiplist($approot, $type);

            if (!isset($assets[$name])) {
                return warn("%(type)s `%(asset)s' not present in skiplist", ['type' => $type, 'asset' => $name]);
            }

            $skiplist = $this->skiplistContents($approot);
            unset($skiplist["${type}:${name}"],$skiplist[$name]);
            return $this->file_put_file_contents("${approot}/" . self::ASSET_SKIPLIST, implode("\n", array_keys($skiplist)));
        }
    }