] * : Render format. * --- * default: table * options: * - table * - json * - csv * - yaml * --- * * ## EXAMPLES * * wp webcal doctor * wp webcal doctor --format=json * wp webcal doctor && echo "healthy" * * @psalm-suppress PossiblyUnusedMethod -- Registered via WP_CLI::add_command(). * @psalm-suppress UnusedParam -- WP-CLI mandates the $args/$assoc_args signature. * * @param array $args Positional arguments. * @param array $assoc_args Associative arguments. */ public function __invoke( array $args, array $assoc_args ): void { $checks = $this->run_checks(); \WP_CLI\Utils\format_items( $assoc_args['format'] ?? 'table', $checks, [ 'check', 'status', 'detail' ] ); $failed = array_filter( $checks, static fn( array $c ): bool => self::STATUS_FAIL === $c['status'] ); if ( array() !== $failed ) { // halt() rather than error(): the findings are already printed, and // a script wants the exit code, not another line of prose. \WP_CLI::halt( 1 ); } } /** * Run every check and return the findings. * * Separate from __invoke so the diagnosis can be exercised without going * through WP-CLI's renderer — which is also the honest split: deciding * what is wrong and printing it are different jobs. * * @return array> */ public function run_checks(): array { return [ $this->check_versions(), $this->check_schema(), $this->check_cron(), $this->check_updates(), $this->check_tier(), $this->check_mail(), $this->check_permalinks(), ]; } /** * PHP and WordPress against what the plugin says it needs. * * @return array */ private function check_versions(): array { $wp = get_bloginfo( 'version' ); $php = PHP_VERSION; $wp_ok = version_compare( $wp, '6.6', '>=' ); $php_ok = version_compare( $php, '8.1', '>=' ); return $this->row( 'versions', $wp_ok && $php_ok ? self::STATUS_OK : self::STATUS_FAIL, sprintf( 'WordPress %s (needs 6.6+), PHP %s (needs 8.1+), plugin %s', $wp, $php, defined( 'WEBCALENDAR_WP_VERSION' ) ? WEBCALENDAR_WP_VERSION : '?' ) ); } /** * Every table the schema declares, and whether the migration finished. * * A missing table is the failure that presents as something unrelated — * a feature that silently does nothing — so it is worth naming precisely. * * @return array */ private function check_schema(): array { global $wpdb; $expected = []; foreach ( Schema::get_mvp_tables( $wpdb->prefix ) as $sql ) { if ( preg_match( '/CREATE TABLE\s+(\S+)/i', $sql, $m ) ) { $expected[] = trim( $m[1], '`' ); } } $missing = []; foreach ( $expected as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- CLI diagnostic if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) { $missing[] = $table; } } $installed = get_option( Database::VERSION_OPTION, '' ); $installed = is_scalar( $installed ) ? (string) $installed : ''; $current = Database::DB_VERSION; if ( array() !== $missing ) { return $this->row( 'schema', self::STATUS_FAIL, sprintf( '%d of %d tables missing: %s. Deactivate and reactivate the plugin to rebuild.', count( $missing ), count( $expected ), implode( ', ', array_slice( $missing, 0, 3 ) ) ) ); } if ( $installed !== $current ) { return $this->row( 'schema', self::STATUS_WARN, sprintf( 'All %d tables present, but the recorded version is "%s" and this build expects %s. The migration has not run.', count( $expected ), '' === $installed ? 'unset' : $installed, $current ) ); } return $this->row( 'schema', self::STATUS_OK, sprintf( 'All %d tables present, schema version %s', count( $expected ), $current ) ); } /** * Whether the scheduled work can actually run. * * This is the check that explains the most: reminders, the RSVP sweep and * remote-calendar sync are all cron, and a site with WP-Cron switched off * and no system cron behind it looks perfectly healthy until somebody * notices nothing has been sent for a month. * * @return array */ private function check_cron(): array { $hooks = [ 'webcal_process_reminders', 'webcal_purge_pending_rsvps', 'webcal_sync_remote_calendars' ]; $disabled = defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON; $unscheduled = []; $overdue = []; foreach ( $hooks as $hook ) { $next = wp_next_scheduled( $hook ); if ( false === $next ) { $unscheduled[] = $hook; continue; } // An hour's grace: a quiet site fires cron late by design, and // flagging that would make the check noise. if ( $next < time() - HOUR_IN_SECONDS ) { $overdue[] = $hook; } } $notes = []; if ( $disabled ) { $notes[] = 'DISABLE_WP_CRON is set — a real cron job must call wp-cron.php'; } if ( array() !== $unscheduled ) { $notes[] = sprintf( '%d job(s) not scheduled: %s', count( $unscheduled ), implode( ', ', $unscheduled ) ); } if ( array() !== $overdue ) { $notes[] = sprintf( '%d job(s) overdue by more than an hour: %s', count( $overdue ), implode( ', ', $overdue ) ); } if ( array() !== $overdue || array() !== $unscheduled ) { return $this->row( 'cron', self::STATUS_WARN, implode( '; ', $notes ) ); } return $this->row( 'cron', $disabled ? self::STATUS_WARN : self::STATUS_OK, $disabled ? implode( '; ', $notes ) : sprintf( 'All %d scheduled jobs are due on time', count( $hooks ) ) ); } /** * Whether this site can still see plugin updates. * * Included because it is a real support case rather than a hypothetical: * a site sat on an old version for a week with nothing in the admin to * say why, and the cause was that it never reached wordpress.org. * * @return array */ private function check_updates(): array { $response = wp_remote_get( 'https://api.wordpress.org/plugins/info/1.0/agenticdaisy-calendar.json', [ 'timeout' => 10 ] ); if ( is_wp_error( $response ) ) { return $this->row( 'updates', self::STATUS_WARN, 'Cannot reach api.wordpress.org: ' . $response->get_error_message() . '. Updates will not appear while this is true.' ); } $code = wp_remote_retrieve_response_code( $response ); if ( 200 !== $code ) { return $this->row( 'updates', self::STATUS_WARN, sprintf( 'api.wordpress.org answered %s', (string) $code ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $available = is_array( $body ) && isset( $body['version'] ) && is_scalar( $body['version'] ) ? (string) $body['version'] : '?'; $installed = defined( 'WEBCALENDAR_WP_VERSION' ) ? WEBCALENDAR_WP_VERSION : '?'; if ( '?' !== $available && '?' !== $installed && version_compare( $installed, $available, '<' ) ) { return $this->row( 'updates', self::STATUS_WARN, sprintf( 'Version %s is available; this site runs %s', $available, $installed ) ); } return $this->row( 'updates', self::STATUS_OK, sprintf( 'Reachable; latest is %s', $available ) ); } /** * Which tier the site is on, and whether paid features can actually run. * * Two gates decide that and they can disagree, so both are reported — * naming them, because "the feature is on but the tools are missing" is * otherwise an unexplainable state. * * @return array */ private function check_tier(): array { $license = Container::get_instance()->get( 'license' ); $tier = is_object( $license ) && method_exists( $license, 'get_tier' ) ? (string) $license->get_tier() : 'unknown'; $freemius = 'not loaded'; if ( function_exists( 'webcal_fs' ) ) { $fs = webcal_fs(); $freemius = $fs->is_registered() ? ( $fs->can_use_premium_code() ? 'premium code enabled' : 'registered, no paid licence' ) : 'not registered'; } return $this->row( 'tier', self::STATUS_OK, sprintf( 'Plugin tier "%s"; Freemius: %s', $tier, $freemius ) ); } /** * Mail settings and anything stuck in the queue. * * @return array */ private function check_mail(): array { $queue = Container::get_instance()->get( 'email_queue' ); $depth = is_object( $queue ) && method_exists( $queue, 'get_queue' ) ? count( $queue->get_queue() ) : 0; $raw = get_option( 'webcal_email_notifications', 0 ); $enabled = is_scalar( $raw ) && (string) $raw === '1'; if ( $depth > 0 ) { return $this->row( 'mail', self::STATUS_WARN, sprintf( '%d message(s) queued and unsent. The queue is drained by cron, so check that first. Notifications are %s.', $depth, $enabled ? 'on' : 'off' ) ); } return $this->row( 'mail', self::STATUS_OK, sprintf( 'Queue empty; notifications are %s', $enabled ? 'on' : 'off' ) ); } /** * Single event pages need pretty permalinks to resolve. * * @return array */ private function check_permalinks(): array { $structure = get_option( 'permalink_structure', '' ); $pretty = is_string( $structure ) && '' !== $structure; $raw = get_option( 'webcal_single_event_pages', 'off' ); $setting = is_scalar( $raw ) ? (string) $raw : 'off'; $wanted = 'off' !== $setting; if ( $wanted && ! $pretty ) { return $this->row( 'permalinks', self::STATUS_FAIL, sprintf( 'Single event pages are set to "%s" but permalinks are plain, so their URLs cannot resolve.', $setting ) ); } return $this->row( 'permalinks', self::STATUS_OK, $pretty ? sprintf( 'Pretty permalinks; single event pages "%s"', $setting ) : 'Plain permalinks; single event pages are off' ); } /** * One result row. * * @return array */ private function row( string $check, string $status, string $detail ): array { return [ 'check' => $check, 'status' => $status, 'detail' => $detail, ]; } }