├── scaffold ├── issues-README.md ├── load-safety-net.php ├── project-README.md └── plugin-README.md ├── src ├── commands │ ├── increase-ulimit │ ├── delete-branch-protection-rules.php │ ├── deployhq-rotate-private-key.php │ ├── add-branch-protection-rules.php │ ├── jetpack-enable-sso.php │ ├── jetpack-module-list.php │ ├── plugin-list.php │ ├── jetpack-module.php │ ├── wpcom-get-stickers.php │ ├── dump-commands.php │ ├── plugin-list-full-dump.php │ ├── wpcom-remove-sticker.php │ ├── wpcom-add-sticker.php │ ├── pressable-generate-oauth-token.php │ ├── triage-graphql.php │ ├── jetpack-sites-with.php │ ├── pressable-site-run-wp-cli-command.php │ ├── pressable-call-api.php │ ├── pressable-upload-site-icon.php │ ├── update-repository-secret.php │ ├── pressable-site-open-shell.php │ ├── remove-user.php │ ├── flickr-scrap-photostream.php │ ├── stats-wpcom-traffic.php │ ├── pressable-site-rotate-passwords.php │ └── plugin-search.php └── helpers │ ├── github-api-helper.php │ ├── deployhq-api-helper.php │ ├── flickr-api-helper.php │ ├── config-loader.php │ ├── flickr-functions.php │ ├── pressable-connection-helper.php │ ├── deployhq-functions.php │ ├── wpcom-functions.php │ ├── wpcom-api-helper.php │ ├── pressable-api-helper.php │ ├── 1password-functions.php │ └── github-functions.php ├── install-osx ├── secrets ├── config__contractors.dist.json └── config.tpl.json ├── README.md ├── .ascii ├── .editorconfig ├── LICENSE ├── .phpcs.xml ├── composer.json ├── load-application.php ├── team51-cli.php └── .gitignore /scaffold/issues-README.md: -------------------------------------------------------------------------------- 1 | # EXAMPLE_REPO_NAME 2 | -------------------------------------------------------------------------------- /src/commands/increase-ulimit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ulimit -n 16000 3 | -------------------------------------------------------------------------------- /install-osx: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ulimit -n 8192 3 | composer install 4 | composer dump-autoload -o 5 | sudo mkdir -p /usr/local/bin 6 | script_path=$(pwd) 7 | sudo ln -sf "$script_path"/team51-cli.php /usr/local/bin/team51 8 | -------------------------------------------------------------------------------- /secrets/config__contractors.dist.json: -------------------------------------------------------------------------------- 1 | { 2 | "pressable": { 3 | "api_app_client_id": "", 4 | "api_app_client_secret": "", 5 | "api_refresh_token": "" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /scaffold/load-safety-net.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Best WordPress practices for non-WordPress code. 4 | 5 | 6 | 7 | ./src 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /scaffold/project-README.md: -------------------------------------------------------------------------------- 1 | # EXAMPLE_REPO_NAME 2 | 3 | This repo is for EXAMPLE_REPO_NAME, powered by WordPress. 4 | 5 | ## Project Structure 6 | 7 | - git is initialized in the `wp-content` directory 8 | - The main `themes`, `plugins`, and `mu-plugins` directories are ignored 9 | - Project-relevant themes and plugins that must be tracked are added as exceptions to the `.gitignore` file 10 | 11 | ## GitHub Workflow 12 | 13 | 1. Make your fix in a new branch. 14 | 1. Merge your `fix/` branch into the `develop` branch and test on the staging site. 15 | 1. If all looks good, make a PR and merge from your fix branch into `trunk`. 16 | 17 | NOTE: While PRs are not required to be manually reviewed, we are happy to review any PR for any reason. Please ping us in Slack with a link to the PR. 18 | 19 | ## Deployment 20 | 21 | - Prior to launch, during development, pushing to the `trunk` branch will automatically deploy to the in-progress site at https://EXAMPLE_REPO_PROD_URL 22 | - Once this project is launched, pushing to the `trunk` branch will be reviewed and deployed to the production site by a member of the Special Projects Team (see GitHub workflow above) 23 | - A new dev/staging site will then be created and pushing to the `develop` branch will then automatically deploy that dev/staging site at https://EXAMPLE_REPO_DEV_URL 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "ext-json": "*", 4 | "ext-sodium": "*", 5 | "ext-gd": "*", 6 | "amphp/http-client": "^4.6", 7 | "phpseclib/phpseclib": "^3.0", 8 | "symfony/console": "^4.4", 9 | "symfony/filesystem": "^4.4", 10 | "symfony/finder": "^4.4", 11 | "symfony/process": "^4.4" 12 | }, 13 | "require-dev": { 14 | "roave/security-advisories": "dev-latest" 15 | }, 16 | "autoload": { 17 | "files": [ 18 | "src/helpers/functions.php", 19 | "src/helpers/1password-functions.php", 20 | "src/helpers/deployhq-functions.php", 21 | "src/helpers/flickr-functions.php", 22 | "src/helpers/github-functions.php", 23 | "src/helpers/pressable-functions.php", 24 | "src/helpers/wpcom-functions.php" 25 | ], 26 | "classmap": [ 27 | "src/" 28 | ] 29 | }, 30 | "scripts": { 31 | "format:php": "@php phpcbf --standard=.phpcs.xml -v", 32 | "lint:php": "@php phpcs --standard=.phpcs.xml -s -v", 33 | 34 | "packages-install": "@composer install --ignore-platform-reqs --no-interaction", 35 | "packages-update": [ 36 | "@composer clear-cache", 37 | "@composer update --prefer-stable --no-interaction" 38 | ] 39 | }, 40 | "config": { 41 | "platform": { 42 | "php": "7.4" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/delete-branch-protection-rules.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Delete branch protection rules for a given GitHub repository.' ) 17 | ->setHelp( 'Allows deleting branch protection rules for a GitHub repository..' ) 18 | ->addArgument( 'repo-slug', InputArgument::REQUIRED, 'Repository name in slug form (e.g. client-name)?' ); 19 | } 20 | 21 | protected function execute( InputInterface $input, OutputInterface $output ) { 22 | $api_helper = new API_Helper(); 23 | 24 | $slug = $input->getArgument( 'repo-slug' ); 25 | 26 | $output->writeln( "Adding branch protection rules to $slug." ); 27 | $delete_branch_protection_rules = $api_helper->call_github_api( 'repos/' . GITHUB_API_OWNER . "/$slug/branches/master/protection", array(), 'DELETE' ); 28 | 29 | if ( ! empty( '' === $delete_branch_protection_rules ) ) { 30 | $output->writeln( "Done. Deleted branch protection rules for $slug." ); 31 | } else { 32 | $output->writeln( "Failed to delete branch protection rules for $slug: {$delete_branch_protection_rules->message}" ); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/deployhq-rotate-private-key.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Rotates private key in DeployHQ projects.' ) 25 | ->setHelp( 'This command allows you to rotate the private key in DeployHQ projects.' ); 26 | } 27 | 28 | /** 29 | * {@inheritDoc} 30 | */ 31 | protected function execute( InputInterface $input, OutputInterface $output ): int { 32 | $projects = get_deployhq_projects(); 33 | 34 | foreach ( $projects as $project ) { 35 | $output->writeln( "{$project->permalink}: Starting key rotation." ); 36 | 37 | if ( empty( $project->repository->url ) ) { 38 | $output->writeln( "{$project->permalink}: Skipped. No linked repo." ); 39 | continue; 40 | } 41 | 42 | $data = array( 43 | 'project' => array( 44 | 'custom_private_key' => DEPLOYHQ_PRIVATE_KEY, 45 | ), 46 | ); 47 | 48 | $response = DeployHQ_API_Helper::call_api( "projects/{$project->permalink}", 'PUT', $data ); 49 | 50 | if ( empty( $response->public_key ) || strpos( $response->public_key, DEPLOYHQ_PUBLIC_KEY ) === false ) { 51 | $output->writeln( "{$project->permalink}: Failed to rotate private key." ); 52 | continue; 53 | } 54 | 55 | $output->writeln( "{$project->permalink}: Done!" ); 56 | } 57 | 58 | return 0; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/add-branch-protection-rules.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Adds predefined branch protection rules to a given GitHub repository.' ) 17 | ->setHelp( 'Allows adding branch protection rules to a GitHub repository..' ) 18 | ->addArgument( 'repo-slug', InputArgument::REQUIRED, 'Repository name in slug form (e.g. client-name)?' ); 19 | } 20 | 21 | protected function execute( InputInterface $input, OutputInterface $output ) { 22 | $api_helper = new API_Helper(); 23 | 24 | $slug = $input->getArgument( 'repo-slug' ); 25 | 26 | // TODO: Allow these rules to be managed via the config.json file. 27 | 28 | $branch_protection_rules = array( 29 | 'required_status_checks' => array( 30 | 'strict' => true, 31 | 'contexts' => array( 32 | 'Run PHPCS inspection', 33 | ), 34 | ), 35 | 'enforce_admins' => null, 36 | 'required_pull_request_reviews' => null, 37 | 'restrictions' => null, 38 | ); 39 | 40 | $output->writeln( "Adding branch protection rules to $slug." ); 41 | $branch_protection_rules = $api_helper->call_github_api( 'repos/' . GITHUB_API_OWNER . "/$slug/branches/trunk/protection", $branch_protection_rules, 'PUT' ); 42 | 43 | if ( ! empty( $branch_protection_rules->required_status_checks->contexts ) ) { 44 | $output->writeln( "Done. Added branch protection rules to $slug." ); 45 | } else { 46 | $output->writeln( "Failed to add branch protection rules to $slug." ); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/helpers/github-api-helper.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Activates Jetpack SSO module and enables two-factor authentication.' ) 17 | ->setHelp( 'Use this command to enable the SSO module and two-factor authentication option in a single step. This command requires a Jetpack site connected to the a8cteam51 account.' ) 18 | ->addArgument( 'site-domain', InputArgument::REQUIRED, 'The domain of the Jetpack connected site.' ); 19 | } 20 | 21 | protected function execute( InputInterface $input, OutputInterface $output ) { 22 | $site_domain = $input->getArgument( 'site-domain' ); 23 | 24 | $api_helper = new API_Helper(); 25 | 26 | $output->writeln( 'Fetching site information...' ); 27 | 28 | $site = $api_helper->call_wpcom_api( 'rest/v1.1/sites/' . $site_domain, array() ); 29 | 30 | if ( empty( $site->ID ) ) { 31 | $output->writeln( 'Failed to fetch site information.' ); 32 | $output->writeln( 'Are you sure this site is connected to Jetpack and on the a8cteam51 account?' ); 33 | exit; 34 | } 35 | 36 | $output->writeln( 'Asking Jetpack to enable SSO...' ); 37 | 38 | $data = array( 39 | 'path' => '/jetpack/v4/settings/', 40 | 'json' => true, 41 | 'body' => json_encode( 42 | array( 43 | 'sso' => true, // activates SSO module. 44 | 'jetpack_sso_require_two_step' => true, 45 | ) 46 | ), 47 | ); 48 | 49 | $result = $api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/' . $site->ID . '/rest-api/', $data, 'POST' ); 50 | 51 | if ( ! empty( $result->error ) ) { 52 | $output->writeln( 'Failed. ' . $result->message . '' ); 53 | exit; 54 | } 55 | 56 | $output->writeln( 'All done! :)' ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /secrets/config.tpl.json: -------------------------------------------------------------------------------- 1 | { 2 | "deployhq": { 3 | "account": "op://Internal/Team 51 CLI config/DeployHQ/Account", 4 | "api_key": "op://Internal/Team 51 CLI config/DeployHQ/API key", 5 | "username": "op://Internal/mvuwmd4tnrgxnn64syaqh7ag5a/username", 6 | "default_project_template": "op://Internal/Team 51 CLI config/DeployHQ/Default project template", 7 | "private_key": "op://Internal/Team 51 CLI config/DeployHQ/Deployments private key", 8 | "public_key": "op://Internal/Team 51 CLI config/DeployHQ/Deployments public key", 9 | "atomic_key": "op://Internal/Team 51 CLI config/DeployHQ/Atomic host key" 10 | }, 11 | "flickr": { 12 | "api_key": "op://Internal/Team 51 CLI config/Flickr/API key" 13 | }, 14 | "github": { 15 | "api_owner": "op://Internal/Team 51 CLI config/GitHub/API owner", 16 | "api_token": "op://Internal/Team 51 CLI config/GitHub/API token", 17 | "api_bot_secrets_token": "op://Internal/Team 51 CLI config/GitHub/API bot secrets token", 18 | 19 | "default_issues_repository": "op://Internal/Team 51 CLI config/GitHub/Default issues repository", 20 | "devqueue_triage_column": "op://Internal/Team 51 CLI config/GitHub/Development queue triage column ID", 21 | "team_to_add_to_new_repository": "op://Internal/Team 51 CLI config/GitHub/Team ID to add to new repository", 22 | 23 | "devqueue_project_id": "op://Internal/Team 51 CLI config/GitHub/Development queue project ID" 24 | }, 25 | "pressable": { 26 | "account_email": "op://Internal/Pressable - Team 51/username", 27 | "account_password": "op://Internal/Pressable - Team 51/password", 28 | 29 | "api_app_client_id": "op://Internal/Team 51 CLI config/Pressable/API app client id", 30 | "api_app_client_secret": "op://Internal/Team 51 CLI config/Pressable/API app client secret" 31 | }, 32 | "slack": { 33 | "webhook_url": "op://Internal/Team 51 CLI config/Slack/Webhook URL" 34 | }, 35 | "wpcom": { 36 | "api_account_token": "op://Internal/Team 51 CLI config/WordPress.com/API account token", 37 | "get_stickers_url": "op://Internal/Team 51 CLI config/WordPress.com/Get stickers URL", 38 | "add_sticker_url": "op://Internal/Team 51 CLI config/WordPress.com/Add sticker URL", 39 | "remove_sticker_url": "op://Internal/Team 51 CLI config/WordPress.com/Remove sticker URL" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/jetpack-module-list.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Shows status of Jetpack modules on a specified site.' ) 18 | ->setHelp( 'Use this command to show a list of Jetpack modules on a site, and their status. This command requires a Jetpack site connected to the a8cteam51 account.' ) 19 | ->addArgument( 'site-domain', InputArgument::REQUIRED, 'The domain of the Jetpack connected site.' ); 20 | } 21 | 22 | protected function execute( InputInterface $input, OutputInterface $output ) { 23 | $site_domain = $input->getArgument( 'site-domain' ); 24 | 25 | $api_helper = new API_Helper(); 26 | 27 | $site = $api_helper->call_wpcom_api( 'rest/v1.1/sites/' . $site_domain . '?fields=ID', array() ); 28 | 29 | if ( empty( $site->ID ) ) { 30 | $output->writeln( 'Failed to fetch site information.' ); 31 | $output->writeln( 'Are you sure this site is connected to Jetpack and on the a8cteam51 account?' ); 32 | exit; 33 | } 34 | 35 | $output->writeln( "Jetpack module status for {$site_domain}" ); 36 | 37 | $module_data = $api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/' . $site->ID . '/rest-api/?path=/jetpack/v4/module/all', array() ); 38 | 39 | if ( ! empty( $module_data->error ) ) { 40 | $output->writeln( 'Failed. ' . $result->message . '' ); 41 | exit; 42 | } 43 | 44 | $module_table = new Table( $output ); 45 | $module_table->setStyle( 'box-double' ); 46 | $module_table->setHeaders( array( 'Module', 'Status' ) ); 47 | 48 | $module_list = array(); 49 | foreach ( $module_data->data as $module ) { 50 | $module_list[] = array( $module->module, ( $module->activated ? 'on' : 'off' ) ); 51 | } 52 | asort( $module_list ); 53 | 54 | $module_table->setRows( $module_list ); 55 | $module_table->render(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/helpers/deployhq-api-helper.php: -------------------------------------------------------------------------------- 1 | .deployhq.com/'; 19 | 20 | // endregion 21 | 22 | // region METHODS 23 | 24 | /** 25 | * Calls a given endpoint on the DeployHQ API and returns the response. 26 | * 27 | * @param string $endpoint The endpoint to call. 28 | * @param string $method The HTTP method to use. One of 'GET', 'POST', 'PUT', 'DELETE'. 29 | * @param array $params The parameters to send with the request. 30 | * 31 | * @return object|object[]|null 32 | */ 33 | public static function call_api( string $endpoint, string $method = 'GET', array $params = array() ) { 34 | $result = get_remote_content( 35 | self::get_request_url( $endpoint ), 36 | array( 37 | 'Accept: application/json', 38 | 'Content-type: application/json', 39 | 'Authorization: Basic ' . \base64_encode( DEPLOYHQ_USERNAME . ':' . DEPLOYHQ_API_KEY ), 40 | 'User-Agent: PHP', 41 | ), 42 | $method, 43 | encode_json_content( $params ) 44 | ); 45 | 46 | if ( 0 !== \strpos( $result['headers']['http_code'], '2' ) ) { 47 | $message = encode_json_content( $result['body'] ) ?? 'Badly formatted error'; 48 | console_writeln( 49 | "❌ DeployHQ API error: {$result['headers']['http_code']} $message", 50 | 404 === $result['headers']['http_code'] ? OutputInterface::VERBOSITY_DEBUG : OutputInterface::VERBOSITY_QUIET 51 | ); 52 | return null; 53 | } 54 | 55 | return $result['body']; 56 | } 57 | 58 | // endregion 59 | 60 | // region HELPERS 61 | 62 | /** 63 | * Prepares the fully qualified request URL for the given endpoint. 64 | * 65 | * @param string $endpoint The endpoint to call. 66 | * 67 | * @return string 68 | */ 69 | private static function get_request_url( string $endpoint ): string { 70 | return \str_replace( '', DEPLOYHQ_ACCOUNT, self::BASE_URL ) . \ltrim( $endpoint, '/' ); 71 | } 72 | 73 | // endregion 74 | } 75 | -------------------------------------------------------------------------------- /src/commands/plugin-list.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Shows list of plugins on a specified site.' ) 18 | ->setHelp( 'Use this command to show a list of installed plugins on a site. This command requires a Jetpack site connected to the a8cteam51 account.' ) 19 | ->addArgument( 'site-domain', InputArgument::REQUIRED, 'The domain of the Jetpack connected site.' ); 20 | } 21 | 22 | protected function execute( InputInterface $input, OutputInterface $output ) { 23 | $site_domain = $input->getArgument( 'site-domain' ); 24 | 25 | $api_helper = new API_Helper(); 26 | 27 | $site = $api_helper->call_wpcom_api( 'rest/v1.1/sites/' . $site_domain, array() ); 28 | 29 | if ( empty( $site->ID ) ) { 30 | $output->writeln( 'Failed to fetch site information.' ); 31 | $output->writeln( 'Are you sure this site is connected to Jetpack and on the a8cteam51 account?' ); 32 | exit; 33 | } 34 | 35 | $output->writeln( "Plugins installed on {$site_domain}" ); 36 | 37 | $plugin_data = $api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/' . $site->ID . '/rest-api/?path=/jetpack/v4/plugins', array() ); 38 | 39 | if ( ! empty( $plugin_data->error ) ) { 40 | $output->writeln( 'Failed. ' . $plugin_data->message . '' ); 41 | exit; 42 | } 43 | 44 | $plugin_table = new Table( $output ); 45 | $plugin_table->setStyle( 'box-double' ); 46 | $plugin_table->setHeaders( array( 'Plugin slug', 'Status', 'Version' ) ); 47 | 48 | $plugin_list = array(); 49 | foreach ( $plugin_data->data as $plugin ) { 50 | $plugin_list[] = array( ( empty( $plugin->TextDomain ) ? $plugin->Name . ' - (No slug)' : $plugin->TextDomain ), ( $plugin->active ? 'Active' : 'Inactive' ), $plugin->Version ); 51 | } 52 | asort( $plugin_list ); 53 | 54 | $plugin_table->setRows( $plugin_list ); 55 | $plugin_table->render(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /scaffold/plugin-README.md: -------------------------------------------------------------------------------- 1 | # EXAMPLE_REPO_NAME 2 | 3 | **Contributors:** wpcomspecialprojects \ 4 | **Tags:** \ 5 | **Requires at least:** 6.2 \ 6 | **Tested up to:** 6.2 \ 7 | **Requires PHP:** 8.0 \ 8 | **Stable tag:** 1.0.0 \ 9 | **License:** GPLv3 or later \ 10 | **License URI:** http://www.gnu.org/licenses/gpl-3.0.html 11 | 12 | EXAMPLE_REPO_DESCRIPTION 13 | 14 | ## Description 15 | 16 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed leo ligula, aliquam et sem luctus, placerat facilisis orci. Cras faucibus, odio ac aliquet scelerisque, nisi ligula dignissim nisi, sed tincidunt magna libero vitae dui. Sed varius lectus turpis, fringilla maximus libero posuere nec. Aenean volutpat pharetra sem, et cursus leo sodales quis. 17 | 18 | ## Installation 19 | 20 | This plugin requires WooCommerce 7.4+ to run. If you're running a lower version, please update first. After you made sure that you're running a supported version of WooCommerce, you may install `Team51 Plugin Scaffold` either manually or through your site's plugins page. 21 | 22 | ### INSTALL FROM WITHIN WORDPRESS 23 | 24 | 1. Visit the plugins page withing your dashboard and select `Add New`. 25 | 1. Search for `Team51 Plugin Scaffold` and click the `Install Now` button. 26 | 1. Activate the plugin from within your `Plugins` page. 27 | 28 | ### INSTALL MANUALLY 29 | 30 | 1. Download the plugin from https://wordpress.org/plugins/ and unzip the archive. 31 | 1. Upload the `EXAMPLE_REPO_NAME` folder to the `/wp-content/plugins/` directory. 32 | 1. Activate the plugin through the `Plugins` menu in WordPress. 33 | 34 | ### AFTER ACTIVATION 35 | 36 | If the minimum required version of WooCommerce is present, you will find a section present in the `Advanced` tab of the WooCommerce `Settings` page. Aliquam dolor sem, convallis malesuada neque sit amet, dictum mattis velit. Vestibulum at pharetra metus. Suspendisse rhoncus libero nisi, sed rhoncus tortor aliquam pretium. 37 | 38 | ## Frequently Asked Questions 39 | 40 | ### How can I get help if I'm stuck? 41 | 42 | Quisque volutpat tortor id varius pulvinar. Vivamus porttitor, mi non auctor pellentesque, leo purus interdum libero, at aliquam justo lectus sed ligula. 43 | 44 | ### I have a question that is not listed here 45 | 46 | Duis efficitur, sapien ac scelerisque placerat, elit justo tempor nisl, ut feugiat magna orci quis odio. 47 | 48 | ## Screenshots 49 | 50 | ### 1. Example screenshot 51 | 52 | [missing image] 53 | 54 | ## Changelog 55 | 56 | ### 1.0.0 (FIRST RELEASE DATE) 57 | 58 | * First official release. 59 | -------------------------------------------------------------------------------- /src/helpers/flickr-api-helper.php: -------------------------------------------------------------------------------- 1 | stat ) { 50 | console_writeln( 51 | "❌ Flickr API error ($endpoint)", 52 | OutputInterface::VERBOSITY_QUIET 53 | ); 54 | return null; 55 | } 56 | 57 | return $result['body']; 58 | } 59 | 60 | // endregion 61 | 62 | // region HELPERS 63 | 64 | /** 65 | * Prepares the fully qualified request URL for the given endpoint. 66 | * 67 | * @param string $endpoint The endpoint to call. 68 | * @param array $arguments The arguments to send with the request. 69 | * 70 | * @return string 71 | */ 72 | private static function get_request_url( string $endpoint, array $arguments ): string { 73 | return self::BASE_URL . '?' . \http_build_query( 74 | array( 75 | 'method' => $endpoint, 76 | 'api_key' => FLICKR_API_KEY, 77 | 'format' => 'json', 78 | 'nojsoncallback' => 1, 79 | ) + $arguments 80 | ); 81 | } 82 | 83 | // endregion 84 | } 85 | -------------------------------------------------------------------------------- /load-application.php: -------------------------------------------------------------------------------- 1 | addCommands( 18 | array( 19 | new Team51\Command\Create_Production_Site(), 20 | new Team51\Command\Create_Development_Site(), 21 | new Team51\Command\Create_Repository(), 22 | new Team51\Command\Add_Branch_Protection_Rules(), 23 | new Team51\Command\Delete_Branch_Protection_Rules(), 24 | new Team51\Command\Flickr_Scrap_Photostream(), 25 | new Team51\Command\Jetpack_Enable_SSO(), 26 | new Team51\Command\Remove_User(), 27 | new Team51\Command\Update_Repository_Secret(), 28 | new Team51\Command\Plugin_List(), 29 | new Team51\Command\Plugin_Search(), 30 | new Team51\Command\Pressable_Call_Api(), 31 | new Team51\Command\Pressable_Generate_OAuth_Token(), 32 | new Team51\Command\Pressable_Site_Add_Domain(), 33 | new Team51\Command\Pressable_Site_Create_Collaborator(), 34 | new Team51\Command\Pressable_Site_Open_Shell(), 35 | new Team51\Command\Pressable_Site_PHP_Errors(), 36 | new Team51\Command\Pressable_Site_Rotate_Passwords(), 37 | new Team51\Command\Pressable_Site_Rotate_SFTP_User_Password(), 38 | new Team51\Command\Pressable_Site_Rotate_WP_User_Password(), 39 | new Team51\Command\Pressable_Site_Run_WP_CLI_Command(), 40 | new Team51\Command\Pressable_Site_Upload_Icon(), 41 | new Team51\Command\Jetpack_Modules(), 42 | new Team51\Command\Jetpack_Module(), 43 | new Team51\Command\Jetpack_Sites_With(), 44 | new Team51\Command\Triage_GraphQL(), 45 | new Team51\Command\Dump_Commands(), 46 | new Team51\Command\Site_List(), 47 | new Team51\Command\Plugin_Summary(), 48 | new Team51\Command\Get_Site_Stats(), 49 | new Team51\Command\Get_WooCommerce_Stats(), 50 | new Team51\Command\DeployHQ_Rotate_Private_Key(), 51 | new Team51\Command\WPCOM_Get_Stickers(), 52 | new Team51\Command\WPCOM_Add_Sticker(), 53 | new Team51\Command\WPCOM_Remove_Sticker(), 54 | ) 55 | ); 56 | 57 | foreach ( $application->all() as $command ) { 58 | $command->addOption( '--contractor', '-c', InputOption::VALUE_NONE, 'Use the contractor config file.' ); 59 | $command->addOption( '--dev', null, InputOption::VALUE_NONE, 'Run the CLI tool in developer mode.' ); 60 | } 61 | 62 | $application->run(); 63 | -------------------------------------------------------------------------------- /src/helpers/config-loader.php: -------------------------------------------------------------------------------- 1 | $secrets ) { 56 | foreach ( $secrets as $name => $secret ) { 57 | $constant_name = \strtoupper( $name ); 58 | if ( 'general' !== $section ) { 59 | $constant_name = \strtoupper( $section ) . '_' . $constant_name; 60 | } 61 | 62 | \define( $constant_name, $secret ); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /team51-cli.php: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | &1", $output, $result_code ); 41 | 42 | if ( 0 !== $result_code ) { 43 | debug( sprintf( 'Error running command: %s', $command ), false ); 44 | 45 | // Print the output. 46 | foreach ( $output as $line ) { 47 | debug( $line, false ); 48 | } 49 | } 50 | 51 | return array( 52 | 'output' => $output, 53 | 'result_code' => $result_code, 54 | ); 55 | } 56 | 57 | /** 58 | * CLI update routine. 59 | * 60 | * @return void 61 | */ 62 | function update() { 63 | 64 | // Check current branch. 65 | $command = run_command( sprintf( 'git -C %s branch --show-current', __DIR__ ) ); 66 | 67 | if ( 'trunk' !== $command['output'][0] ) { 68 | debug( 'Not in `trunk`. Switching...' ); 69 | run_command( sprintf( 'git -C %s stash', __DIR__ ) ); 70 | run_command( sprintf( 'git -C %s checkout -f trunk', __DIR__ ) ); 71 | } 72 | 73 | // Reset branch. 74 | run_command( sprintf( 'git -C %s fetch origin', __DIR__ ) ); 75 | run_command( sprintf( 'git -C %s reset --hard origin/trunk', __DIR__ ) ); 76 | } 77 | 78 | // Initialize environment. 79 | $is_quiet = false; 80 | $is_dev = false; 81 | 82 | foreach ( $argv as $arg ) { 83 | switch ( $arg ) { 84 | case '-q': 85 | case '--quiet': 86 | $is_quiet = true; 87 | break; 88 | case '--dev': 89 | $is_dev = true; 90 | break; 91 | } 92 | } 93 | 94 | // Support a .dev file to set the environment. 95 | if ( file_exists( __DIR__ . '/.dev' ) ) { 96 | $is_dev = true; 97 | } 98 | 99 | define( 'IS_QUIET', $is_quiet ); 100 | define( 'IS_DEV', $is_dev ); 101 | 102 | // Print ASCII art. 103 | print_ascii_art(); 104 | 105 | if ( IS_DEV ) { 106 | debug( "\033[44mRunning in developer mode. Skipping update check.\033[0m" ); 107 | } else { 108 | debug( "\033[33mChecking for updates..\033[0m" ); 109 | update(); 110 | } 111 | 112 | // Update Composer. 113 | run_command( sprintf( 'composer install -o --working-dir %s', __DIR__ ) ); 114 | run_command( sprintf( 'composer dump-autoload -o --working-dir %s', __DIR__ ) ); 115 | 116 | require __DIR__ . '/load-application.php'; 117 | -------------------------------------------------------------------------------- /src/commands/jetpack-module.php: -------------------------------------------------------------------------------- 1 | true, 16 | 'disable' => false, 17 | ); 18 | 19 | protected function configure() { 20 | $this 21 | ->setDescription( 'Enable/disable Jetpack modules for a site.' ) 22 | ->setHelp( 'Use this command to enable/disable Jetpack modules. This command requires a Jetpack site connected to the a8cteam51 account.' ) 23 | ->addArgument( 'site-domain', InputArgument::REQUIRED, 'The domain of the Jetpack connected site.' ) 24 | ->addArgument( 'module', InputArgument::REQUIRED, 'The desired Jetpack module.' ) 25 | ->addArgument( 'setting', InputArgument::REQUIRED, 'enable/disable' ); 26 | } 27 | 28 | protected function execute( InputInterface $input, OutputInterface $output ) { 29 | $site_domain = $input->getArgument( 'site-domain' ); 30 | $module = $input->getArgument( 'module' ); 31 | $setting = $input->getArgument( 'setting' ); 32 | $setting_boolean = $this->get_setting_boolean( $setting ); 33 | 34 | if ( null === $setting_boolean ) { 35 | $output->writeln( 'Wrong setting! Accepted values are enable/disable.' ); 36 | exit; 37 | } 38 | 39 | $api_helper = new API_Helper(); 40 | 41 | $output->writeln( 'Fetching site information...' ); 42 | 43 | $site = $api_helper->call_wpcom_api( 'rest/v1.1/sites/' . $site_domain, array() ); 44 | 45 | if ( empty( $site->ID ) ) { 46 | $output->writeln( 'Failed to fetch site information.' ); 47 | $output->writeln( 'Are you sure this site is connected to Jetpack and on the a8cteam51 account?' ); 48 | exit; 49 | } 50 | 51 | $output->writeln( 'Asking Jetpack to set the module setting...' ); 52 | 53 | $data = array( 54 | 'path' => '/jetpack/v4/settings/', 55 | 'json' => true, 56 | 'body' => json_encode( 57 | array( 58 | $module => $setting_boolean, 59 | ) 60 | ), 61 | ); 62 | 63 | $result = $api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/' . $site->ID . '/rest-api/', $data, 'POST' ); 64 | 65 | if ( ! empty( $result->error ) ) { 66 | $output->writeln( 'Failed. ' . $result->message . '' ); 67 | exit; 68 | } 69 | 70 | $output->writeln( 'All done! :)' ); 71 | } 72 | 73 | /** 74 | * @param $setting 75 | * 76 | * @return bool|null 77 | */ 78 | private function get_setting_boolean( $setting ) { 79 | foreach ( $this->settings as $key => $val ) { 80 | if ( $setting === $key ) { 81 | return $val; 82 | } 83 | } 84 | return null; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/commands/wpcom-get-stickers.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Get a list of a site\'s stickers.' ) 36 | ->setHelp( 'This command allows you get a list of stickers associated with a site given a site ID or URL.' ) 37 | ->addArgument( 'site', InputArgument::REQUIRED, 'ID or URL of the site for which get stickers associated.' ); 38 | } 39 | 40 | /** 41 | * {@inheritDoc} 42 | */ 43 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 44 | maybe_define_console_verbosity( $output->getVerbosity() ); 45 | 46 | $this->wpcom_site = get_wpcom_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); 47 | 48 | if ( \is_null( $this->wpcom_site ) ) { 49 | exit( 1 ); // Exit if the site does not exist. 50 | } 51 | 52 | // Store the ID of the site in the argument field. 53 | $input->setArgument( 'site', $this->wpcom_site->ID ); 54 | } 55 | 56 | /** 57 | * {@inheritDoc} 58 | */ 59 | protected function execute( InputInterface $input, OutputInterface $output ) { 60 | $stickers = WPCOM_API_Helper::call_api( 61 | sprintf( WPCOM_GET_STICKERS_URL, $this->wpcom_site->ID ) 62 | ); 63 | 64 | if ( empty( $stickers ) ) { 65 | $output->writeln( 'Site has no stickers associated.' ); 66 | exit; 67 | } 68 | 69 | $sticker_table = new Table( $output ); 70 | $sticker_table->setStyle( 'box-double' ) 71 | ->setHeaderTitle( 'Stickers' ) 72 | ->setHeaders( array( "ID: {$this->wpcom_site->ID} ({$this->wpcom_site->URL})" ) ) 73 | ->setRows( array_map( fn ( $sticker ) => array( $sticker ), $stickers ) ) 74 | ->render(); 75 | } 76 | 77 | /** 78 | * Prompts the user for a site if in interactive mode. 79 | * 80 | * @param InputInterface $input The input object. 81 | * @param OutputInterface $output The output object. 82 | * 83 | * @return string|null 84 | */ 85 | private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { 86 | if ( $input->isInteractive() ) { 87 | $question = new Question( 'Enter the site ID or URL to query for stickers: ' ); 88 | $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); 89 | } 90 | 91 | return $site ?? null; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/commands/dump-commands.php: -------------------------------------------------------------------------------- 1 | setName( 'dump-commands' ) 18 | ->setDescription( 'Dumps information about all commands' ) 19 | // TODO: Update link to point to an actual doc 20 | ->setHelp( "This command allows you to dump a list of all commands with their description and help.\nFor more details on using this to update the CLI documentation, check here: https://github.com/Automattic/team51-cli/wiki" ) 21 | ->addOption( 'format', 'f', InputOption::VALUE_REQUIRED, 'The format to use (md, txt, json, xml)', 'md' ) 22 | ->addOption( 'save', '', InputOption::VALUE_NONE, 'Save the output to a file' ) 23 | ->addOption( 'destination', 'd', InputOption::VALUE_REQUIRED, "The path to save the output to (Only applies if --save option is set)\nIf an extension isn't specified, it will be added automatically based on the format (e.g., 'dump-commands --format=json --destination=myfile' will output to myfile.json", getcwd() . '/team51-commands' ); 24 | } 25 | 26 | protected function execute( InputInterface $input, OutputInterface $output ) { 27 | 28 | try { 29 | $descriptor = $this->get_descriptor( $input->getOption( 'format' ) ); 30 | } catch ( \Exception $e ) { 31 | $output->writeln( '' . $e->getMessage() . '' ); 32 | return; 33 | } 34 | 35 | $stream = $this->set_output_stream( $input, $output ); 36 | $descriptor->describe( $stream, $this->getApplication() ); 37 | } 38 | 39 | private function get_descriptor( $format ) { 40 | switch ( $format ) { 41 | case 'md': 42 | return new MarkdownDescriptor(); 43 | case 'json': 44 | return new JsonDescriptor(); 45 | case 'xml': 46 | return new XmlDescriptor(); 47 | default: 48 | throw new \InvalidArgumentException( 'Invalid format' ); 49 | } 50 | } 51 | 52 | private function maybe_add_file_extension( $input ) { 53 | $path = $input->getOption( 'destination' ); 54 | $regex = '/\w+\.\w+$/'; // Checks if there is already an extension on the file 55 | if ( preg_match( $regex, $path ) ) { 56 | return $path; 57 | } 58 | 59 | return $path . '.' . $input->getOption( 'format' ); 60 | } 61 | 62 | private function set_output_stream( InputInterface $input, OutputInterface $output ) { 63 | if ( $input->getOption( 'save' ) ) { 64 | $path = $this->maybe_add_file_extension( $input ); 65 | $outfile = new StreamOutput( fopen( $path, 'w' ) ); 66 | if ( ! $outfile ) { 67 | $output->writeln( 'Could not open file for writing: ' . $path . '' ); 68 | return; 69 | } 70 | $output->writeln( 'Saving to file: ' . $path . '' ); 71 | return $outfile; 72 | } 73 | 74 | // Default to standard output stream 75 | return $output; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/helpers/flickr-functions.php: -------------------------------------------------------------------------------- 1 | $username ) ); 16 | return $user->user ?? null; 17 | } 18 | 19 | /** 20 | * Returns the photosets belonging to the specified user. 21 | * 22 | * @param string $user_id The NSID of the user to get a photoset list for. 23 | * 24 | * @link https://www.flickr.com/services/api/flickr.photosets.getList.html 25 | * 26 | * @return object|null 27 | */ 28 | function get_flickr_photosets_for_user( string $user_id ): ?object { 29 | $photosets = Flickr_API_Helper::call_api( 'flickr.photosets.getList', array( 'user_id' => $user_id ) ); 30 | return $photosets->photosets ?? null; 31 | } 32 | 33 | /** 34 | * Returns the list of photos in a set. 35 | * 36 | * @param string $photoset_id The ID of the photoset to return the photos for. 37 | * @param array $arguments Additional arguments to pass to the API call. 38 | * 39 | * @link https://www.flickr.com/services/api/flickr.photosets.getPhotos.html 40 | * 41 | * @return object|null 42 | */ 43 | function get_flickr_photos_for_photoset( string $photoset_id, array $arguments = array() ): ?object { 44 | $photos = Flickr_API_Helper::call_api( 'flickr.photosets.getPhotos', array( 'photoset_id' => $photoset_id ) + $arguments ); 45 | return $photos->photoset ?? null; 46 | } 47 | 48 | /** 49 | * Returns photos from the given user's photostream. Only photos visible to the calling user will be returned. 50 | * 51 | * @param string $user_id The NSID of the user whose photos to return. A value of "me" will return the calling user's photos. 52 | * @param array $arguments Additional arguments to pass to the API call. 53 | * 54 | * @link https://www.flickr.com/services/api/flickr.people.getPhotos.html 55 | * 56 | * @return object|null 57 | */ 58 | function get_flickr_photos_for_user( string $user_id, array $arguments = array() ): ?object { 59 | $photos = Flickr_API_Helper::call_api( 'flickr.people.getPhotos', array( 'user_id' => $user_id ) + $arguments ); 60 | return $photos->photos ?? null; 61 | } 62 | 63 | /** 64 | * Returns the available sizes for a photo. The calling user must have permission to view the photo. 65 | * 66 | * @param string $photo_id The ID of the photo to return the sizes for. 67 | * 68 | * @link https://www.flickr.com/services/api/flickr.photos.getSizes.html 69 | * 70 | * @return object|null 71 | */ 72 | function get_flickr_photo_sizes( string $photo_id ): ?object { 73 | $sizes = Flickr_API_Helper::call_api( 'flickr.photos.getSizes', array( 'photo_id' => $photo_id ) ); 74 | return $sizes->sizes ?? null; 75 | } 76 | 77 | /** 78 | * Returns the comments for a photo. 79 | * 80 | * @param string $photo_id The ID of the photo to return the comments for. 81 | * @param array $arguments Additional arguments to pass to the API call. 82 | * 83 | * @link https://www.flickr.com/services/api/flickr.photos.comments.getList.html 84 | * 85 | * @return object|null 86 | */ 87 | function get_flickr_comments_for_photo( string $photo_id, array $arguments = array() ): ?object { 88 | $comments = Flickr_API_Helper::call_api( 'flickr.photos.comments.getList', array( 'photo_id' => $photo_id ) + $arguments ); 89 | return $comments->comments ?? null; 90 | } 91 | -------------------------------------------------------------------------------- /src/helpers/pressable-connection-helper.php: -------------------------------------------------------------------------------- 1 | login( $login_data['username'], $login_data['password'] ) ) { 43 | $connection->isConnected() && $connection->disconnect(); 44 | return null; 45 | } 46 | 47 | return $connection; 48 | } 49 | 50 | /** 51 | * Opens a new SSH connection to a Pressable site. 52 | * 53 | * @param string $site_id The ID of the Pressable site to open a connection to. 54 | * 55 | * @return SFTP|null 56 | */ 57 | public static function get_ssh_connection( string $site_id ): ?SSH2 { 58 | $login_data = self::get_login_data( $site_id ); 59 | if ( \is_null( $login_data ) || \is_null( $login_data['password'] ) ) { 60 | return null; 61 | } 62 | 63 | $connection = new SSH2( self::SSH_HOST ); 64 | if ( ! $connection->login( $login_data['username'], $login_data['password'] ) ) { 65 | $connection->isConnected() && $connection->disconnect(); 66 | return null; 67 | } 68 | 69 | // Shortly after a new site is created, the server does not support SSH commands yet, but it will still accept 70 | // and authenticate the connection. We need to wait a bit before we can actually run commands. So the following 71 | // lines are a short hack to check if the server is indeed ready. 72 | $response = $connection->exec( 'ls -la' ); 73 | if ( "This service allows sftp connections only.\n" === $response || 0 !== $connection->getExitStatus() ) { 74 | $connection->isConnected() && $connection->disconnect(); 75 | return null; 76 | } 77 | 78 | return $connection; 79 | } 80 | 81 | // endregion 82 | 83 | // region HELPERS 84 | 85 | /** 86 | * Returns the SFTP/SSH login data for the concierge user on a given Pressable site. 87 | * 88 | * @param string $site_id The ID of the Pressable site to get the login data for. 89 | * 90 | * @return array|null 91 | */ 92 | private static function get_login_data( string $site_id ): ?array { 93 | static $cache = array(); 94 | 95 | if ( ! isset( $cache[ $site_id ] ) ) { 96 | $collaborator = get_pressable_site_sftp_user_by_email( $site_id, 'concierge@wordpress.com' ); 97 | if ( \is_null( $collaborator ) ) { 98 | console_writeln( '❌ Could not find the Pressable site collaborator.' ); 99 | return null; 100 | } 101 | 102 | $cache[ $site_id ] = array( 103 | 'username' => $collaborator->username, 104 | 'password' => reset_pressable_site_sftp_user_password( $site_id, $collaborator->username ), 105 | ); 106 | } 107 | 108 | return $cache[ $site_id ]; 109 | } 110 | 111 | // endregion 112 | } 113 | -------------------------------------------------------------------------------- /src/commands/plugin-list-full-dump.php: -------------------------------------------------------------------------------- 1 | api_helper = new API_Helper(); 23 | } 24 | 25 | protected function configure() { 26 | $this 27 | ->setDescription( 'Dumps a CSV of all plugins on on all t51 sites, including activation status' ); 28 | } 29 | 30 | protected function execute( InputInterface $input, OutputInterface $output ) { 31 | 32 | $api_helper = new API_Helper(); 33 | 34 | $output->writeln( 'Fetching production sites connected to a8cteam51...' ); 35 | 36 | // Fetching sites connected to a8cteam51 37 | $sites = $api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/', array() ); 38 | 39 | if ( empty( $sites->blogs->blogs ) ) { 40 | $output->writeln( 'Failed to fetch sites.' ); 41 | exit; 42 | } 43 | 44 | // Filter out non-production sites 45 | $deny_list = array( 46 | 'mystagingwebsite.com', 47 | 'go-vip.co', 48 | 'wpcomstaging.com', 49 | 'wpengine.com', 50 | 'jurassic.ninja', 51 | 'woocommerce.com', 52 | 'atomicsites.blog', 53 | ); 54 | 55 | foreach ( $sites->blogs->blogs as $site ) { 56 | $matches = false; 57 | foreach ( $deny_list as $deny ) { 58 | if ( strpos( $site->siteurl, $deny ) !== false ) { 59 | $matches = true; 60 | break; 61 | } 62 | } 63 | if ( ! $matches ) { 64 | $site_list[] = array( 65 | 'blog_id' => $site->userblog_id, 66 | 'site_url' => $site->siteurl, 67 | ); 68 | } 69 | } 70 | 71 | $site_count = count( $site_list ); 72 | 73 | if ( empty( $site_count ) ) { 74 | $output->writeln( 'No production sites found.' ); 75 | exit; 76 | } 77 | 78 | $output->writeln( "{$site_count} sites found." ); 79 | 80 | // Get plugin lists from Jetpack profile data 81 | $output->writeln( 'Getting plugins from each site...' ); 82 | $jetpack_sites_plugins = $api_helper->call_wpcom_api( 'rest/v1.1/me/sites/plugins/', array() ); 83 | 84 | foreach ( $site_list as $site ) { 85 | if ( ! empty( $jetpack_sites_plugins->sites->{$site['blog_id']} ) ) { 86 | foreach ( $jetpack_sites_plugins->sites->{$site['blog_id']} as $site_plugin ) { 87 | $plugins_on_t51_sites[] = array( 88 | $site['site_url'], 89 | $site['blog_id'], 90 | $site_plugin->slug, 91 | $site_plugin->active, 92 | $site_plugin->version, 93 | ); 94 | } 95 | } 96 | } 97 | 98 | // make a csv out of the $plugins_on_t51_sites array 99 | $output->writeln( 'Making the CSV...' ); 100 | $timestamp = date( 'Y-m-d-H-i-s' ); 101 | $fp = fopen( 'plugins-on-t51-sites-' . $timestamp . '.csv', 'w' ); 102 | fputcsv( $fp, array( 'Site URL', 'Blog ID', 'Plugin Slug', 'Active', 'Version' ) ); 103 | foreach ( $plugins_on_t51_sites as $fields ) { 104 | fputcsv( $fp, $fields ); 105 | } 106 | fclose( $fp ); 107 | 108 | $output->writeln( 'Done, CSV saved to your current working directory: plugins-on-t51-sites-' . $timestamp . '.csv' ); 109 | } 110 | 111 | // Helper functions, getting list of plugins and getting woocommerce stats 112 | private function get_list_of_plugins( $site_id ) { 113 | $plugin_list = $this->api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/' . $site_id . '/rest-api/?path=/jetpack/v4/plugins', array() ); 114 | return $plugin_list; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/commands/wpcom-remove-sticker.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Remove the specified sticker on the site.' ) 44 | ->setHelp( 'This command allows you remove a sticker to a site given a site ID or URL.' ) 45 | ->addArgument( 'site', InputArgument::REQUIRED, 'ID or URL of the site to remove a sticker.' ) 46 | ->addArgument( 'sticker', InputArgument::REQUIRED, 'Sticker to remove.' ); 47 | } 48 | 49 | /** 50 | * {@inheritDoc} 51 | */ 52 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 53 | maybe_define_console_verbosity( $output->getVerbosity() ); 54 | 55 | $this->wpcom_site = get_wpcom_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); 56 | 57 | if ( \is_null( $this->wpcom_site ) ) { 58 | exit( 1 ); // Exit if the site does not exist. 59 | } 60 | 61 | // Store the ID of the site in the argument field. 62 | $input->setArgument( 'site', $this->wpcom_site->ID ); 63 | 64 | $this->sticker = get_string_input( $input, $output, 'sticker', fn() => $this->prompt_sticker_input( $input, $output ) ); 65 | 66 | if ( empty( $this->sticker ) ) { 67 | $output->writeln( 'Sticker not provided.' ); 68 | exit( 1 ); 69 | } 70 | 71 | $input->setArgument( 'sticker', $this->sticker ); 72 | } 73 | 74 | /** 75 | * {@inheritDoc} 76 | */ 77 | protected function execute( InputInterface $input, OutputInterface $output ) { 78 | $response = WPCOM_API_Helper::call_api( 79 | sprintf( WPCOM_REMOVE_STICKER_URL, $this->wpcom_site->ID, $this->sticker ), 80 | 'POST' 81 | ); 82 | 83 | if ( is_object( $response ) && $response->success ) { 84 | $output->writeln( "Successfully removed `{$this->sticker}` on site `{$this->wpcom_site->ID}`" ); 85 | } else { 86 | $output->writeln( "Couldn't remove `{$this->sticker}` on site `{$this->wpcom_site->ID}`" ); 87 | } 88 | } 89 | 90 | /** 91 | * Prompts the user for a site if in interactive mode. 92 | * 93 | * @param InputInterface $input The input object. 94 | * @param OutputInterface $output The output object. 95 | * 96 | * @return string|null 97 | */ 98 | private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { 99 | if ( $input->isInteractive() ) { 100 | $question = new Question( 'Enter the site ID or URL: ' ); 101 | $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); 102 | } 103 | 104 | return $site ?? null; 105 | } 106 | 107 | /** 108 | * Prompts the user for a sticker if in interactive mode. 109 | * 110 | * @param InputInterface $input The input object. 111 | * @param OutputInterface $output The output object. 112 | * 113 | * @return string|null 114 | */ 115 | private function prompt_sticker_input( InputInterface $input, OutputInterface $output ): ?string { 116 | if ( $input->isInteractive() ) { 117 | $question = new Question( 'Enter the sticker to remove: ' ); 118 | $sticker = $this->getHelper( 'question' )->ask( $input, $output, $question ); 119 | } 120 | 121 | return $sticker ?? null; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/commands/wpcom-add-sticker.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Add the specified sticker to the site.' ) 44 | ->setHelp( 'This command allows you add a blog sticker to a site given a site ID or URL.' ) 45 | ->addArgument( 'site', InputArgument::REQUIRED, 'ID or URL of the site to add a sticker.' ) 46 | ->addArgument( 'sticker', InputArgument::REQUIRED, 'Sticker to add to the site.' ); 47 | } 48 | 49 | /** 50 | * {@inheritDoc} 51 | */ 52 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 53 | maybe_define_console_verbosity( $output->getVerbosity() ); 54 | 55 | $this->wpcom_site = get_wpcom_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); 56 | 57 | if ( \is_null( $this->wpcom_site ) ) { 58 | exit( 1 ); // Exit if the site does not exist. 59 | } 60 | 61 | // Store the ID of the site in the argument field. 62 | $input->setArgument( 'site', $this->wpcom_site->ID ); 63 | 64 | $this->sticker = get_string_input( $input, $output, 'sticker', fn() => $this->prompt_sticker_input( $input, $output ) ); 65 | 66 | if ( empty( $this->sticker ) ) { 67 | $output->writeln( 'Sticker not provided.' ); 68 | exit( 1 ); 69 | } 70 | 71 | $input->setArgument( 'sticker', $this->sticker ); 72 | } 73 | 74 | /** 75 | * {@inheritDoc} 76 | */ 77 | protected function execute( InputInterface $input, OutputInterface $output ) { 78 | $response = WPCOM_API_Helper::call_api( 79 | sprintf( WPCOM_ADD_STICKER_URL, $this->wpcom_site->ID, $this->sticker ), 80 | 'POST' 81 | ); 82 | 83 | if ( is_object( $response ) && $response->success ) { 84 | $output->writeln( "Successfully added `{$this->sticker}` on site `{$this->wpcom_site->ID}`" ); 85 | } else { 86 | $output->writeln( "Couldn't add `{$this->sticker}` on site `{$this->wpcom_site->ID}`" ); 87 | } 88 | } 89 | 90 | /** 91 | * Prompts the user for a site if in interactive mode. 92 | * 93 | * @param InputInterface $input The input object. 94 | * @param OutputInterface $output The output object. 95 | * 96 | * @return string|null 97 | */ 98 | private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { 99 | if ( $input->isInteractive() ) { 100 | $question = new Question( 'Enter the site ID or URL to add a sticker: ' ); 101 | $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); 102 | } 103 | 104 | return $site ?? null; 105 | } 106 | 107 | /** 108 | * Prompts the user for a sticker if in interactive mode. 109 | * 110 | * @param InputInterface $input The input object. 111 | * @param OutputInterface $output The output object. 112 | * 113 | * @return string|null 114 | */ 115 | private function prompt_sticker_input( InputInterface $input, OutputInterface $output ): ?string { 116 | if ( $input->isInteractive() ) { 117 | $question = new Question( 'Enter the sticker to add to the site: ' ); 118 | $sticker = $this->getHelper( 'question' )->ask( $input, $output, $question ); 119 | } 120 | 121 | return $sticker ?? null; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/helpers/deployhq-functions.php: -------------------------------------------------------------------------------- 1 | permalink ) ) { 33 | return null; 34 | } 35 | 36 | return $project; 37 | } 38 | 39 | /** 40 | * Gets a list of all servers which are configured for a given project. 41 | * 42 | * @param string $project_permalink The permalink of the project. 43 | * 44 | * @link https://www.deployhq.com/support/api/servers/listing-all-servers 45 | * 46 | * @return object[]|null 47 | */ 48 | function get_deployhq_project_servers( string $project_permalink ): ?array { 49 | $servers = DeployHQ_API_Helper::call_api( "projects/$project_permalink/servers" ); 50 | if ( empty( $servers ) ) { 51 | return null; 52 | } 53 | 54 | return $servers; 55 | } 56 | 57 | /** 58 | * Returns what the DeployHQ project permalink should be for a given Pressable site. 59 | * 60 | * @param object $pressable_site The site object. 61 | * 62 | * @see get_pressable_sites() 63 | * @see get_pressable_site_by_id() 64 | * 65 | * @return string|null 66 | */ 67 | function get_deployhq_project_permalink_from_pressable_site( object $pressable_site ): ?string { 68 | if ( ! \property_exists( $pressable_site, 'name' ) || empty( $pressable_site->name ) ) { 69 | return null; 70 | } 71 | 72 | $project_name = $pressable_site->name; 73 | if ( false !== \strpos( $project_name, '-development' ) ) { 74 | // Handles the case where the project name is "project-development-". 75 | $project_name = \explode( '-development', $project_name, 2 )[0]; 76 | } elseif ( ! empty( $pressable_site->clonedFromId ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 77 | // Handles the legacy case where a labelled temporary clone is missing the "-development" substring. 78 | $pressable_site = get_pressable_site_by_id( $pressable_site->clonedFromId ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 79 | if ( true !== \is_null( $pressable_site ) ) { 80 | // Safeguard against a deleted site (discovered in the wild during testing). 81 | $project_name = get_deployhq_project_permalink_from_pressable_site( $pressable_site ); 82 | } 83 | } elseif ( false !== \strpos( $pressable_site->displayName, '-development' ) ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 84 | // Handles the legacy case where a labelled temporary clone is missing the "-development" substring 85 | // and no 'clonedFromId' is available for some reason. For this to work, the display name of the clone 86 | // must be updated MANUALLY to include the "-development" substring. 87 | $project_name = \explode( '-development', $pressable_site->displayName, 2 )[0]; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 88 | } 89 | 90 | return \explode( '-production', $project_name, 2 )[0]; 91 | } 92 | 93 | /** 94 | * Updates the given server's configuration and returns the updated server. 95 | * 96 | * @param string $project_permalink The permalink of the project. 97 | * @param string $server_id The identifier of the server. 98 | * @param array $params The parameters to update. 99 | * 100 | * @link https://www.deployhq.com/support/api/servers/edit-an-existing-server 101 | * 102 | * @return object|null 103 | */ 104 | function update_deployhq_project_server( string $project_permalink, string $server_id, array $params ): ?object { 105 | if ( ! isset( $params['server'] ) ) { // Quirk of the API. All changes must be sub-nested under 'server' for some inexplicable reason. 106 | $params = array( 107 | 'server' => $params, 108 | ); 109 | } 110 | 111 | $response = DeployHQ_API_Helper::call_api( "projects/$project_permalink/servers/$server_id", 'PUT', $params ); 112 | if ( empty( $response ) ) { 113 | return null; 114 | } 115 | 116 | return $response; 117 | } 118 | -------------------------------------------------------------------------------- /src/commands/pressable-generate-oauth-token.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Generates a Pressable OAuth refresh token for a given API application.' ) 49 | ->setHelp( 'This command requires a Pressable API application client ID and client secret, which it uses to generate a refresh token that outside collaborators can use to gain access to the Pressable API via this CLI tool.' ); 50 | 51 | $this->addOption( 'client-id', null, InputOption::VALUE_REQUIRED, 'The Pressable API application client ID.' ) 52 | ->addOption( 'client-secret', null, InputOption::VALUE_REQUIRED, 'The Pressable API application client secret.' ); 53 | } 54 | 55 | /** 56 | * {@inheritDoc} 57 | */ 58 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 59 | maybe_define_console_verbosity( $output->getVerbosity() ); 60 | 61 | $this->client_id = get_string_input( $input, $output, 'client-id', fn() => $this->prompt_client_id_input( $input, $output ) ); 62 | $this->client_secret = get_string_input( $input, $output, 'client-secret', fn() => $this->prompt_client_secret_input( $input, $output ) ); 63 | } 64 | 65 | /** 66 | * {@inheritDoc} 67 | */ 68 | protected function execute( InputInterface $input, OutputInterface $output ): int { 69 | $output->writeln( "Generating a new OAuth refresh token for client ID $this->client_id." ); 70 | 71 | $api_tokens = Pressable_API_Helper::call_auth_api( $this->client_id, $this->client_secret ); 72 | 73 | $output->writeln( 'Please provide the following output to the outside collaborator. These lines should be placed in their config__contractors.json file:' . PHP_EOL ); 74 | $output->writeln( 75 | encode_json_content( 76 | array( 77 | 'api_app_client_id' => $this->client_id, 78 | 'api_app_client_secret' => $this->client_secret, 79 | 'api_refresh_token' => $api_tokens->refresh_token, 80 | ), 81 | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE 82 | ) . PHP_EOL 83 | ); 84 | 85 | return 0; 86 | } 87 | 88 | // endregion 89 | 90 | // region HELPERS 91 | 92 | /** 93 | * Prompts the user for a client ID and returns the value. 94 | * 95 | * @param InputInterface $input The input object. 96 | * @param OutputInterface $output The output object. 97 | * 98 | * @return string|null 99 | */ 100 | private function prompt_client_id_input( InputInterface $input, OutputInterface $output ): ?string { 101 | if ( $input->isInteractive() ) { 102 | $question = new Question( 'Enter the Pressable API application client ID: ' ); 103 | $client_id = $this->getHelper( 'question' )->ask( $input, $output, $question ); 104 | } 105 | 106 | return $client_id ?? null; 107 | } 108 | 109 | /** 110 | * Prompts the user for a client secret and returns the value. 111 | * 112 | * @param InputInterface $input The input object. 113 | * @param OutputInterface $output The output object. 114 | * 115 | * @return string|null 116 | */ 117 | private function prompt_client_secret_input( InputInterface $input, OutputInterface $output ): ?string { 118 | if ( $input->isInteractive() ) { 119 | $question = new Question( 'Enter the Pressable API application client secret: ' ); 120 | $client_secret = $this->getHelper( 'question' )->ask( $input, $output, $question ); 121 | } 122 | 123 | return $client_secret ?? null; 124 | } 125 | 126 | // endregion 127 | } 128 | -------------------------------------------------------------------------------- /src/commands/triage-graphql.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Generates a Digest Post of what upcoming Triage issues we have.' ) 19 | ->setHelp( 'Scans the triage column to find due dates in the near future.' ); 20 | } 21 | 22 | protected function execute( InputInterface $input, OutputInterface $output ) { 23 | if ( ! defined( 'GITHUB_DEVQUEUE_PROJECT_ID' ) ) { 24 | $output->writeln( 'GITHUB_DEVQUEUE_PROJECT_ID not set in config.' ); 25 | return; 26 | } 27 | 28 | $api_helper = new API_Helper(); 29 | 30 | $output->writeln( 'Grabbing Triage Column Items...' ); 31 | 32 | $response = $api_helper->call_github_graphql_api( $this->query( GITHUB_DEVQUEUE_PROJECT_ID ) ); 33 | $all_items = array( $response ); 34 | 35 | while ( false !== $response->data->node->items->pageInfo->hasNextPage ) { 36 | $response = $api_helper->call_github_graphql_api( 37 | $this->query( GITHUB_DEVQUEUE_PROJECT_ID, $response->data->node->items->pageInfo->endCursor ) 38 | ); 39 | 40 | $all_items[] = $response; 41 | } 42 | 43 | // Merge all nodes from each response. 44 | $nodes = array_merge( ...array_map( fn ( $response ) => $response->data->node->items->nodes, $all_items ) ); 45 | 46 | $triage_nodes = array_filter( $nodes, fn ( $node ) => array_search( self::TRIAGE_STATUS, array_column( $node->fieldValues->nodes, 'name' ) ) !== false ); 47 | $progress_nodes = array_filter( $nodes, fn ( $node ) => array_search( self::IN_PROGRESS_STATUS, array_column( $node->fieldValues->nodes, 'name' ) ) !== false ); 48 | $feedback_nodes = array_filter( $nodes, fn ( $node ) => array_search( self::WAITING_FEEDBACK_STATUS, array_column( $node->fieldValues->nodes, 'name' ) ) !== false ); 49 | 50 | $output->writeln( sprintf( '"Triage" currently has %d cards.', count( $triage_nodes ) ) ); 51 | $output->writeln( sprintf( '"In Progress" currently has %d cards.', count( $progress_nodes ) ) ); 52 | $output->writeln( sprintf( '"Waiting Feedback" currently has %d cards.', count( $feedback_nodes ) ) ); 53 | 54 | $issues = array(); 55 | 56 | foreach ( $triage_nodes as $node ) { 57 | $due_date = array_column( $node->fieldValues->nodes, 'date' )[0] ?? 9999; 58 | 59 | $issues[] = array( 60 | 'due_in' => $due_date === 9999 ? $due_date : ( strtotime( $due_date ) - strtotime( 'today' ) ) / ( 24 * 60 * 60 ), 61 | 'title' => $node->content->title, 62 | 'number' => $node->content->number, 63 | 'url' => $node->content->url, 64 | 'labels' => array_column( $node->content->labels->nodes, 'name' ), 65 | ); 66 | } 67 | 68 | $output->writeln( '' ); 69 | $output->writeln( '' ); 70 | 71 | usort( $issues, fn ( $a, $b ) => $a['due_in'] - $b['due_in'] ); 72 | 73 | foreach ( $issues as $issue ) { 74 | $type = 'comment'; 75 | if ( $issue['due_in'] <= 0 ) { 76 | $type = 'error'; 77 | } 78 | 79 | switch ( $issue['due_in'] ) { 80 | case -1: 81 | $how_long = 'Due YESTERDAY!'; 82 | break; 83 | case '0': 84 | $how_long = 'Due TODAY'; 85 | break; 86 | case '1': 87 | $how_long = 'Due Tomorrow'; 88 | break; 89 | case 9999: 90 | $how_long = 'No Due Date Specified'; 91 | break; 92 | default: 93 | $how_long = "Due in {$issue['due_in']} days"; 94 | break; 95 | } 96 | 97 | $tags = ''; 98 | if ( sizeof( $issue['labels'] ) ) { 99 | foreach ( $issue['labels'] as $tag_name ) { 100 | $tags .= "*{$tag_name}* "; 101 | } 102 | } 103 | 104 | $output->writeln( 105 | sprintf( 106 | '<%1$s>* %2$d: %6$s[%3$s](%5$s) (%4$s)', 107 | $type, 108 | $issue['number'], 109 | $issue['title'], 110 | $how_long, 111 | $issue['url'], 112 | $tags 113 | ) 114 | ); 115 | } 116 | } 117 | 118 | private function query( string $project_id, string $after = '', int $per_page = 100 ) { 119 | $query = 'query { 120 | node(id: "%s") { 121 | ... on ProjectV2 { 122 | items(first: %d, after: "%s") { 123 | totalCount 124 | pageInfo { 125 | hasNextPage 126 | hasPreviousPage 127 | endCursor 128 | startCursor 129 | } 130 | nodes { 131 | fieldValues(first: 10) { 132 | nodes { 133 | ... on ProjectV2ItemFieldDateValue { 134 | date 135 | } 136 | ... on ProjectV2ItemFieldSingleSelectValue { 137 | name 138 | field { 139 | ... on ProjectV2FieldCommon { 140 | name 141 | } 142 | } 143 | } 144 | } 145 | } 146 | content { 147 | ... on Issue { 148 | title 149 | number 150 | url 151 | labels(first: 10) { 152 | nodes { 153 | name 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | } 161 | } 162 | }'; 163 | 164 | return sprintf( $query, $project_id, $per_page, $after ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/commands/jetpack-sites-with.php: -------------------------------------------------------------------------------- 1 | api_helper = new API_Helper(); 26 | } 27 | 28 | protected function configure() { 29 | $this 30 | ->setDescription( 'Searches Team 51 sites for a specified Jetpack module based on its status.' ) 31 | ->setHelp( 'Use this command to find and show a list of sites where a particular Jetpack module is on or off. This command requires a Jetpack site connected to the a8cteam51 account. Usage Example: team51 jetpack-sites-with adwords on' ) 32 | ->addArgument( 'module-slug', InputArgument::REQUIRED, 'The slug of the Jetpack module to search for.' ) 33 | ->addArgument( 'module-status', InputArgument::OPTIONAL, 'The status of the Jetpack module to search for.' ); 34 | } 35 | 36 | protected function execute( InputInterface $input, OutputInterface $output ) { 37 | $module_slug = strtolower( $input->getArgument( 'module-slug' ) ); 38 | $module_status = strtolower( $input->getArgument( 'module-status' ) ); 39 | $validation_run = true; 40 | 41 | if ( ! in_array( $module_status, array( 'on', 'off' ), true ) ) { 42 | $output->writeln( 'module-status should be "on" or "off" only. Aborting.' ); 43 | exit; 44 | } 45 | 46 | $api_helper = new API_Helper(); 47 | 48 | $output->writeln( 'Fetching list of sites...' ); 49 | 50 | $sites = $api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/', array() ); 51 | 52 | if ( empty( $sites ) ) { 53 | $output->writeln( 'Failed to fetch sites.' ); 54 | exit; 55 | } 56 | 57 | $site_list = array(); 58 | foreach ( $sites->blogs->blogs as $site ) { 59 | $site_list[] = array( $site->userblog_id, $site->domain ); 60 | } 61 | $site_count = count( $site_list ); 62 | 63 | $output->writeln( "{$site_count} sites found." ); 64 | $output->writeln( "Checking each site for the Jetpack module: {$module_slug}" ); 65 | $output->writeln( 'Expected duration: 8 - 10 mins. Use Ctrl+C to abort.' ); 66 | 67 | $progress_bar = new ProgressBar( $output, $site_count ); 68 | $progress_bar->start(); 69 | 70 | $module_match_status = array(); 71 | $sites_not_checked = array(); 72 | foreach ( $site_list as $site ) { 73 | $progress_bar->advance(); 74 | $module_list = $this->get_list_of_modules( $site[0] ); 75 | if ( ! is_null( $module_list ) ) { 76 | if ( ! is_null( $module_list->data ) ) { 77 | if ( $validation_run ) { 78 | $all_modules = array(); 79 | foreach ( $module_list->data as $module ) { 80 | $all_modules[] = array( $module->module, $module->name ); 81 | } 82 | if ( ! in_array( $module_slug, array_column( $all_modules, 0 ), true ) ) { 83 | $output->writeln( ' Oooops!' ); 84 | $output->writeln( "Jetpack module slug \"{$module_slug}\" unknown." ); 85 | $output->writeln( 'Available slugs:' ); 86 | $modules_table = new Table( $output ); 87 | $modules_table->setStyle( 'box-double' ); 88 | $modules_table->setHeaders( array( 'slug', 'Name' ) ); 89 | $modules_table->setRows( $all_modules ); 90 | $modules_table->render(); 91 | exit; 92 | } else { 93 | $validation_run = false; 94 | } 95 | } 96 | 97 | foreach ( $module_list->data as $module ) { 98 | if ( $module_slug === $module->module ) { 99 | $module_match_status[] = array( $site[1], ( $module->activated ? 'on' : 'off' ) ); 100 | } 101 | } 102 | } 103 | } else { 104 | $sites_not_checked[] = array( $site[1], $site[0] ); 105 | } 106 | } 107 | $progress_bar->finish(); 108 | 109 | $sites_with_module = array_filter( 110 | $module_match_status, 111 | function ( $site ) use ( $module_status ) { 112 | return $module_status === $site[1]; 113 | } 114 | ); 115 | 116 | $output->writeln( ' Yay!' ); 117 | $output->writeln( "Sites with the Jetpack module \"{$module_slug}\" turned \"{$module_status}\":" ); 118 | 119 | $site_table = new Table( $output ); 120 | $site_table->setStyle( 'box-double' ); 121 | $site_table->setHeaders( array( 'Site URL', 'Module Status' ) ); 122 | $site_table->setRows( $sites_with_module ); 123 | $site_table->render(); 124 | 125 | $output->writeln( 'Ignored sites - either not a Jetpack connected site, or the connection is broken:' ); 126 | $not_found_table = new Table( $output ); 127 | $not_found_table->setStyle( 'box-double' ); 128 | $not_found_table->setHeaders( array( 'Site URL', 'Site ID' ) ); 129 | $not_found_table->setRows( $sites_not_checked ); 130 | $not_found_table->render(); 131 | 132 | $output->writeln( 'All done! :)' ); 133 | } 134 | 135 | private function get_list_of_modules( $site_id ) { 136 | $module_list = $this->api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/' . $site_id . '/rest-api/?path=/jetpack/v4/module/all', array() ); 137 | if ( ! empty( $module_list->error ) ) { 138 | $module_list = null; 139 | } 140 | return $module_list; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/helpers/wpcom-functions.php: -------------------------------------------------------------------------------- 1 | message ); 26 | return null; 27 | } 28 | 29 | return \array_combine( 30 | \array_column( $sites->sites, 'ID' ), 31 | $sites->sites 32 | ); 33 | } 34 | 35 | /** 36 | * Get the list of active, Jetpack-enabled WordPress.com sites for the a8cteam51 account. This includes WPORG sites with an active 37 | * Jetpack connection, but excludes things like WPCOM Simple sites, P2 blogs, and sites subscribed to. 38 | * 39 | * @return object[]|null 40 | */ 41 | function get_wpcom_jetpack_sites(): ?array { 42 | $sites = WPCOM_API_Helper::call_api( 'jetpack-blogs' ); 43 | if ( \is_null( $sites ) || ! \property_exists( $sites, 'success' ) || ! $sites->success ) { 44 | return null; 45 | } 46 | 47 | return \array_combine( 48 | \array_column( $sites->blogs->blogs, 'userblog_id' ), 49 | $sites->blogs->blogs 50 | ); 51 | } 52 | 53 | /** 54 | * Gets the WordPress.com site information by site URL or WordPress.com ID (requires active Jetpack connection for WPORG sites). 55 | * 56 | * @param string $site_id_or_url The site URL or WordPress.com site ID. 57 | * 58 | * @return object|null 59 | */ 60 | function get_wpcom_site( string $site_id_or_url ): ?object { 61 | $site = WPCOM_API_Helper::call_api( "sites/$site_id_or_url" ); 62 | if ( empty( $site ) ) { 63 | return null; 64 | } 65 | 66 | return $site; 67 | } 68 | 69 | /** 70 | * Gets the list of users for a site by site URL or WordPress.com ID (requires active Jetpack connection for WPORG sites). 71 | * 72 | * @param string $site_id_or_url The site URL or WordPress.com site ID. 73 | * @param array $params Optional. Additional parameters to pass to the API call. 74 | * 75 | * @link https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/users/ 76 | * 77 | * @return array|null 78 | */ 79 | function get_wpcom_site_users( string $site_id_or_url, array $params = array() ): ?array { 80 | $users = WPCOM_API_Helper::call_api( "sites/$site_id_or_url/users?" . \http_build_query( $params ) ); 81 | if ( empty( $users ) ) { 82 | return null; 83 | } 84 | 85 | return $users->users; 86 | } 87 | 88 | /** 89 | * Gets a site user for a site by their login email address (requires active Jetpack connection for WPORG sites). 90 | * 91 | * @param string $site_id_or_url The site URL or WordPress.com site ID. 92 | * @param string $email The email address of the user. 93 | * 94 | * @return object|null 95 | */ 96 | function get_wpcom_site_user_by_email( string $site_id_or_url, string $email ): ?object { 97 | $users = get_wpcom_site_users( $site_id_or_url ); 98 | if ( empty( $users ) ) { 99 | return null; 100 | } 101 | 102 | foreach ( $users as $user ) { 103 | if ( true === is_case_insensitive_match( $email, $user->email ) ) { 104 | return $user; 105 | } 106 | } 107 | 108 | return null; 109 | } 110 | 111 | /** 112 | * Resets a given user's password on a site using the Jetpack API. 113 | * 114 | * @param string $site_id_or_url The site URL or WordPress.com site ID. 115 | * @param string $user_id The WP user ID. 116 | * @param string $new_password The new password to set. 117 | * 118 | * @return bool|null 119 | */ 120 | function set_wpcom_site_user_wp_password( string $site_id_or_url, string $user_id, string $new_password ): ?bool { 121 | $result = WPCOM_API_Helper::call_site_api( $site_id_or_url, "/wp/v2/users/$user_id", array( 'password' => $new_password ) ); 122 | if ( empty( $result ) ) { 123 | return false; 124 | } 125 | 126 | return true; 127 | } 128 | 129 | /** 130 | * Grabs a value from the console input and tries to retrieve a WPCOM site based on it. 131 | * 132 | * @param InputInterface $input The console input. 133 | * @param OutputInterface $output The console output. 134 | * @param callable|null $no_input_func The function to call if no input is given. 135 | * @param string $name The name of the value to grab. 136 | * 137 | * @return object|null 138 | */ 139 | function get_wpcom_site_from_input( InputInterface $input, OutputInterface $output, ?callable $no_input_func = null, string $name = 'site' ): ?object { 140 | $site_id_or_url = get_site_input( $input, $output, $no_input_func, $name ); 141 | $wpcom_site = get_wpcom_site( $site_id_or_url ); 142 | 143 | if ( \is_null( $wpcom_site ) ) { 144 | $output->writeln( "WPCOM site $site_id_or_url not found." ); 145 | } else { 146 | $output->writeln( "WPCOM site found: $wpcom_site->name (ID $wpcom_site->ID, URL $wpcom_site->URL).", OutputInterface::VERBOSITY_VERY_VERBOSE ); 147 | } 148 | 149 | return $wpcom_site; 150 | } 151 | -------------------------------------------------------------------------------- /src/commands/pressable-site-run-wp-cli-command.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Runs a given WP-CLI command on a given Pressable site.' ) 51 | ->setHelp( 'This command allows you to run an arbitrary WP-CLI command on a Pressable site.' ); 52 | 53 | $this->addArgument( 'site', InputArgument::REQUIRED, 'ID or URL of the site to run the command on.' ) 54 | ->addArgument( 'wp-cli-command', InputArgument::REQUIRED, 'The WP-CLI command to run.' ); 55 | } 56 | 57 | /** 58 | * {@inheritDoc} 59 | */ 60 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 61 | maybe_define_console_verbosity( $output->getVerbosity() ); 62 | 63 | // Retrieve the given site. 64 | $this->pressable_site = get_pressable_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); 65 | if ( \is_null( $this->pressable_site ) ) { 66 | exit( 1 ); // Exit if the site does not exist. 67 | } 68 | 69 | // Store the ID of the site in the argument field. 70 | $input->setArgument( 'site', $this->pressable_site->id ); 71 | 72 | // Retrieve the given command. 73 | $this->wp_command = get_string_input( $input, $output, 'wp-cli-command', fn() => $this->prompt_command_input( $input, $output ) ); 74 | if ( empty( $this->wp_command ) ) { // Also checks for empty string not just null. 75 | $output->writeln( 'WP-CLI command not provided.' ); 76 | exit( 1 ); // Exit if the WP-CLI command does not exist. 77 | } 78 | 79 | // Store the command in the argument field. 80 | $this->wp_command = \escapeshellcmd( \trim( \preg_replace( '/^wp/', '', \trim( $this->wp_command ) ) ) ); 81 | $input->setArgument( 'wp-cli-command', $this->wp_command ); 82 | } 83 | 84 | /** 85 | * {@inheritDoc} 86 | */ 87 | protected function interact( InputInterface $input, OutputInterface $output ): void { 88 | $question = new ConfirmationQuestion( "Are you sure you want to run the command `wp $this->wp_command` on {$this->pressable_site->displayName} (ID {$this->pressable_site->id}, URL {$this->pressable_site->url})? [y/N] ", false ); 89 | if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { 90 | $output->writeln( 'Command aborted by user.' ); 91 | exit( 2 ); 92 | } 93 | } 94 | 95 | /** 96 | * {@inheritDoc} 97 | */ 98 | protected function execute( InputInterface $input, OutputInterface $output ): int { 99 | $output->writeln( "Running the command `wp $this->wp_command` on {$this->pressable_site->displayName} (ID {$this->pressable_site->id}, URL {$this->pressable_site->url})." ); 100 | 101 | $ssh = Pressable_Connection_Helper::get_ssh_connection( $this->pressable_site->id ); 102 | if ( \is_null( $ssh ) ) { 103 | $output->writeln( 'Could not connect to the SSH server.' ); 104 | return 1; 105 | } 106 | 107 | $output->writeln( 'SSH connection established.', OutputInterface::VERBOSITY_VERBOSE ); 108 | 109 | $ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time. 110 | $ssh->exec( 111 | "wp $this->wp_command", 112 | static function( string $str ): void { 113 | echo $str; 114 | } 115 | ); 116 | 117 | return 0; 118 | } 119 | 120 | // endregion 121 | 122 | // region HELPERS 123 | 124 | /** 125 | * Prompts the user for a site if in interactive mode. 126 | * 127 | * @param InputInterface $input The input object. 128 | * @param OutputInterface $output The output object. 129 | * 130 | * @return string|null 131 | */ 132 | private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { 133 | if ( $input->isInteractive() ) { 134 | $question = new Question( 'Enter the site ID or URL to run the WP-CLI command on: ' ); 135 | $question->setAutocompleterValues( \array_map( static fn( object $site ) => $site->url, get_pressable_sites() ?? array() ) ); 136 | 137 | $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); 138 | } 139 | 140 | return $site ?? null; 141 | } 142 | 143 | /** 144 | * Prompts the user for a WP-CLI command if in interactive mode. 145 | * 146 | * @param InputInterface $input The input object. 147 | * @param OutputInterface $output The output object. 148 | * 149 | * @return string|null 150 | */ 151 | private function prompt_command_input( InputInterface $input, OutputInterface $output ): ?string { 152 | if ( $input->isInteractive() ) { 153 | $question = new Question( 'Enter the WP-CLI command to run: ' ); 154 | $command = $this->getHelper( 'question' )->ask( $input, $output, $question ); 155 | } 156 | 157 | return $command ?? null; 158 | } 159 | 160 | // endregion 161 | } 162 | -------------------------------------------------------------------------------- /src/commands/pressable-call-api.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Calls the Pressable API directly' ) 24 | ->setHelp( 'Refer to the API docs for more details: https://my.pressable.com/documentation/api/v1' ) 25 | ->addOption( 'query', null, InputOption::VALUE_REQUIRED, 'The query string for the request. This is everything after "https://my.pressable.com/v1/" in URL. (e.g., "sites/1234"' ) 26 | ->addOption( 'method', null, InputOption::VALUE_OPTIONAL, 'The query type (GET, POST, etc.). Default is GET.' ) 27 | ->addOption( 'data', null, InputOption::VALUE_OPTIONAL, 'A JSON string of the data to pass on. (e.g.: {"paginate":true}' ) 28 | ->addOption( 'format', null, InputOption::VALUE_OPTIONAL, 'The format of the response output (text, json). Default is json. "text" will dump using the print_r() function.', 'json' ); 29 | } 30 | 31 | /** 32 | * Access to the QuestionHelper 33 | * 34 | * @return \Symfony\Component\Console\Helper\QuestionHelper 35 | */ 36 | private function get_question_helper(): QuestionHelper { 37 | return $this->getHelper( 'question' ); 38 | } 39 | 40 | 41 | /** 42 | * Main callback for the command. 43 | * 44 | * @param \Symfony\Component\Console\Input\InputInterface $input 45 | * @param \Symfony\Component\Console\Output\OutputInterface $output 46 | * @return void 47 | */ 48 | protected function execute( InputInterface $input, OutputInterface $output ) { 49 | $this->api_helper = new API_Helper(); 50 | $this->output = $output; 51 | 52 | if ( ! in_array( $input->getOption( 'format' ), array( 'text', 'json' ), true ) ) { 53 | $this->output->writeln( 'Invalid output format' ); 54 | return; 55 | } 56 | 57 | $this->format = $input->getOption( 'format' ); 58 | 59 | $this->handle_api_call( $input, $output ); 60 | 61 | $output->writeln( "\nAll done!" ); 62 | } 63 | 64 | /*********************************** 65 | *********************************** 66 | * INPUT GETTERS * 67 | *********************************** 68 | ***********************************/ 69 | 70 | /** 71 | * Gets the query string. 72 | * 73 | * @param \Symfony\Component\Console\Input\InputInterface $input 74 | * @param \Symfony\Component\Console\Output\OutputInterface $output 75 | * @return string 76 | */ 77 | private function get_query( InputInterface $input, OutputInterface $output ): string { 78 | // Attempt to get the query from the Input. 79 | $query = $input->getOption( 'query' ); 80 | 81 | // If not provided, fail 82 | if ( empty( $query ) ) { 83 | $output->writeln( 'You must supply a valid query string.' ); 84 | exit; 85 | } 86 | 87 | // Account for leading slashes, if provided. 88 | $query = trim( $query, '/' ); 89 | return $query; 90 | } 91 | 92 | /** 93 | * Gets the query method, defaulting to GET. 94 | * 95 | * @param \Symfony\Component\Console\Input\InputInterface $input 96 | * @param \Symfony\Component\Console\Output\OutputInterface $output 97 | * @return string 98 | */ 99 | private function get_method( InputInterface $input, OutputInterface $output ): string { 100 | // Attempt to get search term from Input. 101 | $method = $input->getOption( 'method' ); 102 | 103 | // If we don't have a method, default to GET. 104 | if ( empty( $method ) ) { 105 | $method = 'GET'; 106 | } 107 | 108 | // Make sure it's a valid http method. 109 | if ( ! in_array( $method, array( 'GET', 'POST', 'PUT', 'DELETE' ), true ) ) { 110 | $output->writeln( 'You must supply a valid HTTP method.' ); 111 | exit; 112 | } 113 | 114 | return $method; 115 | } 116 | 117 | /** 118 | * Gets the query data from Input or prompted for. 119 | * 120 | * @param \Symfony\Component\Console\Input\InputInterface $input 121 | * @param \Symfony\Component\Console\Output\OutputInterface $output 122 | * @return string 123 | */ 124 | private function get_data( InputInterface $input, OutputInterface $output ): string { 125 | // Attempt to get data from Input. 126 | $data = $input->getOption( 'data' ) ?? ''; 127 | 128 | // If this isn't valid JSON data, fail. 129 | if ( ! empty( $data ) && null === json_decode( $data ) ) { 130 | $output->writeln( 'You must supply a valid JSON encoded string.' ); 131 | exit; 132 | } 133 | return $data; 134 | } 135 | 136 | /************************************* 137 | ************************************* 138 | * HANDLERS * 139 | ************************************* 140 | *************************************/ 141 | 142 | /** 143 | * Handles the call to the Pressable API. 144 | * 145 | * @param \Symfony\Component\Console\Input\InputInterface $input 146 | * @param \Symfony\Component\Console\Output\OutputInterface $output 147 | * @return void 148 | */ 149 | private function handle_api_call( InputInterface $input, OutputInterface $output ): void { 150 | // Get the query data. 151 | $query = $this->get_query( $input, $output ); 152 | 153 | $method = $this->get_method( $input, $output ); 154 | 155 | $data = $this->get_data( $input, $output ); 156 | 157 | // Callback for API call 158 | $output->writeln( sprintf( "Attempting to call Pressable API at endpoint %s using %s. \nData:\n%s", $query, $method, $data ) ); 159 | 160 | // The API Helper encodes the JSON data. Decode it here so that it fits properly. 161 | $result = $this->api_helper->call_pressable_api( $query, $method, json_decode( $data ) ); 162 | 163 | $dump = 'json' === $this->format ? json_encode( $result, JSON_PRETTY_PRINT ) : print_r( $result, true ); 164 | $output->writeln( sprintf( 'API call result: %s', $dump ) ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/helpers/wpcom-api-helper.php: -------------------------------------------------------------------------------- 1 | $endpoint ) { 85 | $endpoint = self::get_request_url( $endpoint ); 86 | $body = $params[ $index ] ?? null; 87 | 88 | $promises[ $index ] = call( 89 | static function() use ( $body, $method, $http_client, $endpoint, $quiet ) { 90 | $request = new Request( $endpoint, $method ); 91 | $request->setInactivityTimeout( 60000 ); 92 | $request->setTransferTimeout( 60000 ); 93 | 94 | $request->setHeaders( 95 | array( 96 | 'Accept' => 'application/json', 97 | 'Content-Type' => 'application/json', 98 | 'Authorization' => 'Bearer ' . WPCOM_API_ACCOUNT_TOKEN, 99 | 'User-Agent' => 'PHP', 100 | ) 101 | ); 102 | if ( ! \is_null( $body ) && \in_array( $method, array( 'POST', 'PUT' ), true ) ) { 103 | $request->setBody( encode_json_content( $body ) ); 104 | } 105 | 106 | $response = yield $http_client->request( $request ); 107 | $body = yield $response->getBody()->buffer(); 108 | 109 | $status = $response->getStatus(); 110 | if ( 0 !== \strpos( (string) $status, '2' ) ) { 111 | console_writeln( 112 | "❌ WordPress.com API error ($endpoint): $status $body", 113 | $quiet ? OutputInterface::VERBOSITY_DEBUG : OutputInterface::VERBOSITY_QUIET 114 | ); 115 | return null; 116 | } 117 | 118 | $body = $body ?: '{}'; // On non-2xx status codes, the WPCOM body is empty and that will trigger a needless exception. 119 | return decode_json_content( $body ); 120 | } 121 | ); 122 | } 123 | 124 | return wait( all( $promises ) ); 125 | } 126 | 127 | /** 128 | * Calls a given endpoint on a site connected to WordPress.com via Jetpack and returns the response. 129 | * 130 | * @param string $site_id_or_url The site URL or WordPress.com site ID. 131 | * @param string $path The WP REST API path to call. 132 | * @param mixed|null $body The body to send with the request. 133 | * @param bool|null $json Whether to send the body as JSON. 134 | * 135 | * @return object|null 136 | */ 137 | public static function call_site_api( string $site_id_or_url, string $path, $body = null, ?bool $json = null ): ?object { 138 | $site_id = self::ensure_site_id( $site_id_or_url ); 139 | if ( \is_null( $site_id ) ) { 140 | console_writeln( "❌ WordPress.com API error: Invalid site ID or URL ($site_id_or_url)", OutputInterface::VERBOSITY_QUIET ); 141 | return null; 142 | } 143 | 144 | $params = array( 'path' => $path ); 145 | if ( ! \is_null( $body ) ) { 146 | $params['json'] = $json ?? true; // Unless explicitly set, send the body as JSON. 147 | $params['body'] = $params['json'] ? encode_json_content( $body ) : $body; 148 | } 149 | 150 | return self::call_api( "jetpack-blogs/$site_id/rest-api", 'POST', $params ); 151 | } 152 | 153 | // endregion 154 | 155 | // region HELPERS 156 | 157 | /** 158 | * Prepares the fully qualified request URL for the given endpoint. 159 | * 160 | * @param string $endpoint The endpoint to call. 161 | * 162 | * @return string 163 | */ 164 | private static function get_request_url( string $endpoint ): string { 165 | $endpoint = \trim( $endpoint, '/' ); 166 | if ( 0 !== \strpos( $endpoint, 'rest/v1.1' ) ) { 167 | $endpoint = 'rest/v1.1/' . $endpoint; 168 | } 169 | 170 | return self::BASE_URL . $endpoint; 171 | } 172 | 173 | /** 174 | * Given a WordPress.com site ID or URL, validates its existence and returns the site ID. 175 | * 176 | * @param string $site_id_or_url The site URL or WordPress.com site ID. 177 | * 178 | * @return string|null 179 | */ 180 | private static function ensure_site_id( string $site_id_or_url ): ?string { 181 | $wpcom_site = get_wpcom_site( $site_id_or_url ); 182 | if ( \is_null( $wpcom_site ) ) { 183 | return null; 184 | } 185 | 186 | return $wpcom_site->ID; 187 | } 188 | 189 | // endregion 190 | } 191 | -------------------------------------------------------------------------------- /src/commands/pressable-upload-site-icon.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Uploads the site icon as apple-touch-icon.png on a Pressable site.' ); 45 | $this->setHelp( 'If a site is displaying a white square icon when bookmarking it in iOS, this command may help fix it.' ); 46 | $this->addArgument( 'site', InputArgument::REQUIRED, 'ID or URL of the site to upload the icon to.' ); 47 | $this->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Execute a dry run. It will output all the steps, but will not upload the icon.' ); 48 | } 49 | 50 | /** 51 | * {@inheritDoc} 52 | */ 53 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 54 | maybe_define_console_verbosity( $output->getVerbosity() ); 55 | 56 | $this->dry_run = (bool) $input->getOption( 'dry-run' ); 57 | 58 | // Retrieve the given site. 59 | $this->pressable_site = get_pressable_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); 60 | if ( \is_null( $this->pressable_site ) ) { 61 | exit( 1 ); // Exit if the site does not exist. 62 | } 63 | 64 | // Store the ID of the site in the argument field. 65 | $input->setArgument( 'site', $this->pressable_site->id ); 66 | } 67 | 68 | /** 69 | * {@inheritDoc} 70 | */ 71 | protected function execute( InputInterface $input, OutputInterface $output ): int { 72 | $output->writeln( "Uploading apple-touch-icon.png to {$this->pressable_site->url}" ); 73 | 74 | // First, check if the site already has an `apple-touch-icon.png` in its root. 75 | $sftp = Pressable_Connection_Helper::get_sftp_connection( $this->pressable_site->id ); 76 | if ( \is_null( $sftp ) ) { 77 | $output->writeln( 'Could not connect to the SFTP server.' ); 78 | return 1; 79 | } 80 | 81 | $output->writeln( 'SFTP connection established.', OutputInterface::VERBOSITY_VERBOSE ); 82 | 83 | if ( $sftp->file_exists( 'apple-touch-icon.png' ) ) { 84 | $output->writeln( 'apple-touch-icon.png already exists. Aborting.' ); 85 | return 1; 86 | } 87 | 88 | $ssh = Pressable_Connection_Helper::get_ssh_connection( $this->pressable_site->id ); 89 | if ( \is_null( $ssh ) ) { 90 | $output->writeln( 'Could not connect to the SSH server.' ); 91 | return 1; 92 | } 93 | $output->writeln( 'SSH connection established.', OutputInterface::VERBOSITY_VERBOSE ); 94 | 95 | $output->writeln( 'Getting site icon URL...' ); 96 | 97 | $url = $ssh->exec( "wp --skip-themes --skip-plugins eval 'echo get_site_icon_url(180);'" ); 98 | $ssh->disconnect(); 99 | 100 | if ( ! filter_var( $url, FILTER_VALIDATE_URL ) ) { 101 | $output->writeln( 'Site has no icon set. Aborting.' ); 102 | $output->writeln( "Error with URL: $url", OutputInterface::VERBOSITY_VERY_VERBOSE ); 103 | return 1; 104 | } 105 | 106 | $output->writeln( "Site icon URL: $url", OutputInterface::VERBOSITY_VERBOSE ); 107 | 108 | $output->writeln( 'Downloading site icon...' ); 109 | $file_data = \file_get_contents( $url ); 110 | if ( false === $file_data ) { 111 | $output->writeln( 'Could not download the site icon. Aborting.' ); 112 | return 1; 113 | } 114 | 115 | $image = $this->process_image( $file_data, $output ); 116 | if ( false === $image ) { 117 | $output->writeln( 'Could not process the site icon. Aborting.' ); 118 | return 1; 119 | } 120 | 121 | // If site icon is set and downloaded successfully, upload it to the site. 122 | if ( ! $this->dry_run ) { 123 | $output->writeln( 'Uploading site icon through SFTP...' ); 124 | $result = $sftp->put( 'apple-touch-icon.png', $image ); 125 | } else { 126 | $output->writeln( 'Dry run. Uploading skipped.' ); 127 | $result = true; 128 | } 129 | 130 | $sftp->disconnect(); 131 | 132 | if ( false === $result ) { 133 | $output->writeln( 'Could not upload the site icon.' ); 134 | return 1; 135 | } 136 | 137 | if ( ! $this->dry_run ) { 138 | $output->writeln( 'Site icon uploaded successfully.' ); 139 | $output->writeln( "URL: https://{$this->pressable_site->url}/apple-touch-icon.png", OutputInterface::VERBOSITY_VERBOSE ); 140 | } 141 | 142 | return 0; 143 | } 144 | 145 | /** 146 | * Prompts the user for a site if in interactive mode. 147 | * 148 | * @param InputInterface $input The input object. 149 | * @param OutputInterface $output The output object. 150 | * 151 | * @return string|null 152 | */ 153 | private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { 154 | if ( $input->isInteractive() ) { 155 | $question = new Question( 'Enter the site ID or URL to rotate the passwords on: ' ); 156 | $question->setAutocompleterValues( \array_map( static fn( object $site ) => $site->url, get_pressable_sites() ?? array() ) ); 157 | 158 | $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); 159 | } 160 | 161 | return $site ?? null; 162 | } 163 | 164 | /** 165 | * Processes the image data so that images are converted to PNGs if needed. 166 | * 167 | * @param string $data The image data. 168 | * 169 | * @return string|false 170 | */ 171 | private function process_image( string $data, OutputInterface $output ) { 172 | $image_info = getimagesizefromstring( $data ); 173 | 174 | // Do nothing if image is already PNG. 175 | if ( $image_info && $image_info[2] === IMAGETYPE_PNG ) { 176 | $output->writeln( 'Image is already PNG. Skipping conversion.', OutputInterface::VERBOSITY_VERBOSE ); 177 | return $data; 178 | } 179 | 180 | $image = imagecreatefromstring( $data ); 181 | ob_start(); 182 | imagepng( $image ); 183 | $image_data = ob_get_contents(); 184 | ob_end_clean(); 185 | 186 | return $image_data; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/commands/update-repository-secret.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Updates GitHub repository secret on github.com in the organization specified with GITHUB_API_OWNER. and project name' ) 59 | ->setHelp( 'This command allows you to update Github repository secret or create one if it is missing.' ); 60 | 61 | $this->addArgument( 'repo-slug', InputArgument::OPTIONAL, 'The slug of the GitHub repository' ) 62 | ->addOption( 'secret-name', null, InputOption::VALUE_REQUIRED, 'Secret name in all caps (e.g., GH_BOT_TOKEN)', 'GH_BOT_TOKEN' ); 63 | 64 | $this->addOption( 'multiple', null, InputOption::VALUE_REQUIRED, 'Determines whether the \'repo-slug\' argument is optional or not. Accepts only \'all\' currently.' ); 65 | } 66 | 67 | /** 68 | * {@inheritDoc} 69 | */ 70 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 71 | maybe_define_console_verbosity( $output->getVerbosity() ); 72 | 73 | // Retrieve and validate the modifier options. 74 | $this->multiple = get_enum_input( $input, $output, 'multiple', array( 'all' ) ); 75 | 76 | // If processing a single repository, retrieve it from the input. 77 | if ( 'all' !== $this->multiple ) { 78 | $repo_slug = $input->getArgument( 'repo-slug' ); 79 | if ( empty( $repo_slug ) ) { 80 | $output->writeln( 'Repository slug is required.' ); 81 | exit( 1 ); 82 | } 83 | 84 | if ( \is_null( get_github_repository( GITHUB_API_OWNER, $repo_slug ) ) ) { 85 | $output->writeln( 'Repository not found.' ); 86 | exit( 1 ); 87 | } 88 | 89 | // Set the repo slug as an argument for the command. 90 | $input->setArgument( 'repo-slug', $repo_slug ); 91 | 92 | // Set the repo slug as the only repository to process. 93 | $this->repositories = array( $repo_slug ); 94 | } else { 95 | $page = 1; 96 | do { 97 | $repositories_page = get_github_repositories( 98 | GITHUB_API_OWNER, 99 | array( 100 | 'per_page' => 100, 101 | 'page' => $page, 102 | ) 103 | ); 104 | if ( \is_null( $repositories_page ) ) { 105 | $output->writeln( 'Failed to retrieve repositories.' ); 106 | exit( 1 ); 107 | } 108 | 109 | $this->repositories = array_merge( $this->repositories ?? array(), array_column( $repositories_page, 'name' ) ); 110 | ++$page; 111 | } while ( ! empty( $repositories_page ) ); 112 | } 113 | 114 | // Retrieve the given secret name which is always required. 115 | $this->secret_name = \strtoupper( $input->getOption( 'secret-name' ) ); 116 | if ( 'GH_BOT_TOKEN' !== $this->secret_name && ! defined( $this->secret_name ) ) { 117 | $output->writeln( 'No constant with the given secret name found.' ); 118 | exit( 1 ); 119 | } 120 | } 121 | 122 | /** 123 | * {@inheritDoc} 124 | */ 125 | protected function interact( InputInterface $input, OutputInterface $output ): void { 126 | switch ( $this->multiple ) { 127 | case 'all': 128 | $question = new ConfirmationQuestion( "Are you sure you want to update the $this->secret_name secret on ALL repositories? [y/N] " ); 129 | break; 130 | default: 131 | $question = new ConfirmationQuestion( "Are you sure you want to update the $this->secret_name secret on {$this->repositories[0]}? [y/N] " ); 132 | } 133 | 134 | if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { 135 | $output->writeln( 'Command aborted by user.' ); 136 | exit( 2 ); 137 | } 138 | } 139 | 140 | /** 141 | * {@inheritDoc} 142 | */ 143 | protected function execute( InputInterface $input, OutputInterface $output ): int { 144 | foreach ( $this->repositories as $repository ) { 145 | $secrets = get_github_repository_secrets( GITHUB_API_OWNER, $repository ); 146 | 147 | // Check if $secrets is an array before proceeding 148 | if ( ! \is_array( $secrets ) ) { 149 | $output->writeln( "Error: Unable to retrieve secrets for $repository. Skipping...", OutputInterface::VERBOSITY_VERBOSE ); 150 | continue; 151 | } 152 | 153 | if ( ! \in_array( $this->secret_name, \array_column( $secrets, 'name' ), true ) ) { 154 | $output->writeln( "Secret $this->secret_name not found on $repository. Skipping...", OutputInterface::VERBOSITY_VERBOSE ); 155 | continue; 156 | } 157 | 158 | $repo_public_key = get_github_repository_public_key( GITHUB_API_OWNER, $repository ); 159 | if ( empty( $repo_public_key ) ) { 160 | $output->writeln( "Failed to retrieve public key for $repository. Skipping..." ); 161 | continue; 162 | } 163 | 164 | $secret_value = \constant( 'GH_BOT_TOKEN' === $this->secret_name ? 'GITHUB_API_BOT_SECRETS_TOKEN' : $this->secret_name ); 165 | $encrypted_value = $this->seal_secret( $secret_value, \base64_decode( $repo_public_key->key ) ); 166 | 167 | $result = update_github_repository_secret( GITHUB_API_OWNER, $repository, $this->secret_name, $encrypted_value, $repo_public_key->key_id ); 168 | if ( $result ) { 169 | $output->writeln( "Successfully updated secret $this->secret_name on $repository." ); 170 | } else { 171 | $output->writeln( "Failed to update secret $this->secret_name on $repository." ); 172 | } 173 | } 174 | 175 | return 0; 176 | } 177 | 178 | /** 179 | * Generate base64 encoded sealed box of passed secret. 180 | * 181 | * @throws \SodiumException 182 | */ 183 | private function seal_secret( string $secret_string, string $public_key ): string { 184 | return \base64_encode( \sodium_crypto_box_seal( $secret_string, $public_key ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/commands/pressable-site-open-shell.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Opens an interactive SSH or SFTP shell to a given Pressable site.' ) 63 | ->setHelp( 'This command accepts a Pressable site as an input, then searches for the concierge user to generate the host argument. Lastly, it calls the system SSH/SFTP applications which will authenticate automatically via AutoProxxy. If the command is run in verbose mode, it will display the SSH user ID.' ); 64 | 65 | $this->addArgument( 'site', InputArgument::REQUIRED, 'ID or URL of the site to connect to.' ) 66 | ->addOption( 'user', 'u', InputOption::VALUE_REQUIRED, 'Email of the user to connect as. Defaults to your Team51 1Password email.' ) 67 | ->addOption( 'shell-type', null, InputOption::VALUE_REQUIRED, 'The type of shell to open. Accepts either "ssh" or "sftp". Default "ssh".', 'ssh' ); 68 | } 69 | 70 | /** 71 | * {@inheritDoc} 72 | */ 73 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 74 | maybe_define_console_verbosity( $output->getVerbosity() ); 75 | 76 | // Retrieve and validate the modifier options. 77 | $this->shell_type = get_enum_input( $input, $output, 'shell-type', array( 'ssh', 'sftp' ), 'ssh' ); 78 | 79 | // Retrieve and validate the site. 80 | $this->pressable_site = get_pressable_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); 81 | if ( \is_null( $this->pressable_site ) ) { 82 | exit( 1 ); // Exit if the site does not exist. 83 | } 84 | 85 | // Store the ID of the site in the argument field. 86 | $input->setArgument( 'site', $this->pressable_site->id ); 87 | 88 | // Figure out the SFTP user to connect as. 89 | $this->user_email = get_email_input( 90 | $input, 91 | $output, 92 | static function() { 93 | $team51_op_account = \array_filter( 94 | list_1password_accounts(), 95 | static fn( object $account ) => 'ZVYA3AB22BC37JPJZJNSGOPYEQ' === $account->account_uuid 96 | ); 97 | return empty( $team51_op_account ) ? null : \reset( $team51_op_account )->email; 98 | }, 99 | 'user' 100 | ); 101 | $input->setOption( 'user', $this->user_email ); // Store the user email in the input. 102 | } 103 | 104 | /** 105 | * {@inheritDoc} 106 | */ 107 | protected function execute( InputInterface $input, OutputInterface $output ): int { 108 | $output->writeln( "Opening an interactive $this->shell_type shell for {$this->pressable_site->displayName} (ID {$this->pressable_site->id}, URL {$this->pressable_site->url}) as $this->user_email." ); 109 | 110 | // Retrieve the SFTP user for the given email. 111 | $sftp_user = get_pressable_site_sftp_user_by_email( $this->pressable_site->id, $this->user_email ); 112 | if ( \is_null( $sftp_user ) ) { 113 | $output->writeln( "Could not find a Pressable SFTP user with the email $this->user_email on {$this->pressable_site->displayName}. Creating...", OutputInterface::VERBOSITY_VERBOSE ); 114 | 115 | $sftp_user = create_pressable_site_collaborator( $this->user_email, $this->pressable_site->id ); 116 | if ( \is_null( $sftp_user ) ) { 117 | $output->writeln( "Could not create a Pressable SFTP user with the email $this->user_email on {$this->pressable_site->displayName}." ); 118 | return 1; 119 | } 120 | 121 | // SFTP users are different from collaborator users. We need to query the API again to get the SFTP user. 122 | $sftp_user = get_pressable_site_sftp_user_by_email( $this->pressable_site->id, $this->user_email ); 123 | } 124 | 125 | // Team51 users are logged-in through AutoProxxy, but for everyone else we must first reset their password and display it. 126 | if ( ! \strpos( $this->user_email, '@automattic.com' ) ) { // Check both against 'false' and '0'. 127 | $output->writeln( "Resetting the SFTP password of $sftp_user->email on {$this->pressable_site->displayName}...", OutputInterface::VERBOSITY_VERBOSE ); 128 | 129 | $result = reset_pressable_site_sftp_user_password( $this->pressable_site->id, $sftp_user->username ); 130 | if ( \is_null( $result ) ) { 131 | $output->writeln( "Could not reset the SFTP password of $sftp_user->email on {$this->pressable_site->displayName}." ); 132 | return 1; 133 | } 134 | 135 | $output->writeln( "New SFTP user password: $result" ); 136 | } 137 | 138 | // Call the system SSH/SFTP application. 139 | $ssh_host = $sftp_user->username . '@' . Pressable_Connection_Helper::SSH_HOST; 140 | 141 | // If verbose mode is set, show the SSH connect string. 142 | if ( $output->isVerbose() ) { 143 | $output->writeln( "Connecting to $ssh_host..." ); 144 | } 145 | if ( ! \is_null( \passthru( "$this->shell_type $ssh_host", $result_code ) ) ) { 146 | $output->writeln( "Could not open an interactive $this->shell_type shell. Error code: $result_code" ); 147 | return 1; 148 | } 149 | 150 | return 0; 151 | } 152 | 153 | // endregion 154 | 155 | // region HELPERS 156 | 157 | /** 158 | * Prompts the user for a site if in interactive mode. 159 | * 160 | * @param InputInterface $input The input object. 161 | * @param OutputInterface $output The output object. 162 | * 163 | * @return string|null 164 | */ 165 | private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { 166 | if ( $input->isInteractive() ) { 167 | $question = new Question( 'Enter the site ID or URL to connect to: ' ); 168 | $question->setAutocompleterValues( \array_map( static fn( object $site ) => $site->url, get_pressable_sites() ?? array() ) ); 169 | 170 | $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); 171 | } 172 | 173 | return $site ?? null; 174 | } 175 | 176 | // endregion 177 | } 178 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | secrets/* 2 | !secrets/**/*.tpl.* 3 | !secrets/**/*.dist.* 4 | 5 | config.json 6 | config.example.json 7 | src/helpers/pressable_token.json 8 | flickr 9 | 10 | .dev 11 | .idea 12 | sites.csv 13 | sites.json 14 | .vscode 15 | 16 | 17 | # Created by https://www.toptal.com/developers/gitignore/api/node,macos,composer,phpstorm+all,visualstudiocode,windows,linux 18 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,macos,composer,phpstorm+all,visualstudiocode,windows,linux 19 | 20 | ### Composer ### 21 | composer.phar 22 | /vendor/ 23 | 24 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control 25 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file 26 | # composer.lock 27 | 28 | ### Linux ### 29 | *~ 30 | 31 | # temporary files which can be created if a process still has a handle open of a deleted file 32 | .fuse_hidden* 33 | 34 | # KDE directory preferences 35 | .directory 36 | 37 | # Linux trash folder which might appear on any partition or disk 38 | .Trash-* 39 | 40 | # .nfs files are created when an open file is removed but is still being accessed 41 | .nfs* 42 | 43 | ### macOS ### 44 | # General 45 | .DS_Store 46 | .AppleDouble 47 | .LSOverride 48 | 49 | # Icon must end with two \r 50 | Icon 51 | 52 | 53 | # Thumbnails 54 | ._* 55 | 56 | # Files that might appear in the root of a volume 57 | .DocumentRevisions-V100 58 | .fseventsd 59 | .Spotlight-V100 60 | .TemporaryItems 61 | .Trashes 62 | .VolumeIcon.icns 63 | .com.apple.timemachine.donotpresent 64 | 65 | # Directories potentially created on remote AFP share 66 | .AppleDB 67 | .AppleDesktop 68 | Network Trash Folder 69 | Temporary Items 70 | .apdisk 71 | 72 | ### macOS Patch ### 73 | # iCloud generated files 74 | *.icloud 75 | 76 | ### Node ### 77 | # Logs 78 | logs 79 | *.log 80 | npm-debug.log* 81 | yarn-debug.log* 82 | yarn-error.log* 83 | lerna-debug.log* 84 | .pnpm-debug.log* 85 | 86 | # Diagnostic reports (https://nodejs.org/api/report.html) 87 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 88 | 89 | # Runtime data 90 | pids 91 | *.pid 92 | *.seed 93 | *.pid.lock 94 | 95 | # Directory for instrumented libs generated by jscoverage/JSCover 96 | lib-cov 97 | 98 | # Coverage directory used by tools like istanbul 99 | coverage 100 | *.lcov 101 | 102 | # nyc test coverage 103 | .nyc_output 104 | 105 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 106 | .grunt 107 | 108 | # Bower dependency directory (https://bower.io/) 109 | bower_components 110 | 111 | # node-waf configuration 112 | .lock-wscript 113 | 114 | # Compiled binary addons (https://nodejs.org/api/addons.html) 115 | build/Release 116 | 117 | # Dependency directories 118 | node_modules/ 119 | jspm_packages/ 120 | 121 | # Snowpack dependency directory (https://snowpack.dev/) 122 | web_modules/ 123 | 124 | # TypeScript cache 125 | *.tsbuildinfo 126 | 127 | # Optional npm cache directory 128 | .npm 129 | 130 | # Optional eslint cache 131 | .eslintcache 132 | 133 | # Optional stylelint cache 134 | .stylelintcache 135 | 136 | # Microbundle cache 137 | .rpt2_cache/ 138 | .rts2_cache_cjs/ 139 | .rts2_cache_es/ 140 | .rts2_cache_umd/ 141 | 142 | # Optional REPL history 143 | .node_repl_history 144 | 145 | # Output of 'npm pack' 146 | *.tgz 147 | 148 | # Yarn Integrity file 149 | .yarn-integrity 150 | 151 | # dotenv environment variable files 152 | .env 153 | .env.development.local 154 | .env.test.local 155 | .env.production.local 156 | .env.local 157 | 158 | # parcel-bundler cache (https://parceljs.org/) 159 | .cache 160 | .parcel-cache 161 | 162 | # Next.js build output 163 | .next 164 | out 165 | 166 | # Nuxt.js build / generate output 167 | .nuxt 168 | dist 169 | 170 | # Gatsby files 171 | .cache/ 172 | # Comment in the public line in if your project uses Gatsby and not Next.js 173 | # https://nextjs.org/blog/next-9-1#public-directory-support 174 | # public 175 | 176 | # vuepress build output 177 | .vuepress/dist 178 | 179 | # vuepress v2.x temp and cache directory 180 | .temp 181 | 182 | # Docusaurus cache and generated files 183 | .docusaurus 184 | 185 | # Serverless directories 186 | .serverless/ 187 | 188 | # FuseBox cache 189 | .fusebox/ 190 | 191 | # DynamoDB Local files 192 | .dynamodb/ 193 | 194 | # TernJS port file 195 | .tern-port 196 | 197 | # Stores VSCode versions used for testing VSCode extensions 198 | .vscode-test 199 | 200 | # yarn v2 201 | .yarn/cache 202 | .yarn/unplugged 203 | .yarn/build-state.yml 204 | .yarn/install-state.gz 205 | .pnp.* 206 | 207 | ### Node Patch ### 208 | # Serverless Webpack directories 209 | .webpack/ 210 | 211 | # Optional stylelint cache 212 | 213 | # SvelteKit build / generate output 214 | .svelte-kit 215 | 216 | ### PhpStorm+all ### 217 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 218 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 219 | 220 | # User-specific stuff 221 | .idea/**/workspace.xml 222 | .idea/**/tasks.xml 223 | .idea/**/usage.statistics.xml 224 | .idea/**/dictionaries 225 | .idea/**/shelf 226 | 227 | # AWS User-specific 228 | .idea/**/aws.xml 229 | 230 | # Generated files 231 | .idea/**/contentModel.xml 232 | 233 | # Sensitive or high-churn files 234 | .idea/**/dataSources/ 235 | .idea/**/dataSources.ids 236 | .idea/**/dataSources.local.xml 237 | .idea/**/sqlDataSources.xml 238 | .idea/**/dynamic.xml 239 | .idea/**/uiDesigner.xml 240 | .idea/**/dbnavigator.xml 241 | 242 | # Gradle 243 | .idea/**/gradle.xml 244 | .idea/**/libraries 245 | 246 | # Gradle and Maven with auto-import 247 | # When using Gradle or Maven with auto-import, you should exclude module files, 248 | # since they will be recreated, and may cause churn. Uncomment if using 249 | # auto-import. 250 | # .idea/artifacts 251 | # .idea/compiler.xml 252 | # .idea/jarRepositories.xml 253 | # .idea/modules.xml 254 | # .idea/*.iml 255 | # .idea/modules 256 | # *.iml 257 | # *.ipr 258 | 259 | # CMake 260 | cmake-build-*/ 261 | 262 | # Mongo Explorer plugin 263 | .idea/**/mongoSettings.xml 264 | 265 | # File-based project format 266 | *.iws 267 | 268 | # IntelliJ 269 | out/ 270 | 271 | # mpeltonen/sbt-idea plugin 272 | .idea_modules/ 273 | 274 | # JIRA plugin 275 | atlassian-ide-plugin.xml 276 | 277 | # Cursive Clojure plugin 278 | .idea/replstate.xml 279 | 280 | # SonarLint plugin 281 | .idea/sonarlint/ 282 | 283 | # Crashlytics plugin (for Android Studio and IntelliJ) 284 | com_crashlytics_export_strings.xml 285 | crashlytics.properties 286 | crashlytics-build.properties 287 | fabric.properties 288 | 289 | # Editor-based Rest Client 290 | .idea/httpRequests 291 | 292 | # Android studio 3.1+ serialized cache file 293 | .idea/caches/build_file_checksums.ser 294 | 295 | ### PhpStorm+all Patch ### 296 | # Ignore everything but code style settings and run configurations 297 | # that are supposed to be shared within teams. 298 | 299 | .idea/* 300 | 301 | !.idea/codeStyles 302 | !.idea/runConfigurations 303 | 304 | ### VisualStudioCode ### 305 | .vscode/* 306 | !.vscode/settings.json 307 | !.vscode/tasks.json 308 | !.vscode/launch.json 309 | !.vscode/extensions.json 310 | !.vscode/*.code-snippets 311 | 312 | # Local History for Visual Studio Code 313 | .history/ 314 | 315 | # Built Visual Studio Code Extensions 316 | *.vsix 317 | 318 | ### VisualStudioCode Patch ### 319 | # Ignore all local history of files 320 | .history 321 | .ionide 322 | 323 | # Support for Project snippet scope 324 | .vscode/*.code-snippets 325 | 326 | # Ignore code-workspaces 327 | *.code-workspace 328 | 329 | ### Windows ### 330 | # Windows thumbnail cache files 331 | Thumbs.db 332 | Thumbs.db:encryptable 333 | ehthumbs.db 334 | ehthumbs_vista.db 335 | 336 | # Dump file 337 | *.stackdump 338 | 339 | # Folder config file 340 | [Dd]esktop.ini 341 | 342 | # Recycle Bin used on file shares 343 | $RECYCLE.BIN/ 344 | 345 | # Windows Installer files 346 | *.cab 347 | *.msi 348 | *.msix 349 | *.msm 350 | *.msp 351 | 352 | # Windows shortcuts 353 | *.lnk 354 | 355 | # End of https://www.toptal.com/developers/gitignore/api/node,macos,composer,phpstorm+all,visualstudiocode,windows,linux 356 | 357 | -------------------------------------------------------------------------------- /src/commands/remove-user.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Removes a Pressable collaborator and WordPress user based on email.' ) 22 | ->setHelp( 'This command allows you to bulk-delete from all sites a Pressable collaborator and WordPress user via CLI.' ) 23 | ->addOption( 'email', null, InputOption::VALUE_REQUIRED, "The email of the user you'd like to remove access from sites." ) 24 | ->addOption( 'list', null, InputOption::VALUE_NONE, 'List the sites where this email is found.' ); 25 | } 26 | 27 | protected function execute( InputInterface $input, OutputInterface $output ) { 28 | $this->api_helper = new API_Helper(); 29 | $this->output = $output; 30 | 31 | $email = $input->getOption( 'email' ); 32 | 33 | if ( empty( $email ) ) { 34 | $email = trim( readline( 'Please provide the email of the user you want to remove: ' ) ); 35 | if ( empty( $email ) ) { 36 | $output->writeln( 'Missing collaborator email (--email=user@domain.com).' ); 37 | exit; 38 | } 39 | } 40 | 41 | $output->writeln( 'Getting collaborator data from Pressable.' ); 42 | 43 | // Each site will have a separate collaborator instance/ID for the same user/email. 44 | $collaborator_data = array(); 45 | 46 | $collaborators = $this->api_helper->call_pressable_api( 47 | 'collaborators', 48 | 'GET', 49 | array() 50 | ); 51 | 52 | // TODO: This code is duplicated below for the site clone. Should be a function. 53 | if ( empty( $collaborators->data ) ) { 54 | $output->writeln( 'Something has gone wrong while looking up the Pressable collaborators site.' ); 55 | exit; 56 | } 57 | 58 | foreach ( $collaborators->data as $collaborator ) { 59 | if ( $collaborator->email === $email ) { 60 | $collaborator_data[] = $collaborator; 61 | } 62 | } 63 | 64 | if ( empty( $collaborator_data ) ) { 65 | $output->writeln( "No collaborators found in Pressable with the email '$email'." ); 66 | } else { 67 | $site_info = new Table( $output ); 68 | $site_info->setStyle( 'box-double' ); 69 | $site_info->setHeaders( array( 'Default Pressable URL', 'Site ID' ) ); 70 | 71 | $collaborator_sites = array(); 72 | 73 | $output->writeln( '' ); 74 | $output->writeln( "$email is a collaborator on the following Pressable sites:" ); 75 | foreach ( $collaborator_data as $collaborator ) { 76 | $collaborator_sites[] = array( $collaborator->siteName . '.mystagingwebsite.com', $collaborator->siteId ); 77 | } 78 | 79 | $site_info->setRows( $collaborator_sites ); 80 | $site_info->render(); 81 | } 82 | 83 | // Get users from wordpress.com 84 | $wpcom_collaborator_data = $this->get_wpcom_users( $email ); 85 | 86 | if ( empty( $wpcom_collaborator_data ) ) { 87 | $output->writeln( "No collaborators found in WordPress.com with the email '$email'." ); 88 | } else { 89 | $site_info = new Table( $output ); 90 | $site_info->setStyle( 'box-double' ); 91 | $site_info->setHeaders( array( 'WP URL', 'Site ID', 'WP User ID' ) ); 92 | $wpcom_collaborator_sites = array(); 93 | 94 | $output->writeln( '' ); 95 | $output->writeln( "$email is a user on the following WordPress sites:" ); 96 | foreach ( $wpcom_collaborator_data as $collaborator ) { 97 | $wpcom_collaborator_sites[] = array( $collaborator->siteName, $collaborator->siteId, $collaborator->userId ); 98 | } 99 | $site_info->setRows( $wpcom_collaborator_sites ); 100 | $site_info->render(); 101 | } 102 | 103 | // Bail here unless the user has asked to remove the collaborator. 104 | if ( $input->getOption( 'list' ) ) { 105 | exit; 106 | } 107 | 108 | // Remove? 109 | if ( ! $input->getOption( 'no-interaction' ) ) { 110 | $confirm_remove = trim( readline( 'Are you sure you want to remove this user from WordPress.com and Pressable? (y/N) ' ) ); 111 | if ( 'y' !== $confirm_remove ) { 112 | exit; 113 | } 114 | } 115 | 116 | // Remove from Pressable 117 | foreach ( $collaborator_data as $collaborator ) { 118 | $removed_collaborator = $this->api_helper->call_pressable_api( "/sites/{$collaborator->siteId}/collaborators/{$collaborator->id}", 'DELETE', array() ); 119 | if ( 'Success' === $removed_collaborator->message ) { 120 | $output->writeln( "✓ Removed {$collaborator->email} from {$collaborator->siteName}. (Pressable site)" ); 121 | } else { 122 | $output->writeln( "❌ Failed to remove from {$collaborator->email} from Pressable site '{$collaborator->siteName}." ); 123 | } 124 | } 125 | 126 | // Remove from WordPress 127 | foreach ( $wpcom_collaborator_data as $collaborator ) { 128 | $removed_collaborator = $this->api_helper->call_wpcom_api( "rest/v1.1/sites/{$collaborator->siteId}/users/{$collaborator->userId}/delete", array(), 'POST' ); 129 | 130 | if ( isset( $removed_collaborator->success ) && $removed_collaborator->success ) { 131 | $output->writeln( "✓ Removed {$collaborator->email} from {$collaborator->siteName} (WordPress site)." ); 132 | } else { 133 | $output->writeln( "❌ Failed to remove {$collaborator->email} from WordPress site '{$collaborator->siteName}." ); 134 | } 135 | } 136 | 137 | // TODO: Remove user from Github too? 138 | 139 | $output->writeln( 'All done!' ); 140 | } 141 | 142 | /** 143 | * Given an email, return the list of sites owned by that user. 144 | */ 145 | private function get_wpcom_users( $email ) { 146 | $exclude_sites = array( 147 | 'https://woocommerce.com', 148 | ); 149 | 150 | $this->output->writeln( 'Fetching list of WordPress.com & Jetpack sites...' ); 151 | 152 | $all_sites = $this->api_helper->call_wpcom_api( 'rest/v1.1/me/sites/?fields=ID,URL', array() ); 153 | 154 | if ( ! empty( $all_sites->error ) ) { 155 | $this->output->writeln( 'Failed. ' . $all_sites->message . '' ); 156 | exit; 157 | } 158 | 159 | // Filter out sites from exclude list. 160 | $filtered_sites = array_filter( 161 | $all_sites->sites, 162 | function( $site ) use ( $exclude_sites ) { 163 | foreach ( $exclude_sites as $exclude ) { 164 | if ( $exclude === $site->URL ) { 165 | return false; 166 | } 167 | } 168 | return true; 169 | } 170 | ); 171 | 172 | $this->output->writeln( "Searching for '$email' across " . count( $filtered_sites ) . ' WordPress.com & Jetpack sites...' ); 173 | 174 | $site_users_endpoints = array_map( 175 | static function( $site ) use ( $email ) { 176 | return "sites/$site->ID/users/?search=$email&search_columns=user_email&fields=ID,email,site_ID,URL"; 177 | }, 178 | $filtered_sites 179 | ); 180 | 181 | // concurrent call for all endpoints. 182 | $sites_users = WPCOM_API_Helper::call_api_concurrent( $site_users_endpoints ); 183 | 184 | // clean up data by removing entries were user was not found. 185 | $sites_users = array_filter( 186 | $sites_users, 187 | static function( $user ) { 188 | return ( isset( $user ) && ! isset( $user->error ) && $user->found > 0 ); 189 | } 190 | ); 191 | 192 | $data = array(); 193 | foreach ( $filtered_sites as $site ) { 194 | foreach ( $site_users_endpoints as $index => $endpoint ) { 195 | if ( isset( $sites_users[ $index ] ) && str_contains( $endpoint, $site->ID ) ) { 196 | $data[] = (object) array( 197 | 'userId' => $sites_users[ $index ]->users[0]->ID, 198 | 'email' => $sites_users[ $index ]->users[0]->email, 199 | 'siteId' => $site->ID, 200 | 'siteName' => $site->URL, 201 | ); 202 | } 203 | } 204 | } 205 | 206 | return $data; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/helpers/pressable-api-helper.php: -------------------------------------------------------------------------------- 1 | message; 66 | } elseif ( \property_exists( $result['body'], 'error' ) ) { 67 | $message = $result['body']->error; 68 | } 69 | 70 | if ( \property_exists( $result['body'], 'errors' ) && ! empty( $result['body']->errors ) ) { 71 | $message .= ' ' . \implode( ', ', (array) $result['body']->errors ); 72 | } 73 | } 74 | 75 | console_writeln( 76 | "❌ Pressable API error ($endpoint): {$result['headers']['http_code']} $message", 77 | 404 === $result['headers']['http_code'] ? OutputInterface::VERBOSITY_DEBUG : OutputInterface::VERBOSITY_QUIET 78 | ); 79 | return null; 80 | } 81 | 82 | return $result['body']; 83 | } 84 | 85 | /** 86 | * Calls the Pressable auth endpoint to get new tokens. 87 | * 88 | * @param string $client_id The API application client ID. 89 | * @param string $client_secret The API application client secret. 90 | * 91 | * @return object 92 | */ 93 | public static function call_auth_api( string $client_id, string $client_secret ): object { 94 | console_writeln( 'Obtaining new Pressable OAuth token.', OutputInterface::VERBOSITY_VERBOSE ); 95 | $post_data = array( 96 | 'client_id' => $client_id, 97 | 'client_secret' => $client_secret, 98 | ); 99 | 100 | if ( \defined( 'PRESSABLE_ACCOUNT_PASSWORD' ) && \defined( 'PRESSABLE_ACCOUNT_EMAIL' ) ) { 101 | $post_data['grant_type'] = 'password'; 102 | $post_data['email'] = PRESSABLE_ACCOUNT_EMAIL; 103 | $post_data['password'] = PRESSABLE_ACCOUNT_PASSWORD; 104 | } elseif ( \defined( 'PRESSABLE_API_REFRESH_TOKEN' ) ) { 105 | $post_data['grant_type'] = 'refresh_token'; 106 | $post_data['refresh_token'] = self::get_cached_refresh_token(); 107 | } else { 108 | exit( '❌ Missing both Pressable credentials and a refresh token. Aborting!' . PHP_EOL ); 109 | } 110 | 111 | $result = get_remote_content( 112 | 'https://my.pressable.com/auth/token/', 113 | array( 114 | 'Content-Type: application/x-www-form-urlencoded', 115 | 'User-Agent: PHP', 116 | ), 117 | 'POST', 118 | \http_build_query( $post_data ) 119 | ); 120 | if ( empty( $result['body']->access_token ) ) { 121 | exit( '❌ Pressable API token could not be retrieved. Aborting!' . PHP_EOL ); 122 | } 123 | 124 | return $result['body']; 125 | } 126 | 127 | // endregion 128 | 129 | // region HELPERS 130 | 131 | /** 132 | * Prepares the fully qualified request URL for the given endpoint. 133 | * 134 | * @param string $endpoint The endpoint to call. 135 | * 136 | * @return string 137 | */ 138 | private static function get_request_url( string $endpoint ): string { 139 | return self::BASE_URL . \ltrim( $endpoint, '/' ); 140 | } 141 | 142 | /** 143 | * Returns the access token for the Pressable API. 144 | * 145 | * @return string 146 | */ 147 | private static function get_access_token(): string { 148 | // Check for an existing access token. 149 | $access_token = self::get_cached_access_token(); 150 | if ( ! \is_null( $access_token ) ) { 151 | console_writeln( 'Re-using Pressable OAuth token cached locally.', OutputInterface::VERBOSITY_DEBUG ); 152 | 153 | return $access_token; 154 | } 155 | 156 | // If no access token exists, get a new one. 157 | $api_tokens = self::call_auth_api( PRESSABLE_API_APP_CLIENT_ID, PRESSABLE_API_APP_CLIENT_SECRET ); 158 | if ( false === self::set_cached_tokens( $api_tokens ) ) { 159 | console_writeln( '❌ Failed to cache Pressable access tokens.' ); 160 | } 161 | 162 | return $api_tokens->access_token; 163 | } 164 | 165 | /** 166 | * Returns the access token from the cached tokens file, if exists and still valid. 167 | * 168 | * @return string|null 169 | */ 170 | private static function get_cached_access_token(): ?string { 171 | // Load tokens file contents. 172 | if ( ! \file_exists( self::CACHED_TOKENS_FILE_PATH ) ) { 173 | return null; 174 | } 175 | 176 | $data = decode_json_content( \file_get_contents( self::CACHED_TOKENS_FILE_PATH ) ); 177 | if ( \is_null( $data ) ) { 178 | return null; 179 | } 180 | 181 | // Check temporal validity. 182 | if ( (int) $data->created_at < \strtotime( 'now -' . self::ACCESS_TOKEN_VALIDITY ) ) { 183 | return null; 184 | } 185 | 186 | return $data->access_token; 187 | } 188 | 189 | /** 190 | * Returns the refresh token from the cached tokens file, if exists and still valid. 191 | * 192 | * @return string|null 193 | */ 194 | private static function get_cached_refresh_token(): ?string { 195 | if ( ! \file_exists( self::CACHED_TOKENS_FILE_PATH ) ) { 196 | if ( \defined( 'PRESSABLE_API_REFRESH_TOKEN' ) ) { 197 | console_writeln( 'Using PRESSABLE_API_REFRESH_TOKEN from config.json file.', OutputInterface::VERBOSITY_DEBUG ); 198 | return PRESSABLE_API_REFRESH_TOKEN; 199 | } 200 | 201 | console_writeln( '❌ No PRESSABLE_API_REFRESH_TOKEN found. Please check your config.json file.' ); 202 | return null; 203 | } 204 | 205 | $data = decode_json_content( \file_get_contents( self::CACHED_TOKENS_FILE_PATH ) ); 206 | if ( \is_null( $data ) ) { 207 | return null; 208 | } 209 | 210 | return $data->refresh_token; 211 | } 212 | 213 | /** 214 | * Saves the access token to the access token file. 215 | * 216 | * @param object $token The access token data to save. 217 | * 218 | * @return bool True if the access token was saved successfully, false otherwise. 219 | */ 220 | private static function set_cached_tokens( object $token ): bool { 221 | // No point in saving more data than we need. 222 | $data = array( 223 | 'access_token' => $token->access_token, 224 | 'refresh_token' => $token->refresh_token, 225 | 'created_at' => \time(), // Safeguard against the user's PHP time being misconfigured. 226 | ); 227 | 228 | // Save the data to the file as a JSON string. 229 | $data = encode_json_content( $data ); 230 | if ( \is_null( $data ) ) { 231 | return false; 232 | } 233 | 234 | $result = \file_put_contents( self::CACHED_TOKENS_FILE_PATH, $data ); 235 | return false !== $result; 236 | } 237 | 238 | // endregion 239 | } 240 | -------------------------------------------------------------------------------- /src/commands/flickr-scrap-photostream.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Scraps the photostream of a given Flickr account.' ) 54 | ->setHelp( 'This command downloads the photos and videos from a given Flickr account.' ); 55 | 56 | $this->addArgument( 'username', InputArgument::REQUIRED, 'The username of the Flickr account to scrap.' ) 57 | ->addOption( 'limit', null, InputOption::VALUE_REQUIRED, 'Limit the number of media files to scrap.' ); 58 | } 59 | 60 | /** 61 | * {@inheritDoc} 62 | */ 63 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 64 | maybe_define_console_verbosity( $output->getVerbosity() ); 65 | 66 | $flickr_username = $input->getArgument( 'username' ); 67 | $flickr_user = get_flickr_user_by_username( $flickr_username ); 68 | if ( empty( $flickr_user ) || empty( $flickr_user->nsid ) ) { 69 | $output->writeln( "Failed to fetch user ID from Flickr. Username error: $flickr_username" ); 70 | exit( 1 ); 71 | } 72 | 73 | $this->flickr_user_id = $flickr_user->nsid; 74 | 75 | if ( $input->hasOption( 'limit' ) ) { 76 | $this->limit = (int) $input->getOption( 'limit' ); 77 | $this->limit = abs( $this->limit ); 78 | } 79 | } 80 | 81 | /** 82 | * {@inheritDoc} 83 | */ 84 | protected function execute( InputInterface $input, OutputInterface $output ): int { 85 | $output->writeln( 'Scraping ' . ( $this->limit ?: 'all' ) . " media from the photostream of user $this->flickr_user_id." ); 86 | 87 | // Download photosets information. 88 | $output->writeln( 'Downloading photosets information...' ); 89 | 90 | $photosets = get_flickr_photosets_for_user( $this->flickr_user_id ); 91 | if ( \is_null( $photosets ) ) { 92 | $output->writeln( 'Failed to fetch photosets from Flickr.' ); 93 | return 1; 94 | } 95 | 96 | $photosets_data_directory = TEAM51_CLI_ROOT_DIR . "/flickr/$this->flickr_user_id/photosets"; 97 | if ( ! \file_exists( $photosets_data_directory ) && ! \mkdir( $photosets_data_directory, 0777, true ) && ! \is_dir( $photosets_data_directory ) ) { 98 | throw new \RuntimeException( sprintf( 'Directory "%s" was not created', $photosets_data_directory ) ); 99 | } 100 | 101 | $progress_bar = new ProgressBar( $output, \count( $photosets->photoset ) ); 102 | $progress_bar->start(); 103 | 104 | foreach ( $photosets->photoset as $photoset ) { 105 | $progress_bar->advance(); 106 | 107 | $data_file = $photosets_data_directory . "/$photoset->id.json"; 108 | if ( \file_exists( $data_file ) ) { 109 | continue; 110 | } 111 | 112 | $photos = array(); 113 | 114 | $current_page = 1; 115 | do { 116 | $photoset_photos = get_flickr_photos_for_photoset( 117 | $photoset->id, 118 | array( 119 | 'per_page' => 500, 120 | 'page' => $current_page, 121 | ) 122 | ); 123 | if ( \is_null( $photoset_photos ) ) { 124 | $output->writeln( "Failed to fetch photos from Flickr. Photoset error: $photoset->id" ); 125 | return 1; 126 | } 127 | 128 | $photos[] = $photoset_photos->photo; 129 | 130 | ++$current_page; 131 | $has_next_page = $photoset_photos->page < $photoset_photos->pages; 132 | } while ( $has_next_page ); 133 | 134 | \file_put_contents( 135 | $data_file, 136 | encode_json_content( 137 | array( 138 | 'photoset' => $photoset, 139 | 'photos' => \array_merge( ...$photos ), 140 | ), 141 | JSON_PRETTY_PRINT 142 | ) 143 | ); 144 | } 145 | 146 | $progress_bar->finish(); 147 | $output->writeln( '' ); // Empty line for UX purposes. 148 | 149 | // Download photos/videos information. 150 | $current_page = 1; 151 | do { 152 | $output->writeln( "Downloading photostream. Page: $current_page" ); 153 | 154 | $extras = array( 'url_o', 'description', 'license', 'date_upload', 'date_taken', 'original_format', 'last_update', 'geo', 'tags', 'machine_tags', 'views', 'media' ); 155 | $extras = \implode( ',', $extras ); 156 | 157 | $photos = get_flickr_photos_for_user( 158 | $this->flickr_user_id, 159 | array( 160 | 'extras' => $extras, 161 | 'per_page' => 50, 162 | 'page' => $current_page, 163 | ) 164 | ); 165 | if ( \is_null( $photos ) ) { 166 | $output->writeln( "Failed to fetch photos from Flickr. Page error: $current_page" ); 167 | return 1; 168 | } 169 | 170 | $progress_bar = new ProgressBar( $output, \min( $this->limit ?? PHP_INT_MAX, \count( $photos->photo ) ) ); 171 | $progress_bar->start(); 172 | 173 | foreach ( $photos->photo as $photo ) { 174 | $media_data_directory = TEAM51_CLI_ROOT_DIR . "/flickr/$this->flickr_user_id/media/$photo->media/$photo->id"; 175 | if ( ! \file_exists( $media_data_directory ) && ! \mkdir( $media_data_directory, 0777, true ) && ! is_dir( $media_data_directory ) ) { 176 | throw new \RuntimeException( sprintf( 'Directory "%s" was not created', $media_data_directory ) ); 177 | } 178 | 179 | $progress_bar->advance(); 180 | 181 | // Save photo meta. 182 | \file_put_contents( 183 | $media_data_directory . '/meta.json', 184 | encode_json_content( $photo, JSON_PRETTY_PRINT ) 185 | ); 186 | 187 | // Save photo comments. 188 | $comments = get_flickr_comments_for_photo( $photo->id ); 189 | if ( \is_null( $comments ) ) { 190 | $output->writeln( "Failed to fetch comments from Flickr. Photo error: $photo->id" ); 191 | return 1; 192 | } 193 | 194 | \file_put_contents( 195 | $media_data_directory . '/comments.json', 196 | encode_json_content( $comments->comment ?? array(), JSON_PRETTY_PRINT ) 197 | ); 198 | 199 | // Download photo/video file. 200 | if ( 'photo' === $photo->media ) { 201 | $media_url = $photo->url_o; 202 | } else { // Video. 203 | $media_sizes = get_flickr_photo_sizes( $photo->id ); 204 | if ( \is_null( $media_sizes ) ) { 205 | $output->writeln( "Failed to fetch file sizes. Media error: $photo->id" ); 206 | return 1; 207 | } 208 | 209 | foreach ( $media_sizes->size as $size ) { 210 | if ( 'video' === $size->media && $size->height === $photo->height_o ) { 211 | $media_url = $size->source; 212 | break; 213 | } 214 | } 215 | } 216 | 217 | $media_file = \file_get_contents( $media_url ); 218 | if ( empty( $media_file ) ) { 219 | $output->writeln( "Failed to download media. Meida error: $photo->id, Media URL: $media_url" ); 220 | return 1; 221 | } 222 | 223 | if ( false === \file_put_contents( $media_data_directory . '/media.' . $photo->originalformat, $media_file ) ) { 224 | $output->writeln( "Failed to save media. Media error: $photo->id" ); 225 | return 1; 226 | } 227 | 228 | if ( $this->limit ) { 229 | --$this->limit; 230 | if ( 0 === $this->limit ) { 231 | break; 232 | } 233 | } 234 | sleep( 2 ); // Flickr API rate limit. We can make a maximum of 3600 requests per hour or 1 per second. 235 | } 236 | 237 | $progress_bar->finish(); 238 | $output->writeln( '' ); // Empty line for UX purposes. 239 | 240 | ++$current_page; 241 | $has_next_page = 0 !== $this->limit && $photos->page < $photos->pages; 242 | } while ( $has_next_page ); 243 | 244 | return 0; 245 | } 246 | 247 | // endregion 248 | 249 | // region HELPERS 250 | 251 | // endregion 252 | } 253 | -------------------------------------------------------------------------------- /src/helpers/1password-functions.php: -------------------------------------------------------------------------------- 1 | $dry_run ) ), 75 | \array_flip( array( 'title', 'url', 'template', 'category', 'tags', 'generate-password', 'vault', 'dry-run' ) ) 76 | ); 77 | $command = _build_1password_command_string( 'op item create', $flags, array( 'title', 'url', 'template', 'category', 'tags', 'vault' ), $global_flags ); 78 | 79 | foreach ( $fields as $field => $value ) { 80 | $command .= " '$field=$value'"; 81 | } 82 | 83 | return decode_json_content( \shell_exec( "$command --format json" ) ); 84 | } 85 | 86 | /** 87 | * Returns details about a given 1Password item. 88 | * 89 | * @param string $item_id The ID of the item to get. 90 | * @param array $flags The flags to pass on to the command. 91 | * @param array $global_flags The global flags to pass to the command. 92 | * 93 | * @link https://developer.1password.com/docs/cli/reference/management-commands/item#item-get 94 | * 95 | * @return object|null 96 | */ 97 | function get_1password_item( string $item_id, array $flags = array(), array $global_flags = array() ): ?object { 98 | $flags = \array_intersect_key( $flags, \array_flip( array( 'fields', 'include-archive', 'otp', 'share-link', 'vault' ) ) ); 99 | $command = _build_1password_command_string( "op item get $item_id", $flags, array( 'fields', 'vault' ), $global_flags ); 100 | 101 | return decode_json_content( \shell_exec( "$command --format json" ) ); 102 | } 103 | 104 | /** 105 | * Edits a given 1Password item. 106 | * 107 | * @param string $item_id The ID of the item to update. 108 | * @param array $fields The fields to set on the item. 109 | * @param array $flags The flags to pass on to the command. 110 | * @param array $global_flags The global flags to pass to the command. 111 | * @param bool $dry_run Perform a dry run of the command and return a preview of the resulting item. 112 | * 113 | * @link https://developer.1password.com/docs/cli/reference/management-commands/item#item-edit 114 | * 115 | * @return object|null 116 | */ 117 | function update_1password_item( string $item_id, array $fields, array $flags = array(), array $global_flags = array(), bool $dry_run = false ): ?object { 118 | $flags = \array_intersect_key( 119 | \array_merge( $flags, array( 'dry-run' => $dry_run ) ), 120 | \array_flip( array( 'title', 'url', 'tags', 'generate-password', 'vault', 'dry-run' ) ) 121 | ); 122 | $command = _build_1password_command_string( "op item edit $item_id", $flags, array( 'title', 'url', 'tags', 'vault' ), $global_flags ); 123 | 124 | foreach ( $fields as $field => $new_value ) { 125 | $command .= " '$field=$new_value'"; 126 | } 127 | 128 | return decode_json_content( \shell_exec( "$command --format json" ) ); 129 | } 130 | 131 | /** 132 | * Deletes a given 1Password item either permanently or by archiving it. 133 | * 134 | * @param string $item_id The ID of the item to delete. 135 | * @param array $flags The flags to pass on to the command. 136 | * @param array $global_flags The global flags to pass to the command. 137 | * 138 | * @link https://developer.1password.com/docs/cli/reference/management-commands/item#item-delete 139 | * 140 | * @return void 141 | */ 142 | function delete_1password_item( string $item_id, array $flags = array(), array $global_flags = array() ): void { 143 | $flags = \array_intersect_key( $flags, \array_flip( array( 'archive', 'vault' ) ) ); 144 | $command = _build_1password_command_string( "op item delete $item_id", $flags, array( 'vault' ), $global_flags ); 145 | 146 | \shell_exec( $command ); 147 | } 148 | 149 | /** 150 | * Prepares a 1Password command string. 151 | * 152 | * @param string $command The command to run. 153 | * @param array $flags The flags to filter the results by. 154 | * @param array $value_flags The flags that can have a value. 155 | * @param array $global_flags The global flags to pass to the command. 156 | * 157 | * @internal 158 | * @return string 159 | */ 160 | function _build_1password_command_string( string $command, array $flags, array $value_flags, array $global_flags ): string { 161 | $global_flags = \array_intersect_key( $global_flags, \array_flip( array( 'account', 'cache', 'config', 'debug', 'encoding', 'iso-timestamps', 'session' ) ) ); 162 | 163 | foreach ( $flags as $flag => $value ) { 164 | if ( \in_array( $flag, $value_flags, true ) ) { 165 | $command .= " --$flag " . \escapeshellarg( \implode( ',', (array) $value ) ); 166 | } elseif ( $value ) { 167 | $command .= " --$flag"; 168 | } 169 | } 170 | foreach ( $global_flags as $flag => $value ) { 171 | if ( \in_array( $flag, array( 'account', 'config', 'encoding', 'session' ), true ) ) { 172 | $command .= " --$flag " . \escapeshellarg( \implode( ',', (array) $value ) ); 173 | } elseif ( $value ) { 174 | $command .= " --$flag"; 175 | } 176 | } 177 | 178 | return $command; 179 | } 180 | 181 | /** 182 | * Returns true if the given 1Password item is a match for the given URL. False otherwise. 183 | * 184 | * @param object $op_item The 1Password item object. 185 | * @param string $match_url The URL to match the item against. 186 | * 187 | * @return bool 188 | */ 189 | function is_1password_item_url_match( object $op_item, string $match_url ): bool { 190 | $result = false; 191 | 192 | $match_host = \trim( $match_url ); 193 | if ( false !== \strpos( $match_host, 'http' ) ) { // Strip away everything but the domain itself. 194 | $match_host = \parse_url( $match_host, PHP_URL_HOST ); 195 | } else { // Strip away endings like /wp-admin or /wp-login.php. 196 | $match_host = \explode( '/', $match_host, 2 )[0]; 197 | } 198 | 199 | $op_item_urls = \property_exists( $op_item, 'urls' ) ? (array) $op_item->urls : array(); 200 | foreach ( $op_item_urls as $op_item_url ) { 201 | $op_item_host = \trim( $op_item_url->href ); 202 | if ( false !== \strpos( $op_item_host, 'http' ) ) { // Strip away everything but the domain itself. 203 | $op_item_host = \parse_url( $op_item_host, PHP_URL_HOST ); 204 | } else { // Strip away endings like /wp-admin or /wp-login.php. 205 | $op_item_host = \explode( '/', $op_item_host, 2 )[0]; 206 | } 207 | 208 | $result = is_case_insensitive_match( $match_host, $op_item_host ); 209 | if ( $result ) { 210 | break; 211 | } 212 | } 213 | 214 | return $result; 215 | } 216 | -------------------------------------------------------------------------------- /src/commands/stats-wpcom-traffic.php: -------------------------------------------------------------------------------- 1 | api_helper = new API_Helper(); 27 | } 28 | 29 | protected function configure() { 30 | $this 31 | ->setDescription( 'Get wpcom traffic across all Team51 sites.' ) 32 | ->setHelp( 33 | "This command will output a summary of wpcom traffic stats across all of our sites.\nExample usage:\nstats:wpcom-traffic --period=year --date=2022-12-12\nstats:wpcom-traffic --num=3 --period=week --date=2021-10-25\nstats:wpcom-traffic --num=6 --period=month --date=2021-02-28\nstats:wpcom-traffic --period=day --date=2022-02-27\n\nThe stats come from: https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/stats/summary/" 34 | ) 35 | ->addOption( 36 | 'num', 37 | null, 38 | InputOption::VALUE_OPTIONAL, 39 | 'Number of periods to include in the results Default: 1.', 40 | 1 41 | ) 42 | ->addOption( 43 | 'period', 44 | null, 45 | InputOption::VALUE_REQUIRED, 46 | "Options: day, week, month, year.\nday: The output will return results over the past [num] days, the last day being the date specified.\nweek: The output will return results over the past [num] weeks, the last week being the week containing the date specified.\nmonth: The output will return results over the past [num] months, the last month being the month containing the date specified.\nyear: The output will return results over the past [num] years, the last year being the year containing the date specified." 47 | ) 48 | ->addOption( 49 | 'date', 50 | null, 51 | InputOption::VALUE_REQUIRED, 52 | 'Date format: YYYY-MM-DD.' 53 | ) 54 | ->addOption( 55 | 'csv', 56 | null, 57 | InputOption::VALUE_NONE, 58 | 'Export stats to a CSV file.' 59 | ); 60 | } 61 | 62 | protected function execute( InputInterface $input, OutputInterface $output ) { 63 | $api_helper = new API_Helper(); 64 | 65 | // error if the unir or date options are not set 66 | if ( empty( $input->getOption( 'period' ) ) ) { 67 | $output->writeln( 'Time unit is required for fetching stats. (example: --period=year)' ); 68 | exit; 69 | } 70 | 71 | if ( empty( $input->getOption( 'date' ) ) ) { 72 | $output->writeln( 'Date is required for fetching stats (example: --date=2022-12-09)' ); 73 | exit; 74 | } 75 | 76 | $period = $input->getOption( 'period' ); 77 | $date = $input->getOption( 'date' ); 78 | $num = $input->getOption( 'num' ); 79 | 80 | $output->writeln( 'Checking for stats for Team51 sites during the ' . $num . ' ' . $period . ' period ending ' . $date . '' ); 81 | 82 | $output->writeln( 'Fetching production sites connected to a8cteam51...' ); 83 | 84 | // Fetching sites connected to a8cteam51 85 | $sites = $api_helper->call_wpcom_api( 'rest/v1.1/jetpack-blogs/', array() ); 86 | 87 | if ( empty( $sites ) ) { 88 | $output->writeln( 'Failed to fetch sites.' ); 89 | exit; 90 | } 91 | 92 | // Filter out non-production sites 93 | $site_list = array(); 94 | 95 | $deny_list = array( 96 | 'mystagingwebsite.com', 97 | 'go-vip.co', 98 | 'wpcomstaging.com', 99 | 'wpengine.com', 100 | 'jurassic.ninja', 101 | 'woocommerce.com', 102 | 'atomicsites.blog', 103 | 'ninomihovilic.com', 104 | 'team51.blog', 105 | ); 106 | 107 | foreach ( $sites->blogs->blogs as $site ) { 108 | $matches = false; 109 | foreach ( $deny_list as $deny ) { 110 | if ( strpos( $site->siteurl, $deny ) !== false ) { 111 | $matches = true; 112 | break; 113 | } 114 | } 115 | if ( ! $matches ) { 116 | $site_list[] = array( 117 | 'blog_id' => $site->userblog_id, 118 | 'site_url' => $site->siteurl, 119 | ); 120 | } 121 | } 122 | 123 | $site_count = count( $site_list ); 124 | 125 | if ( empty( $site_count ) ) { 126 | $output->writeln( 'Zero production sites to check.' ); 127 | exit; 128 | } 129 | 130 | $output->writeln( "{$site_count} sites found." ); 131 | 132 | // Get site stats for each site 133 | $output->writeln( 'Fetching site stats for Team51 production sites...' ); 134 | $progress_bar = new ProgressBar( $output, $site_count ); 135 | $progress_bar->start(); 136 | 137 | $team51_site_stats = array(); 138 | foreach ( $site_list as $site ) { 139 | $progress_bar->advance(); 140 | $stats = $this->get_site_stats( $site['blog_id'], $period, $date, $num ); 141 | 142 | //Checking if stats are not null. If not, add to array 143 | if ( ! empty( $stats->views ) ) { 144 | array_push( 145 | $team51_site_stats, 146 | array( 147 | 'blog_id' => $site['blog_id'], 148 | 'site_url' => $site['site_url'], 149 | 'views' => $stats->views, 150 | 'visitors' => $stats->visitors, 151 | 'comments' => $stats->comments, 152 | 'followers' => $stats->followers, 153 | ) 154 | ); 155 | } 156 | } 157 | 158 | if ( empty( $team51_site_stats ) ) { 159 | $output->writeln( 'Zero sites with stats.' ); 160 | exit; 161 | } 162 | 163 | $progress_bar->finish(); 164 | $output->writeln( ' Yay!' ); 165 | 166 | //Sort the array by total gross sales 167 | usort( 168 | $team51_site_stats, 169 | function ( $a, $b ) { 170 | return $b['views'] - $a['views']; 171 | } 172 | ); 173 | 174 | //Sum the totals 175 | $sum_total_views = array_reduce( 176 | $team51_site_stats, 177 | function ( $carry, $site ) { 178 | return $carry + $site['views']; 179 | }, 180 | 0 181 | ); 182 | 183 | $sum_total_visitors = array_reduce( 184 | $team51_site_stats, 185 | function ( $carry, $site ) { 186 | return $carry + $site['visitors']; 187 | }, 188 | 0 189 | ); 190 | 191 | $sum_total_comments = array_reduce( 192 | $team51_site_stats, 193 | function ( $carry, $site ) { 194 | return $carry + $site['comments']; 195 | }, 196 | 0 197 | ); 198 | 199 | $sum_total_followers = array_reduce( 200 | $team51_site_stats, 201 | function ( $carry, $site ) { 202 | return $carry + $site['followers']; 203 | }, 204 | 0 205 | ); 206 | 207 | $formatted_team51_site_stats = array(); 208 | foreach ( $team51_site_stats as $site ) { 209 | $formatted_team51_site_stats[] = array( $site['blog_id'], $site['site_url'], number_format( $site['views'], 0 ), number_format( $site['visitors'], 0 ), number_format( $site['comments'], 0 ), number_format( $site['followers'], 0 ) ); 210 | } 211 | 212 | $sum_total_views = number_format( $sum_total_views, 0 ); 213 | $sum_total_visitors = number_format( $sum_total_visitors, 0 ); 214 | $sum_total_comments = number_format( $sum_total_comments, 0 ); 215 | $sum_total_followers = number_format( $sum_total_followers, 0 ); 216 | 217 | $output->writeln( 'Site stats for Team51 sites during the ' . $num . ' ' . $period . ' period ending ' . $date . '' ); 218 | // Output the stats in a table 219 | $stats_table = new Table( $output ); 220 | $stats_table->setStyle( 'box-double' ); 221 | $stats_table->setHeaders( array( 'Blog ID', 'Site URL', 'Total Views', 'Total Visitors', 'Total Comments', 'Total Followers' ) ); 222 | $stats_table->setRows( $formatted_team51_site_stats ); 223 | $stats_table->render(); 224 | 225 | $output->writeln( 'Total views across Team51 sites during the ' . $num . ' ' . $period . ' period ending ' . $date . ': ' . $sum_total_views . '' ); 226 | $output->writeln( 'Total visitors across Team51 sites during the ' . $num . ' ' . $period . ' period ending ' . $date . ': ' . $sum_total_visitors . '' ); 227 | $output->writeln( 'Total comments across Team51 sites during the ' . $num . ' ' . $period . ' period ending ' . $date . ': ' . $sum_total_comments . '' ); 228 | $output->writeln( 'Total followers across Team51 sites during the ' . $num . ' ' . $period . ' period ending ' . $date . ': ' . $sum_total_followers . '' ); 229 | 230 | // Output CSV if --csv flag is set 231 | if ( $input->getOption( 'csv' ) ) { 232 | $output->writeln( 'Making the CSV...' ); 233 | $timestamp = date( 'Y-m-d-H-i-s' ); 234 | $fp = fopen( 't51-traffic-stats-' . $timestamp . '.csv', 'w' ); 235 | fputcsv( $fp, array( 'Blog ID', 'Site URL', 'Total Views', 'Total Visitors', 'Total Comments', 'Total Followers' ) ); 236 | foreach ( $formatted_team51_site_stats as $fields ) { 237 | fputcsv( $fp, $fields ); 238 | } 239 | fclose( $fp ); 240 | 241 | $output->writeln( 'Done, CSV saved to your current working directory: t51-traffic-stats-' . $timestamp . '.csv' ); 242 | 243 | } 244 | 245 | $output->writeln( 'All done! :)' ); 246 | } 247 | 248 | // Helper functions, getting site stats 249 | 250 | private function get_site_stats( $site_id, $period, $date, $num ) { 251 | $site_stats = $this->api_helper->call_wpcom_api( 'rest/v1.1/sites/' . $site_id . '/stats/summary?period=' . $period . '&date=' . $date . '&num=' . $num, array() ); 252 | return $site_stats; 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/commands/pressable-site-rotate-passwords.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Rotates the SFTP user and WordPress user passwords of a given user on Pressable sites.' ) 65 | ->setHelp( 'This command calls the commands "pressable:rotate-site-sftp-user-password" and "pressable:rotate-site-wp-user-password", in this order, with the same arguments and options as provided to this command.' ); 66 | 67 | $this->addArgument( 'site', InputArgument::OPTIONAL, 'ID or URL of the site for which to rotate the passwords.' ) 68 | ->addOption( 'user', 'u', InputOption::VALUE_REQUIRED, 'Email of the user for which to rotate the passwords. Default is concierge@wordpress.com.' ); 69 | 70 | $this->addOption( 'multiple', null, InputOption::VALUE_REQUIRED, 'Determines whether the \'site\' argument is optional or not. Accepts only \'related\' currently.' ) 71 | ->addOption( 'dry-run', null, InputOption::VALUE_NONE, 'Execute a dry run. It will output all the steps, but will keep the current passwords. Useful for checking whether a given input is valid.' ); 72 | } 73 | 74 | /** 75 | * {@inheritDoc} 76 | */ 77 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 78 | maybe_define_console_verbosity( $output->getVerbosity() ); 79 | 80 | // Retrieve and validate the modifier options. 81 | $this->dry_run = (bool) $input->getOption( 'dry-run' ); 82 | $this->multiple = get_enum_input( $input, $output, 'multiple', array( 'related' ) ); 83 | 84 | // Retrieve the given site if applicable. 85 | if ( 'all' !== $this->multiple ) { 86 | $pressable_site = get_pressable_site_from_input( $input, $output, fn() => $this->prompt_site_input( $input, $output ) ); 87 | if ( false !== \is_null( $pressable_site ) ) { 88 | exit( 1 ); // Exit if the site does not exist. 89 | } 90 | 91 | // Store the ID of the site in the argument field. 92 | $input->setArgument( 'site', $pressable_site->id ); 93 | } 94 | 95 | // Retrieve the user email which is always required. 96 | $this->user_email = get_email_input( $input, $output, fn() => $this->prompt_user_input( $input, $output ), 'user' ); 97 | $input->setOption( 'user', $this->user_email ); // Store the email of the user in the input. 98 | } 99 | 100 | /** 101 | * {@inheritDoc} 102 | */ 103 | protected function interact( InputInterface $input, OutputInterface $output ): void { 104 | switch ( $input->getOption( 'multiple' ) ) { 105 | case 'all': 106 | $question = new ConfirmationQuestion( "Are you sure you want to rotate the passwords for $this->user_email on ALL sites? [y/N] ", false ); 107 | break; 108 | case 'related': 109 | output_related_pressable_sites( $output, get_related_pressable_sites( get_pressable_site_by_id( $input->getArgument( 'site' ) ) ) ); 110 | $question = new ConfirmationQuestion( "Are you sure you want to rotate the passwords of $this->user_email on all the sites listed above? [y/N] ", false ); 111 | break; 112 | default: 113 | $question = new ConfirmationQuestion( "Are you sure you want to rotate the passwords of $this->user_email on {$input->getArgument('site')}? [y/N] ", false ); 114 | } 115 | 116 | if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { 117 | $output->writeln( 'Command aborted by user.' ); 118 | exit( 2 ); 119 | } 120 | 121 | if ( 'all' === $this->multiple && false === $this->dry_run ) { 122 | $question = new ConfirmationQuestion( 'This is NOT a dry run. Are you sure you want to continue rotating the passwords? [y/N] ', false ); 123 | if ( true !== $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { 124 | $output->writeln( 'Command aborted by user.' ); 125 | exit( 2 ); 126 | } 127 | } 128 | } 129 | 130 | /** 131 | * {@inheritDoc} 132 | * @noinspection PhpUnhandledExceptionInspection 133 | */ 134 | protected function execute( InputInterface $input, OutputInterface $output ): int { 135 | $output->writeln( "Rotating passwords for {$input->getOption( 'user' )}." ); 136 | 137 | // Rotate the SFTP user(s) password. 138 | $output->writeln( '' ); // Empty line for UX purposes. 139 | $output->writeln( '----- SFTP User(s) Password -----' ); 140 | 141 | run_app_command( 142 | $this->getApplication(), 143 | Pressable_Site_Rotate_SFTP_User_Password::getDefaultName(), 144 | \array_filter( 145 | array( 146 | 'site' => $input->getArgument( 'site' ), 147 | '--user' => $this->user_email, 148 | '--multiple' => $this->multiple, 149 | '--dry-run' => $this->dry_run, 150 | ) 151 | ), 152 | $output 153 | ); 154 | 155 | // Rotate the WP user(s) password. 156 | $output->writeln( '' ); // Empty line for UX purposes. 157 | $output->writeln( '----- WP User(s) Password -----' ); 158 | 159 | run_app_command( 160 | $this->getApplication(), 161 | Pressable_Site_Rotate_WP_User_Password::getDefaultName(), 162 | \array_filter( 163 | array( 164 | 'site' => $input->getArgument( 'site' ), 165 | '--user' => $this->user_email, 166 | '--multiple' => $this->multiple, 167 | '--dry-run' => $this->dry_run, 168 | ) 169 | ), 170 | $output 171 | ); 172 | 173 | return 0; 174 | } 175 | 176 | // endregion 177 | 178 | // region HELPERS 179 | 180 | /** 181 | * Prompts the user for a site if in interactive mode. 182 | * 183 | * @param InputInterface $input The input object. 184 | * @param OutputInterface $output The output object. 185 | * 186 | * @return string|null 187 | */ 188 | private function prompt_site_input( InputInterface $input, OutputInterface $output ): ?string { 189 | if ( $input->isInteractive() ) { 190 | $question = new Question( 'Enter the site ID or URL to rotate the passwords on: ' ); 191 | $question->setAutocompleterValues( \array_map( static fn( object $site ) => $site->url, get_pressable_sites() ?? array() ) ); 192 | 193 | $site = $this->getHelper( 'question' )->ask( $input, $output, $question ); 194 | } 195 | 196 | return $site ?? null; 197 | } 198 | 199 | /** 200 | * Prompts the user for an email or returns the default if not in interactive mode. 201 | * 202 | * @param InputInterface $input The input object. 203 | * @param OutputInterface $output The output object. 204 | * 205 | * @return string 206 | */ 207 | private function prompt_user_input( InputInterface $input, OutputInterface $output ): string { 208 | if ( ! $input->isInteractive() ) { 209 | $email = 'concierge@wordpress.com'; 210 | } else { 211 | $question = new ConfirmationQuestion( 'No user was provided. Do you wish to continue with the default concierge user? [y/N] ', false ); 212 | if ( true === $this->getHelper( 'question' )->ask( $input, $output, $question ) ) { 213 | $email = 'concierge@wordpress.com'; 214 | } else { 215 | $question = new Question( 'Enter the user email to rotate the passwords for: ' ); 216 | if ( 'all' !== $this->multiple ) { // Autocompletion is only available when a singular site is provided which is connected to WPCOM via Jetpack. 217 | $pressable_site = get_pressable_site_by_id( $input->getArgument( 'site' ) ); 218 | $question->setAutocompleterValues( \array_map( static fn( object $wp_user ) => $wp_user->email, get_wpcom_site_users( $pressable_site->url ) ?? array() ) ); 219 | } 220 | 221 | $email = $this->getHelper( 'question' )->ask( $input, $output, $question ); 222 | } 223 | } 224 | 225 | return $email; 226 | } 227 | 228 | // endregion 229 | } 230 | -------------------------------------------------------------------------------- /src/helpers/github-functions.php: -------------------------------------------------------------------------------- 1 | $owner, 68 | 'name' => $repository, 69 | 'description' => $description, 70 | 'private' => true, 71 | ) 72 | ) 73 | ); 74 | if ( \is_null( $result ) || ! \property_exists( $result, 'id' ) ) { 75 | return null; 76 | } 77 | 78 | return $result; 79 | } 80 | 81 | /** 82 | * Updates a repository. 83 | * 84 | * @param string $owner The account owner of the repository. The name is not case-sensitive. 85 | * @param string $repository The name of the repository. The name is not case-sensitive. 86 | * @param array $body The body of the request. 87 | * 88 | * @link https://docs.github.com/en/rest/repos/repos#update-a-repository 89 | * 90 | * @return object|null 91 | */ 92 | function update_github_repository( string $owner, string $repository, array $body ): ?object { 93 | $result = GitHub_API_Helper::call_api( sprintf( 'repos/%s/%s', $owner, $repository ), 'PATCH', $body ); 94 | if ( \is_null( $result ) || ! \property_exists( $result, 'id' ) ) { 95 | return null; 96 | } 97 | 98 | return $result; 99 | } 100 | 101 | /** 102 | * Deletes a repository. If OAuth is used, the delete_repo scope is required. 103 | * 104 | * @param string $owner The account owner of the repository. The name is not case-sensitive. 105 | * @param string $repository The name of the repository. The name is not case-sensitive. 106 | * 107 | * @link https://docs.github.com/en/rest/repos/repos#delete-a-repository 108 | * 109 | * @return bool 110 | */ 111 | function delete_github_repository( string $owner, string $repository ): bool { 112 | $result = GitHub_API_Helper::call_api( sprintf( 'repos/%s/%s', $owner, $repository ), 'DELETE' ); 113 | return ! \is_null( $result ); 114 | } 115 | 116 | /** 117 | * Deletes a label from a repository. 118 | * 119 | * @param string $owner The account owner of the repository. The name is not case-sensitive. 120 | * @param string $repository The name of the repository. The name is not case-sensitive. 121 | * @param string $name The name of the label. 122 | * 123 | * @link https://docs.github.com/en/rest/issues/labels#delete-a-label 124 | * 125 | * @return bool 126 | */ 127 | function delete_github_repository_label( string $owner, string $repository, string $name ): bool { 128 | $result = GitHub_API_Helper::call_api( sprintf( 'repos/%s/%s/labels/%s', $owner, $repository, rawurlencode( $name ) ), 'DELETE' ); 129 | return ! \is_null( $result ); 130 | } 131 | 132 | /** 133 | * Creates a new label for a repository. 134 | * 135 | * @param string $owner The account owner of the repository. The name is not case-sensitive. 136 | * @param string $repository The name of the repository. The name is not case-sensitive. 137 | * @param string $name The name of the label. 138 | * @param string|null $color The color of the label. 139 | * @param string|null $description A short description of the label. 140 | * 141 | * @link https://docs.github.com/en/rest/issues/labels#create-a-label 142 | * 143 | * @return object|null 144 | */ 145 | function create_github_repository_label( string $owner, string $repository, string $name, ?string $color = null, ?string $description = null ): ?object { 146 | $result = GitHub_API_Helper::call_api( 147 | sprintf( 'repos/%s/%s/labels', $owner, $repository ), 148 | 'POST', 149 | array_filter( 150 | array( 151 | 'name' => $name, 152 | 'color' => $color, 153 | 'description' => $description, 154 | ) 155 | ) 156 | ); 157 | if ( \is_null( $result ) || ! \property_exists( $result, 'id' ) ) { 158 | return null; 159 | } 160 | 161 | return $result; 162 | } 163 | 164 | /** 165 | * Replaces all topics for a repository with the given ones. 166 | * 167 | * @param string $owner The account owner of the repository. The name is not case-sensitive. 168 | * @param string $repository The name of the repository. The name is not case-sensitive. 169 | * @param array $topics The topics to replace the existing topics with. Uppercase letters are not allowed. 170 | * 171 | * @link https://docs.github.com/en/rest/repos/repos#replace-all-repository-topics 172 | * 173 | * @return object|null 174 | */ 175 | function replace_github_repository_topics( string $owner, string $repository, array $topics ): ?object { 176 | $result = GitHub_API_Helper::call_api( 177 | sprintf( 'repos/%s/%s/topics', $owner, $repository ), 178 | 'PUT', 179 | array( 180 | 'names' => $topics, 181 | ) 182 | ); 183 | if ( \is_null( $result ) || ! \property_exists( $result, 'names' ) ) { 184 | return null; 185 | } 186 | 187 | return $result; 188 | } 189 | 190 | /** 191 | * Lists all secrets available in a repository without revealing their encrypted values. 192 | * 193 | * @param string $owner The account owner of the repository. The name is not case-sensitive. 194 | * @param string $repository The name of the repository. The name is not case-sensitive. 195 | * 196 | * @return object[]|null 197 | */ 198 | function get_github_repository_secrets( string $owner, string $repository ): ?array { 199 | $result = GitHub_API_Helper::call_api( \sprintf( 'repos/%s/%s/actions/secrets', $owner, $repository ) ); 200 | if ( \is_null( $result ) || ! \property_exists( $result, 'secrets' ) ) { 201 | return null; 202 | } 203 | 204 | return $result->secrets; 205 | } 206 | 207 | /** 208 | * Gets your public key, which you need to encrypt secrets. You need to encrypt a secret before you can create or update secrets. 209 | * 210 | * @param string $owner The account owner of the repository. The name is not case-sensitive. 211 | * @param string $repository The name of the repository. The name is not case-sensitive. 212 | * 213 | * @return object|null 214 | */ 215 | function get_github_repository_public_key( string $owner, string $repository ): ?object { 216 | $result = GitHub_API_Helper::call_api( \sprintf( 'repos/%s/%s/actions/secrets/public-key', $owner, $repository ) ); 217 | if ( \is_null( $result ) || ! \property_exists( $result, 'key' ) ) { 218 | return null; 219 | } 220 | 221 | return $result; 222 | } 223 | 224 | /** 225 | * Creates or updates a repository secret with an encrypted value. 226 | * 227 | * @param string $owner The account owner of the repository. The name is not case-sensitive. 228 | * @param string $repository The name of the repository. The name is not case-sensitive. 229 | * @param string $secret_name The name of the secret. 230 | * @param string $encrypted_value Value for your secret, encrypted with LibSodium using the public key retrieved from the Get a repository public key endpoint. 231 | * @param string $key_id ID of the key you used to encrypt the secret. 232 | * 233 | * @link https://docs.github.com/en/rest/actions/secrets#create-or-update-a-repository-secret 234 | * 235 | * @return bool 236 | */ 237 | function update_github_repository_secret( string $owner, string $repository, string $secret_name, string $encrypted_value, string $key_id ): bool { 238 | $result = GitHub_API_Helper::call_api( 239 | \sprintf( 'repos/%s/%s/actions/secrets/%s', $owner, $repository, $secret_name ), 240 | 'PUT', 241 | array( 242 | 'encrypted_value' => $encrypted_value, 243 | 'key_id' => $key_id, 244 | ) 245 | ); 246 | if ( \is_null( $result ) ) { 247 | return false; 248 | } 249 | 250 | return \is_object( $result ); // On success, we just have an empty object. 251 | } 252 | -------------------------------------------------------------------------------- /src/commands/plugin-search.php: -------------------------------------------------------------------------------- 1 | setDescription( 'Search all Team51 WPCOM Jetpack sites for a specific plugin.' ) 58 | ->setHelp( "This command will output a list of Jetpack sites connected to the a8cteam51 account where a particular plugin is installed.\nThe search can be made for an exact match plugin slug, or\na general text search. Letter case is ignored in both search types.\nExample usage:\nplugin-search woocommerce\nplugin-search woo --partial\n" ); 59 | 60 | $this->addArgument( 'plugin-slug', InputArgument::REQUIRED, "The slug of the plugin to search for. This is an exact match against the plugin installation folder name,\nthe main plugin file name without the .php extension, and the Text Domain.\n" ) 61 | ->addOption( 'partial', null, InputOption::VALUE_NONE, "Optional.\nUse for general text/partial match search. Using this option will also search the plugin Name field." ) 62 | ->addOption( 'version-compare', null, InputOption::VALUE_OPTIONAL, "Optional.\nUse to find entries in comparison to your check, formatting is strict, e.g. \"<= 8.4.0\"" ); 63 | } 64 | 65 | /** 66 | * {@inheritDoc} 67 | */ 68 | protected function initialize( InputInterface $input, OutputInterface $output ): void { 69 | maybe_define_console_verbosity( $output->getVerbosity() ); 70 | 71 | $this->plugin_slug = \strtolower( $input->getArgument( 'plugin-slug' ) ); 72 | $this->partial = (bool) $input->getOption( 'partial' ); 73 | $this->version_compare = (string) $input->getOption( 'version-compare' ); 74 | } 75 | 76 | /** 77 | * {@inheritDoc} 78 | */ 79 | protected function execute( InputInterface $input, OutputInterface $output ): int { 80 | $partial_match_text = $this->partial ? 'partial' : 'exact'; 81 | $output->writeln( "Searching through the WPCOM Jetpack sites for the plugin with the slug `$this->plugin_slug` ($partial_match_text match)." ); 82 | 83 | // Get the list of sites. 84 | $sites = get_wpcom_jetpack_sites(); 85 | if ( empty( $sites ) ) { 86 | $output->writeln( 'Failed to fetch sites.' ); 87 | return 1; 88 | } 89 | 90 | $output->writeln( 'Successfully fetched ' . count( $sites ) . ' Jetpack sites.', OutputInterface::VERBOSITY_VERBOSE ); 91 | 92 | // Get the list of plugins for each site. 93 | $sites_plugins = WPCOM_API_Helper::call_api_concurrent( 94 | \array_map( 95 | static fn( $site ) => "/jetpack-blogs/$site->userblog_id/rest-api/?path=/jetpack/v4/plugins", 96 | $sites 97 | ) 98 | ); 99 | $sites_plugins = \array_combine( 100 | \array_column( $sites, 'userblog_id' ), 101 | $sites_plugins 102 | ); 103 | 104 | // Filter the sites that have the plugin installed. 105 | $sites_with_plugin = array(); 106 | $sites_not_checked = array(); 107 | foreach ( $sites_plugins as $jetpack_id => $plugins_list ) { 108 | if ( \is_null( $plugins_list ) || ! \property_exists( $plugins_list, 'data' ) ) { 109 | $sites_not_checked[ $jetpack_id ] = $sites[ $jetpack_id ]; 110 | continue; 111 | } 112 | 113 | $plugins_array = decode_json_content( encode_json_content( $plugins_list->data ), true ); 114 | foreach ( $plugins_array as $plugin_path => $plugin_data ) { 115 | $plugin_folder = \strstr( $plugin_path, '/', true ); 116 | $plugin_file = \basename( $plugin_path, '.php' ); 117 | 118 | if ( $this->is_exact_match( $plugin_data, $plugin_folder, $plugin_file ) || ( $this->partial && $this->is_partial_match( $plugin_data, $plugin_folder, $plugin_file ) ) ) { 119 | $sites_with_plugin[ $jetpack_id ]['site'] = $sites[ $jetpack_id ]; 120 | $sites_with_plugin[ $jetpack_id ]['plugins'][] = $plugin_data + array( 'path' => $plugin_path ); 121 | } 122 | } 123 | } 124 | 125 | // Output the results. 126 | $output->writeln( 'Found ' . count( $sites_with_plugin ) . ' sites with the plugin installed.', OutputInterface::VERBOSITY_VERBOSE ); 127 | $this->output_found_site_list( $sites_with_plugin, $output ); 128 | 129 | if ( ! empty( $sites_not_checked ) ) { 130 | $output->writeln( 'Could not check ' . count( $sites_not_checked ) . ' sites for the plugin.', OutputInterface::VERBOSITY_VERBOSE ); 131 | $this->output_not_checked_site_list( $sites_not_checked, $output ); 132 | } 133 | 134 | return 0; 135 | } 136 | 137 | // endregion 138 | 139 | // region HELPERS 140 | 141 | /** 142 | * Returns whether the given plugin data is an exact match for the plugin slug. 143 | * 144 | * @param array $plugin_data The plugin data as returned by the API. 145 | * @param string $plugin_folder The plugin folder name. 146 | * @param string $plugin_file The plugin file name. 147 | * 148 | * @return bool 149 | */ 150 | protected function is_exact_match( array $plugin_data, string $plugin_folder, string $plugin_file ): bool { 151 | return $this->plugin_slug === $plugin_data['TextDomain'] || $this->plugin_slug === $plugin_folder || $this->plugin_slug === $plugin_file; 152 | } 153 | 154 | /** 155 | * Returns whether the given plugin data is a partial match for the plugin slug. 156 | * 157 | * @param array $plugin_data The plugin data as returned by the API. 158 | * @param string $plugin_folder The plugin folder name. 159 | * @param string $plugin_file The plugin file name. 160 | * 161 | * @return bool 162 | */ 163 | protected function is_partial_match( array $plugin_data, string $plugin_folder, string $plugin_file ): bool { 164 | return false !== strpos( $plugin_data['TextDomain'], $this->plugin_slug ) || false !== strpos( $plugin_folder, $this->plugin_slug ) || false !== strpos( $plugin_file, $this->plugin_slug ) || false !== stripos( $plugin_data['Name'], $this->plugin_slug ); 165 | } 166 | 167 | /** 168 | * Outputs in tabular form the list of sites that have the plugin installed. 169 | * 170 | * @param array $sites_with_plugin The list of sites with the plugin installed. 171 | * @param OutputInterface $output The output object. 172 | * 173 | * @return void 174 | */ 175 | protected function output_found_site_list( array $sites_with_plugin, OutputInterface $output ): void { 176 | $table = new Table( $output ); 177 | $header_title = 'Found sites'; 178 | 179 | // Set-up version comparison. 180 | if ( ! empty( $this->version_compare ) ) { 181 | $header_title .= ' with version comparison ' . $this->version_compare; 182 | 183 | // Split the version comparison into operator and version. 184 | $version_compare_parts = explode( ' ', $this->version_compare ); 185 | 186 | // If the version comparison is invalid, output an error and return. 187 | if ( 2 !== count( $version_compare_parts ) ) { 188 | $output->writeln( 'Your comparison string is invalid. Please format as "{compare_operator} {version_number}", e.g. --version-compare="< 8.4.0"' ); 189 | return; 190 | } 191 | 192 | $version_compare_operator = $version_compare_parts[0]; 193 | $version_compare_version = $version_compare_parts[1]; 194 | } 195 | 196 | $table->setHeaderTitle( $header_title ); 197 | $table->setHeaders( array( 'Site URL', 'Plugin Name', 'Plugin Path', 'Plugin Status', 'Plugin Version' ) ); 198 | 199 | foreach ( $sites_with_plugin as $site ) { 200 | foreach ( $site['plugins'] as $match ) { 201 | // If the version comparison is set, check if the plugin version matches the comparison. 202 | if ( ! empty( $this->version_compare ) && ! version_compare( $match['Version'], $version_compare_version, $version_compare_operator ) ) { 203 | continue; 204 | } 205 | $table->addRow( array( $site['site']->domain, $match['Name'], $match['path'], ( $match['active'] ? 'Active' : 'Inactive' ), $match['Version'] ) ); 206 | } 207 | } 208 | 209 | $table->setColumnMaxWidth( 0, 128 ); 210 | $table->setStyle( 'box-double' ); 211 | $table->render(); 212 | } 213 | 214 | /** 215 | * Outputs in tabular form the list of sites that could not be checked. 216 | * 217 | * @param array $sites_not_checked The list of sites that could not be checked. 218 | * @param OutputInterface $output The output object. 219 | * 220 | * @return void 221 | */ 222 | protected function output_not_checked_site_list( array $sites_not_checked, OutputInterface $output ): void { 223 | $table = new Table( $output ); 224 | 225 | $table->setHeaderTitle( 'Sites not checked - most likely, the connection is broken' ); 226 | $table->setHeaders( array( 'Site URL', 'Site ID' ) ); 227 | 228 | foreach ( $sites_not_checked as $site ) { 229 | $table->addRow( array( $site->domain, $site->userblog_id ) ); 230 | } 231 | 232 | $table->setColumnMaxWidth( 0, 128 ); 233 | $table->setStyle( 'box-double' ); 234 | $table->render(); 235 | } 236 | 237 | // endregion 238 | } 239 | --------------------------------------------------------------------------------