logs = $logs ?? new LogRepository(); } /** * Register all subcommands. Idempotent. */ public static function register(): void { if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { return; } WP_CLI::add_command( 'abilityguard log', array( __CLASS__, 'cmd_log_root' ) ); WP_CLI::add_command( 'abilityguard rollback', array( __CLASS__, 'cmd_rollback' ) ); WP_CLI::add_command( 'abilityguard prune', array( __CLASS__, 'cmd_prune' ) ); WP_CLI::add_command( 'abilityguard approval', array( __CLASS__, 'cmd_approval_root' ) ); } /** * Dispatch `wp abilityguard log ` (list|show). * * ## OPTIONS * * * : One of: list, show. * * [] * : For `show`: numeric log id or invocation uuid. * * [--ability=] * : Filter by ability name (list). * * [--status=] * : Filter by status (list): ok|error|rolled_back|pending. * * [--per-page=] * : Page size (list, default 50, max 500). * * [--format=] * : Output format: table|csv|json|yaml. Default: table. * * [--diff] * : `show` only - render the captured snapshot's pre-state vs post-state * diff, surface by surface. Lines starting with `*` indicate a changed * key. * * @param array $args Positional args. * @param array $assoc_args Flags. */ public function cmd_log( array $args, array $assoc_args ): void { $sub = $args[0] ?? 'list'; switch ( $sub ) { case 'list': $this->log_list( $assoc_args ); return; case 'show': if ( empty( $args[1] ) ) { WP_CLI::error( 'Pass a log id or invocation uuid: wp abilityguard log show ' ); } $this->log_show( (string) $args[1], $assoc_args ); return; default: WP_CLI::error( "Unknown subcommand: {$sub}" ); } } /** * Static dispatcher used by WP_CLI::add_command. * * @param array $args Positional. * @param array $assoc_args Flags. */ public static function cmd_log_root( array $args, array $assoc_args ): void { ( new self() )->cmd_log( $args, $assoc_args ); } /** * Roll back an invocation (or a batch via --batch). * * ## OPTIONS * * [] * : Numeric log id or invocation uuid. Ignored when --batch is set. * * [--yes] * : Skip confirmation (single-id mode only). * * [--force] * : Ignore drift and restore even if live state has changed since capture. * * [--batch=] * : Comma-separated filter list, e.g. ability=acme/foo,status=ok,destructive=1. * When set, positional arg is ignored and no per-row confirmation is asked. * Matched ids are capped at 500. * * [--dry-run] * : When --batch is set, print matched ids without rolling back. * * ## EXAMPLES * * wp abilityguard rollback 42 * wp abilityguard rollback --batch=ability=acme/foo,status=ok --dry-run * wp abilityguard rollback --batch=destructive=1,status=ok --force * * @param array $args Positional. * @param array $assoc_args Flags. */ public static function cmd_rollback( array $args, array $assoc_args ): void { $force = ! empty( $assoc_args['force'] ); if ( ! empty( $assoc_args['batch'] ) ) { ( new self() )->bulk_rollback( (string) $assoc_args['batch'], $force, ! empty( $assoc_args['dry-run'] ) ); return; } if ( empty( $args[0] ) ) { WP_CLI::error( 'Pass a log id or invocation uuid, or use --batch.' ); } $ref = $args[0]; if ( empty( $assoc_args['yes'] ) ) { WP_CLI::confirm( "Roll back invocation {$ref}?", $assoc_args ); } $service = new RollbackService( new LogRepository(), new SnapshotStore() ); $result = $service->rollback( ctype_digit( $ref ) ? (int) $ref : $ref, $force ); if ( is_wp_error( $result ) ) { WP_CLI::error( $result->get_error_message() ); } WP_CLI::success( "Rolled back invocation {$ref}." ); } /** * Handle bulk rollback via --batch filter. * * @param string $batch_filter Comma-separated key=value filter list. * @param bool $force Whether to ignore drift. * @param bool $dry_run When true, only print matched ids. */ private function bulk_rollback( string $batch_filter, bool $force, bool $dry_run ): void { $filters = $this->parse_batch_filter( $batch_filter ); $filters['per_page'] = 500; $rows = $this->logs->list( $filters ); if ( empty( $rows ) ) { WP_CLI::warning( 'No invocations matched the given filters.' ); return; } $ids = array_map( static fn( $r ) => (int) $r['id'], $rows ); if ( $dry_run ) { WP_CLI::line( 'Dry-run: matched ' . count( $ids ) . ' id(s):' ); foreach ( $ids as $id ) { WP_CLI::line( " #{$id}" ); } return; } $service = new BulkRollbackService( new RollbackService( new LogRepository(), new SnapshotStore() ) ); $summary = $service->rollback_many( $ids, $force ); foreach ( $summary['rolled_back'] as $id ) { WP_CLI::line( "\u{2713} #{$id} rolled back" ); } foreach ( $summary['skipped'] as $id => $code ) { WP_CLI::line( "\u{26A0} #{$id} skipped ({$code})" ); } foreach ( $summary['errors'] as $id => $code ) { WP_CLI::line( "\u{2717} #{$id} error ({$code})" ); } $rb = count( $summary['rolled_back'] ); $sk = count( $summary['skipped'] ); $err = count( $summary['errors'] ); WP_CLI::line( "Bulk rollback complete: {$rb} rolled back, {$sk} skipped, {$err} errors." ); if ( $err > 0 ) { WP_CLI::warning( 'Some ids encountered hard errors. See output above.' ); } } /** * Parse a comma-separated batch filter string into a filters array. * * Accepted keys: ability (→ ability_name), status, destructive, caller_id. * * @param string $raw Raw filter string, e.g. "ability=acme/foo,status=ok". * * @return array */ private function parse_batch_filter( string $raw ): array { $filters = array(); $pairs = explode( ',', $raw ); foreach ( $pairs as $pair ) { $parts = explode( '=', $pair, 2 ); if ( 2 !== count( $parts ) ) { continue; } [ $key, $val ] = $parts; $key = trim( $key ); $val = trim( $val ); switch ( $key ) { case 'ability': $filters['ability_name'] = $val; break; case 'status': $filters['status'] = $val; break; case 'destructive': $filters['destructive'] = '1' === $val || 'true' === strtolower( $val ); break; case 'caller_id': $filters['caller_id'] = $val; break; } } return $filters; } /** * Prune expired log rows and orphaned snapshots. * * ## OPTIONS * * [--all-sites] * : Loop every subsite in the current multisite network, running the * prune on each. No-op on single-site installs. Use this from a * real cronjob to work around WP-Cron's visit-driven trigger model * on low-traffic subsites. * * ## EXAMPLES * * wp abilityguard prune * wp abilityguard prune --all-sites * * @param array $args Positional (unused). * @param array $assoc_args Flags. */ public static function cmd_prune( array $args, array $assoc_args ): void { unset( $args ); $all_sites = ! empty( $assoc_args['all-sites'] ); if ( $all_sites && function_exists( 'is_multisite' ) && is_multisite() ) { $site_ids = \AbilityGuard\Installer::network_site_ids(); if ( array() === $site_ids ) { WP_CLI::warning( 'No subsites found in the current network.' ); return; } $total_logs = 0; $total_snapshots = 0; $total_blobs = 0; foreach ( $site_ids as $site_id ) { switch_to_blog( $site_id ); $result = ( new RetentionService() )->prune(); $logs = (int) ( $result['logs_deleted'] ?? 0 ); $snapshots = (int) ( $result['snapshots_deleted'] ?? 0 ); $blobs = (int) ( $result['blobs_deleted'] ?? 0 ); $total_logs += $logs; $total_snapshots += $snapshots; $total_blobs += $blobs; WP_CLI::line( sprintf( ' • site %d: %d logs, %d snapshots, %d blobs', $site_id, $logs, $snapshots, $blobs ) ); restore_current_blog(); } WP_CLI::success( sprintf( 'Pruned across %d subsites: %d log row(s), %d snapshot(s), %d blob(s).', count( $site_ids ), $total_logs, $total_snapshots, $total_blobs ) ); return; } $result = ( new RetentionService() )->prune(); WP_CLI::success( sprintf( 'Pruned %d log row(s) and %d snapshot(s).', $result['logs_deleted'], $result['snapshots_deleted'] ) ); } /** * Dispatch `wp abilityguard approval ` (list|approve|reject). * * ## OPTIONS * * * : One of: list, approve, reject. * * [] * : For `approve` and `reject`: numeric approval id. * * [--status=] * : Filter by status (list): pending|approved|rejected. * * [--format=] * : Output format: table|csv|json|yaml. Default: table. * * @param array $args Positional args. * @param array $assoc_args Flags. */ public function cmd_approval( array $args, array $assoc_args ): void { $sub = $args[0] ?? 'list'; switch ( $sub ) { case 'list': $this->approval_list( $assoc_args ); return; case 'show': if ( empty( $args[1] ) ) { WP_CLI::error( 'Pass an approval id: wp abilityguard approval show ' ); } $this->approval_show( (int) $args[1] ); return; case 'approve': if ( empty( $args[1] ) ) { WP_CLI::error( 'Pass an approval id: wp abilityguard approval approve ' ); } $this->approval_decide( (int) $args[1], 'approve' ); return; case 'reject': if ( empty( $args[1] ) ) { WP_CLI::error( 'Pass an approval id: wp abilityguard approval reject ' ); } $this->approval_decide( (int) $args[1], 'reject' ); return; default: WP_CLI::error( "Unknown subcommand: {$sub}" ); } } /** * Static dispatcher for `wp abilityguard approval`. * * @param array $args Positional. * @param array $assoc_args Flags. */ public static function cmd_approval_root( array $args, array $assoc_args ): void { ( new self() )->cmd_approval( $args, $assoc_args ); } /** * Render `approval list`. * * @param array $assoc_args Flags. */ private function approval_list( array $assoc_args ): void { $filters = array(); if ( ! empty( $assoc_args['status'] ) ) { $filters['status'] = (string) $assoc_args['status']; } $repo = new \AbilityGuard\Approval\ApprovalRepository(); $rows = $repo->list( $filters ); // Annotate each row with a stage progress string so the operator // sees "stage 2 of 3" inline without a separate `approval show`. foreach ( $rows as &$row ) { $stages = $repo->find_stages( (int) $row['id'] ); if ( count( $stages ) > 1 ) { $active = null; foreach ( $stages as $s ) { if ( 'waiting' === $s['status'] ) { $active = $s; break; } } $idx = null !== $active ? ( (int) $active['stage_index'] ) + 1 : count( $stages ); $row['stage'] = sprintf( '%d / %d', $idx, count( $stages ) ); } else { $row['stage'] = '-'; } } unset( $row ); $format = (string) ( $assoc_args['format'] ?? 'table' ); $fields = array( 'id', 'log_id', 'ability_name', 'status', 'stage', 'requested_by', 'decided_by', 'decided_at', 'created_at' ); Utils\format_items( $format, $rows, $fields ); } /** * Render `approval show ` - full row plus stage chain. * * @param int $approval_id Approval row id. */ private function approval_show( int $approval_id ): void { $repo = new \AbilityGuard\Approval\ApprovalRepository(); $row = $repo->find( $approval_id ); if ( null === $row ) { WP_CLI::error( "No approval with id {$approval_id}." ); } WP_CLI::line( '' ); WP_CLI::line( sprintf( 'Approval #%d · %s · status: %s', $row['id'], $row['ability_name'], $row['status'] ) ); WP_CLI::line( sprintf( 'Log #%d · requested_by user #%d · created %s', $row['log_id'], $row['requested_by'], $row['created_at'] ) ); if ( ! empty( $row['decided_at'] ) ) { WP_CLI::line( sprintf( 'Decided by user #%d at %s', $row['decided_by'], $row['decided_at'] ) ); } $stages = $repo->find_stages( $approval_id ); if ( array() === $stages ) { WP_CLI::line( '' ); WP_CLI::line( '(no stage rows - should not happen for v1.1+ approvals)' ); return; } WP_CLI::line( '' ); WP_CLI::line( 'Stage chain:' ); foreach ( $stages as $s ) { $marker = 'waiting' === $s['status'] ? '→' : ' '; $decided = ! empty( $s['decided_at'] ) ? sprintf( ' (by user #%d at %s)', $s['decided_by'], $s['decided_at'] ) : ''; WP_CLI::line( sprintf( ' %s stage %d · %-9s · cap %s%s', $marker, (int) $s['stage_index'] + 1, $s['status'], $s['required_cap'], $decided ) ); } } /** * Approve or reject an approval row. * * @param int $approval_id Approval row id. * @param string $action 'approve' or 'reject'. */ private function approval_decide( int $approval_id, string $action ): void { $user_id = function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0; $service = new \AbilityGuard\Approval\ApprovalService(); $result = 'approve' === $action ? $service->approve( $approval_id, $user_id ) : $service->reject( $approval_id, $user_id ); if ( is_wp_error( $result ) ) { WP_CLI::error( $result->get_error_message() ); } $verb = 'approve' === $action ? 'Approved' : 'Rejected'; WP_CLI::success( "{$verb} approval {$approval_id}." ); } /** * Render `log list`. * * @param array $assoc_args Flags. */ private function log_list( array $assoc_args ): void { $filters = array(); if ( ! empty( $assoc_args['ability'] ) ) { $filters['ability_name'] = (string) $assoc_args['ability']; } if ( ! empty( $assoc_args['status'] ) ) { $filters['status'] = (string) $assoc_args['status']; } if ( ! empty( $assoc_args['per-page'] ) ) { $filters['per_page'] = (int) $assoc_args['per-page']; } $rows = $this->logs->list( $filters ); $format = (string) ( $assoc_args['format'] ?? 'table' ); $fields = array( 'id', 'invocation_id', 'ability_name', 'status', 'destructive', 'duration_ms', 'created_at' ); Utils\format_items( $format, $rows, $fields ); } /** * Render `log show`. * * @param string $reference Log id or uuid. * @param array $assoc_args Flags. */ private function log_show( string $reference, array $assoc_args ): void { $row = ctype_digit( $reference ) ? $this->logs->find( (int) $reference ) : $this->logs->find_by_invocation_id( $reference ); if ( null === $row ) { WP_CLI::error( "No invocation matched: {$reference}" ); } // --diff: print surface-by-surface changes from the captured snapshot // instead of the raw log row. Same data the admin Diff tab renders. if ( ! empty( $assoc_args['diff'] ) ) { $this->log_show_diff( (array) $row ); return; } $format = (string) ( $assoc_args['format'] ?? 'yaml' ); Utils\format_items( $format, array( $row ), array_keys( $row ) ); } /** * Render the diff for a log row's snapshot - pre-state vs post-state per surface. * * @param array $row Log row. */ private function log_show_diff( array $row ): void { $invocation_id = (string) ( $row['invocation_id'] ?? '' ); if ( '' === $invocation_id ) { WP_CLI::error( 'Log row has no invocation_id.' ); } $store = new \AbilityGuard\Snapshot\SnapshotStore(); $snapshot = $store->find_by_invocation_id( $invocation_id ); if ( null === $snapshot ) { WP_CLI::error( 'No snapshot stored for this invocation - cannot diff.' ); } $pre = is_array( $snapshot['surfaces'] ?? null ) ? $snapshot['surfaces'] : array(); $post = is_array( $snapshot['post_state'] ?? null ) ? $snapshot['post_state'] : null; WP_CLI::line( '' ); WP_CLI::line( sprintf( 'Diff for invocation %s', $invocation_id ) ); if ( null === $post ) { WP_CLI::line( '(no post-state captured - showing pre-state only)' ); } foreach ( $pre as $surface => $pre_data ) { if ( ! is_array( $pre_data ) ) { continue; } WP_CLI::line( '' ); WP_CLI::line( "[{$surface}]" ); $post_data = is_array( $post ) && isset( $post[ $surface ] ) && is_array( $post[ $surface ] ) ? $post[ $surface ] : array(); $this->print_surface_diff( $pre_data, $post_data ); } } /** * Print a flat key/before/after table for a single surface. * * @param array $pre Pre-state slice. * @param array $post Post-state slice (may be empty). */ private function print_surface_diff( array $pre, array $post ): void { $keys = array_unique( array_merge( array_keys( $pre ), array_keys( $post ) ) ); foreach ( $keys as $key ) { $before = $pre[ $key ] ?? null; $after = $post[ $key ] ?? null; $bs = is_scalar( $before ) ? (string) $before : wp_json_encode( $before ); $as = is_scalar( $after ) ? (string) $after : wp_json_encode( $after ); $marker = $bs === $as ? ' ' : '*'; WP_CLI::line( sprintf( ' %s %s: %s -> %s', $marker, (string) $key, $bs, $as ) ); } } }