From dbbcd1c9b42ab4e1864c42c671acc5714f2fea53 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 14 Sep 2026 18:25:14 -0400 Subject: [PATCH 01/16] refactor: move the plugin decisions into src Signed-off-by: YvesCesar --- composer.json | 5 + includes/account-navigation.php | 90 +----- includes/github-site-webhook.php | 258 ++++-------------- libresign-wp-customizations.php | 185 ++++++------- phpstan.neon.dist | 1 + phpunit.xml.dist | 1 + src/Account/Navigation.php | 133 +++++++++ src/Account/RootEndpoint.php | 49 ++++ src/Autoloader.php | 49 ++++ src/Github/DeployDispatch.php | 52 ++++ src/Github/SiteDeploy.php | 62 +++++ src/Github/WebhookDecision.php | 186 +++++++++++++ src/Github/WebhookGate.php | 108 ++++++++ src/Github/WebhookRequest.php | 114 ++++++++ src/Github/WebhookSignature.php | 44 +++ src/Github/WorkflowRun.php | 132 +++++++++ src/Settings/Secret.php | 106 +++++++ src/Subscription/StatusChange.php | 39 +++ .../Includes/AccountNavigationTest.php | 94 ++----- .../LibresignWpCustomizationsTest.php | 12 + tests/Support/RegistersPluginSettings.php | 26 +- tests/Unit/Account/NavigationTest.php | 191 +++++++++++++ tests/Unit/Account/RootEndpointTest.php | 69 +++++ tests/Unit/Github/DeployDispatchTest.php | 63 +++++ tests/Unit/Github/SiteDeployTest.php | 111 ++++++++ tests/Unit/Github/WebhookGateTest.php | 217 +++++++++++++++ tests/Unit/Github/WebhookRequestTest.php | 124 +++++++++ tests/Unit/Github/WebhookSignatureTest.php | 57 ++++ tests/Unit/Github/WorkflowRunTest.php | 115 ++++++++ tests/Unit/Includes/AccountNavigationTest.php | 110 -------- tests/Unit/Includes/GithubSiteWebhookTest.php | 118 -------- tests/Unit/LibresignWpCustomizationsTest.php | 65 ----- tests/Unit/Settings/SecretTest.php | 80 ++++++ tests/Unit/Subscription/StatusChangeTest.php | 58 ++++ tests/bootstrap-unit.php | 10 + 35 files changed, 2365 insertions(+), 769 deletions(-) create mode 100644 src/Account/Navigation.php create mode 100644 src/Account/RootEndpoint.php create mode 100644 src/Autoloader.php create mode 100644 src/Github/DeployDispatch.php create mode 100644 src/Github/SiteDeploy.php create mode 100644 src/Github/WebhookDecision.php create mode 100644 src/Github/WebhookGate.php create mode 100644 src/Github/WebhookRequest.php create mode 100644 src/Github/WebhookSignature.php create mode 100644 src/Github/WorkflowRun.php create mode 100644 src/Settings/Secret.php create mode 100644 src/Subscription/StatusChange.php create mode 100644 tests/Unit/Account/NavigationTest.php create mode 100644 tests/Unit/Account/RootEndpointTest.php create mode 100644 tests/Unit/Github/DeployDispatchTest.php create mode 100644 tests/Unit/Github/SiteDeployTest.php create mode 100644 tests/Unit/Github/WebhookGateTest.php create mode 100644 tests/Unit/Github/WebhookRequestTest.php create mode 100644 tests/Unit/Github/WebhookSignatureTest.php create mode 100644 tests/Unit/Github/WorkflowRunTest.php delete mode 100644 tests/Unit/Includes/AccountNavigationTest.php delete mode 100644 tests/Unit/Includes/GithubSiteWebhookTest.php delete mode 100644 tests/Unit/LibresignWpCustomizationsTest.php create mode 100644 tests/Unit/Settings/SecretTest.php create mode 100644 tests/Unit/Subscription/StatusChangeTest.php diff --git a/composer.json b/composer.json index fa69f39..f46c32a 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,11 @@ "wp-phpunit/wp-phpunit": "^7.0", "yoast/phpunit-polyfills": "^4.0" }, + "autoload": { + "psr-4": { + "LibreSign\\WPCustomizations\\": "src/" + } + }, "autoload-dev": { "psr-4": { "LibreSign\\WPCustomizations\\Tests\\": "tests/" diff --git a/includes/account-navigation.php b/includes/account-navigation.php index ae7e05f..7be4fcc 100644 --- a/includes/account-navigation.php +++ b/includes/account-navigation.php @@ -6,9 +6,14 @@ * endpoint page titles in sync with those labels, and merges the addresses * screen into the billing screen. * + * The navigation itself is decided by LibreSign\WPCustomizations\Account\Navigation; + * what is left here is the wiring into WooCommerce. + * * @package LibreSign_WP_Customizations */ +use LibreSign\WPCustomizations\Account\Navigation; + defined( 'ABSPATH' ) || exit; /** @@ -20,51 +25,11 @@ function libresign_get_current_query_vars() { return isset( $wp->query_vars ) ? $wp->query_vars : array(); } -/** - * Customer navigation entries kept, in display order. - */ -function libresign_get_account_menu_order() { - return array( 'subscriptions', 'orders', 'payment-methods', 'edit-account', 'customer-logout' ); -} - -/** - * Navigation entries renamed because their meaning changed here. - * - * Whatever is left out keeps the WooCommerce label, and with it the translation - * WooCommerce already ships for every locale. - */ -function libresign_get_account_menu_labels() { - return array( - 'subscriptions' => __( 'My subscription', 'libresign-wp-customizations' ), - 'orders' => __( 'Invoices', 'libresign-wp-customizations' ), - 'payment-methods' => __( 'Billing', 'libresign-wp-customizations' ), - ); -} - -/** - * Detail endpoints neither core nor Subscriptions already highlights, mapped to their entry. - */ -function libresign_get_account_menu_item_aliases() { - return array( - 'subscriptions' => array( 'subscription-payment-method' ), - 'payment-methods' => array( 'edit-address' ), - ); -} - /** * Reorder and relabel the My Account navigation, skipping unregistered endpoints. */ function libresign_filter_account_menu_items( $items ) { - $labels = libresign_get_account_menu_labels(); - $menu = array(); - - foreach ( libresign_get_account_menu_order() as $endpoint ) { - if ( isset( $items[ $endpoint ] ) ) { - $menu[ $endpoint ] = isset( $labels[ $endpoint ] ) ? $labels[ $endpoint ] : $items[ $endpoint ]; - } - } - - return $menu; + return Navigation::filter_items( (array) $items ); } add_filter( 'woocommerce_account_menu_items', 'libresign_filter_account_menu_items', 20 ); @@ -72,26 +37,7 @@ function libresign_filter_account_menu_items( $items ) { * Highlight the navigation entry a detail screen belongs to. */ function libresign_filter_account_menu_item_classes( $classes, $endpoint ) { - if ( in_array( 'is-active', $classes, true ) ) { - return $classes; - } - - $aliases = libresign_get_account_menu_item_aliases(); - - if ( empty( $aliases[ $endpoint ] ) ) { - return $classes; - } - - $query_vars = libresign_get_current_query_vars(); - - foreach ( $aliases[ $endpoint ] as $alias ) { - if ( isset( $query_vars[ $alias ] ) ) { - $classes[] = 'is-active'; - break; - } - } - - return $classes; + return Navigation::filter_item_classes( (array) $classes, $endpoint, libresign_get_current_query_vars() ); } add_filter( 'woocommerce_account_menu_item_classes', 'libresign_filter_account_menu_item_classes', 10, 2 ); @@ -99,25 +45,7 @@ function libresign_filter_account_menu_item_classes( $classes, $endpoint ) { * Page title of an endpoint whose navigation label was renamed. */ function libresign_filter_account_endpoint_title( $title, $endpoint ) { - $labels = libresign_get_account_menu_labels(); - - if ( ! isset( $labels[ $endpoint ] ) ) { - return $title; - } - - $query_vars = libresign_get_current_query_vars(); - $page_number = isset( $query_vars[ $endpoint ] ) ? intval( $query_vars[ $endpoint ] ) : 0; - - if ( $page_number < 2 ) { - return $labels[ $endpoint ]; - } - - return sprintf( - /* translators: 1: navigation label, 2: page number */ - __( '%1$s (page %2$d)', 'libresign-wp-customizations' ), - $labels[ $endpoint ], - $page_number - ); + return Navigation::endpoint_title( $title, $endpoint, libresign_get_current_query_vars() ); } /** @@ -144,7 +72,7 @@ function libresign_filter_view_order_endpoint_title( $title ) { * Keep the endpoint page titles in sync with the navigation labels. */ function libresign_register_account_endpoint_titles() { - foreach ( array_keys( libresign_get_account_menu_labels() ) as $endpoint ) { + foreach ( array_keys( Navigation::labels() ) as $endpoint ) { add_filter( 'woocommerce_endpoint_' . $endpoint . '_title', 'libresign_filter_account_endpoint_title', 20, 2 ); } diff --git a/includes/github-site-webhook.php b/includes/github-site-webhook.php index 89a8019..bc65b8f 100644 --- a/includes/github-site-webhook.php +++ b/includes/github-site-webhook.php @@ -2,9 +2,17 @@ /** * GitHub webhook receiver for production site deploy synchronization. * + * What a delivery means is decided by LibreSign\WPCustomizations\Github\WebhookGate; + * what is left here is the endpoint, the settings it reads and the effects it + * applies once the decision is taken. + * * @package LibreSign_WP_Customizations */ +use LibreSign\WPCustomizations\Github\SiteDeploy; +use LibreSign\WPCustomizations\Github\WebhookGate; +use LibreSign\WPCustomizations\Github\WebhookRequest; + defined( 'ABSPATH' ) || exit; const LIBRESIGN_GITHUB_SITE_WEBHOOK_NAMESPACE = 'libresign/v1'; @@ -37,64 +45,13 @@ function libresign_github_site_webhook_endpoint_url() { return rest_url( ltrim( LIBRESIGN_GITHUB_SITE_WEBHOOK_NAMESPACE . LIBRESIGN_GITHUB_SITE_WEBHOOK_ROUTE, '/' ) ); } -/** - * Decrypt an encrypted plugin option value. - * - * @param string $value Raw stored value. - * @return string - */ -function libresign_decrypt_plugin_secret( $value ) { - $value = trim( (string) $value ); - if ( '' === $value ) { - return ''; - } - - $decoded = base64_decode( $value, true ); - if ( false === $decoded ) { - return $value; - } - - $key = hash( 'sha256', AUTH_KEY . SECURE_AUTH_SALT ); - $iv = substr( hash( 'sha256', NONCE_SALT ), 0, 16 ); - - $decrypted = openssl_decrypt( $decoded, 'AES-256-CBC', $key, 0, $iv ); - - return false === $decrypted ? $value : trim( (string) $decrypted ); -} - -/** - * Encrypt a plugin option value, leaving an already encrypted one untouched. - * - * WordPress runs the sanitize callback of an option again when update_option() - * hands a value that does not exist yet to add_option(), so encrypting has to - * be idempotent or the value is stored encrypted twice. - * - * @param string $value Plain text value. - * @return string - */ -function libresign_encrypt_plugin_secret( $value ) { - $value = trim( (string) $value ); - if ( '' === $value ) { - return ''; - } - - if ( libresign_decrypt_plugin_secret( $value ) !== $value ) { - return $value; - } - - $key = hash( 'sha256', AUTH_KEY . SECURE_AUTH_SALT ); - $iv = substr( hash( 'sha256', NONCE_SALT ), 0, 16 ); - - return base64_encode( openssl_encrypt( $value, 'AES-256-CBC', $key, 0, $iv ) ); -} - /** * Resolve the configured GitHub webhook secret. * * @return string */ function libresign_github_webhook_secret() { - return libresign_decrypt_plugin_secret( get_option( 'libresign_github_webhook_secret', '' ) ); + return libresign_plugin_secret()->decrypt( get_option( 'libresign_github_webhook_secret', '' ) ); } /** @@ -141,6 +98,19 @@ function libresign_site_deploy_branch_name() { return '' === $name ? 'gh-pages' : $name; } +/** + * The run that publishes the site, as configured. + * + * @return SiteDeploy + */ +function libresign_site_deploy() { + return new SiteDeploy( + libresign_site_deploy_repository_name(), + libresign_site_deploy_branch_name(), + libresign_site_deploy_workflow_name() + ); +} + /** * Build a standardized ignored response. * @@ -155,94 +125,6 @@ function libresign_github_site_webhook_ignored_response( $data = array(), $statu return $response; } -/** - * Verify the GitHub webhook HMAC signature. - * - * @param string $body Raw request body. - * @param string $signature Signature header value. - * @param string $secret Shared secret. - * @return bool - */ -function libresign_verify_github_webhook_signature( $body, $signature, $secret ) { - $secret = trim( (string) $secret ); - $signature = trim( (string) $signature ); - - if ( '' === $body || '' === $secret || '' === $signature ) { - return false; - } - - if ( 0 === stripos( $signature, 'sha256=' ) ) { - $signature = substr( $signature, 7 ); - } - - if ( ! ctype_xdigit( $signature ) ) { - return false; - } - - $expected = hash_hmac( 'sha256', $body, $secret ); - - return hash_equals( $expected, strtolower( $signature ) ); -} - -/** - * Check whether the webhook user agent looks like GitHub Hookshot. - * - * @param string $user_agent User agent header. - * @return bool - */ -function libresign_is_github_hookshot_user_agent( $user_agent ) { - return 0 === strpos( trim( (string) $user_agent ), 'GitHub-Hookshot/' ); -} - -/** - * Extract the workflow name from the payload. - * - * @param array $payload Parsed payload. - * @return string - */ -function libresign_site_deploy_workflow_name_from_payload( $payload ) { - $workflow_run_name = isset( $payload['workflow_run']['name'] ) ? trim( (string) $payload['workflow_run']['name'] ) : ''; - if ( '' !== $workflow_run_name ) { - return $workflow_run_name; - } - - $workflow_name = isset( $payload['workflow']['name'] ) ? trim( (string) $payload['workflow']['name'] ) : ''; - - return $workflow_name; -} - -/** - * Determine whether the payload represents the production site deploy event. - * - * @param array $payload Parsed payload. - * @return bool - */ -function libresign_is_production_site_deploy_workflow_run( $payload ) { - $repository = isset( $payload['repository']['full_name'] ) ? trim( (string) $payload['repository']['full_name'] ) : ''; - $action = isset( $payload['action'] ) ? trim( (string) $payload['action'] ) : ''; - $conclusion = isset( $payload['workflow_run']['conclusion'] ) ? trim( (string) $payload['workflow_run']['conclusion'] ) : ''; - $head_branch = isset( $payload['workflow_run']['head_branch'] ) ? trim( (string) $payload['workflow_run']['head_branch'] ) : ''; - $workflow_name = libresign_site_deploy_workflow_name_from_payload( $payload ); - - if ( libresign_site_deploy_repository_name() !== $repository ) { - return false; - } - - if ( 'completed' !== $action ) { - return false; - } - - if ( 'success' !== $conclusion ) { - return false; - } - - if ( libresign_site_deploy_branch_name() !== $head_branch ) { - return false; - } - - return libresign_site_deploy_workflow_name() === $workflow_name; -} - /** * Mark a webhook delivery as processed, returning false when duplicated. * @@ -291,36 +173,27 @@ function libresign_record_site_fragment_sync_result( $status, $payload ) { * @return WP_REST_Response|WP_Error */ function libresign_receive_github_site_deploy_webhook( $request ) { - $secret = libresign_github_webhook_secret(); - if ( '' === $secret ) { - return new WP_Error( - 'libresign_github_webhook_secret_missing', - __( 'The GitHub webhook secret is not configured.', 'libresign-wp-customizations' ), - array( 'status' => 503 ) - ); - } - - $user_agent = (string) $request->get_header( 'user-agent' ); - if ( ! libresign_is_github_hookshot_user_agent( $user_agent ) ) { - return new WP_Error( - 'libresign_github_webhook_invalid_agent', - __( 'The webhook request does not look like a GitHub delivery.', 'libresign-wp-customizations' ), - array( 'status' => 403 ) - ); - } + $gate = new WebhookGate( libresign_github_webhook_secret(), libresign_site_deploy() ); + + $decision = $gate->decide( + new WebhookRequest( + (string) $request->get_header( 'user-agent' ), + (string) $request->get_header( 'x-github-event' ), + (string) $request->get_header( 'x-hub-signature-256' ), + (string) $request->get_header( 'x-github-delivery' ), + (string) $request->get_body() + ) + ); - $body = (string) $request->get_body(); - $signature = (string) $request->get_header( 'x-hub-signature-256' ); - if ( ! libresign_verify_github_webhook_signature( $body, $signature, $secret ) ) { + if ( $decision->is_rejected() ) { return new WP_Error( - 'libresign_github_webhook_invalid_signature', - __( 'Invalid GitHub webhook signature.', 'libresign-wp-customizations' ), - array( 'status' => 403 ) + $decision->code(), + $decision->message(), + array( 'status' => $decision->status() ) ); } - $event = strtolower( trim( (string) $request->get_header( 'x-github-event' ) ) ); - if ( 'ping' === $event ) { + if ( $decision->is_pong() ) { return rest_ensure_response( array( 'status' => 'pong', @@ -329,37 +202,13 @@ function libresign_receive_github_site_deploy_webhook( $request ) { ); } - if ( 'workflow_run' !== $event ) { - return libresign_github_site_webhook_ignored_response( - array( - 'reason' => 'unsupported_event', - 'event' => $event, - ) - ); - } - - $payload = json_decode( $body, true ); - if ( ! is_array( $payload ) ) { - return new WP_Error( - 'libresign_github_webhook_invalid_payload', - __( 'The GitHub webhook payload must be valid JSON.', 'libresign-wp-customizations' ), - array( 'status' => 400 ) - ); - } - - if ( ! libresign_is_production_site_deploy_workflow_run( $payload ) ) { - return libresign_github_site_webhook_ignored_response( - array( - 'reason' => 'not_production_deploy', - 'repository' => isset( $payload['repository']['full_name'] ) ? (string) $payload['repository']['full_name'] : '', - 'workflow_name' => libresign_site_deploy_workflow_name_from_payload( $payload ), - 'head_branch' => isset( $payload['workflow_run']['head_branch'] ) ? (string) $payload['workflow_run']['head_branch'] : '', - 'conclusion' => isset( $payload['workflow_run']['conclusion'] ) ? (string) $payload['workflow_run']['conclusion'] : '', - ) - ); + if ( $decision->is_ignored() ) { + return libresign_github_site_webhook_ignored_response( $decision->data() ); } + $run = $decision->workflow_run(); $delivery_id = (string) $request->get_header( 'x-github-delivery' ); + if ( ! libresign_mark_github_delivery_once( $delivery_id ) ) { return libresign_github_site_webhook_ignored_response( array( @@ -369,14 +218,13 @@ function libresign_receive_github_site_deploy_webhook( $request ) { ); } - $workflow_run = isset( $payload['workflow_run'] ) && is_array( $payload['workflow_run'] ) ? $payload['workflow_run'] : array(); - $sync_result = libresign_sync_site_fragments_from_origin( + $sync_result = libresign_sync_site_fragments_from_origin( libresign_site_origin(), array( 'header', 'footer' ), array( - 'generated_at' => isset( $workflow_run['updated_at'] ) ? (string) $workflow_run['updated_at'] : current_time( 'mysql', true ), - 'source_sha' => isset( $workflow_run['head_sha'] ) ? (string) $workflow_run['head_sha'] : '', - 'source_url' => isset( $workflow_run['html_url'] ) ? (string) $workflow_run['html_url'] : '', + 'generated_at' => '' === $run->updated_at() ? current_time( 'mysql', true ) : $run->updated_at(), + 'source_sha' => $run->head_sha(), + 'source_url' => $run->html_url(), ) ); @@ -396,11 +244,11 @@ function libresign_receive_github_site_deploy_webhook( $request ) { 'synced', array( 'delivery_id' => $delivery_id, - 'repository' => libresign_site_deploy_repository_name(), - 'workflow' => libresign_site_deploy_workflow_name_from_payload( $payload ), - 'head_branch' => isset( $workflow_run['head_branch'] ) ? (string) $workflow_run['head_branch'] : '', - 'source_sha' => isset( $workflow_run['head_sha'] ) ? (string) $workflow_run['head_sha'] : '', - 'source_url' => isset( $workflow_run['html_url'] ) ? (string) $workflow_run['html_url'] : '', + 'repository' => $run->repository(), + 'workflow' => $run->workflow_name(), + 'head_branch' => $run->head_branch(), + 'source_sha' => $run->head_sha(), + 'source_url' => $run->html_url(), 'synced' => $sync_result['synced'], ) ); @@ -409,8 +257,8 @@ function libresign_receive_github_site_deploy_webhook( $request ) { array( 'status' => 'synced', 'delivery_id' => $delivery_id, - 'repository' => libresign_site_deploy_repository_name(), - 'workflow' => libresign_site_deploy_workflow_name_from_payload( $payload ), + 'repository' => $run->repository(), + 'workflow' => $run->workflow_name(), 'origin' => $sync_result['origin'], 'synced' => $sync_result['synced'], ) diff --git a/libresign-wp-customizations.php b/libresign-wp-customizations.php index d239b4f..2986d07 100644 --- a/libresign-wp-customizations.php +++ b/libresign-wp-customizations.php @@ -23,11 +23,28 @@ * GitHub Plugin URI: https://github.com/LibreSign/libresign-wp-customizations */ +use LibreSign\WPCustomizations\Account\RootEndpoint; +use LibreSign\WPCustomizations\Autoloader; +use LibreSign\WPCustomizations\Github\DeployDispatch; +use LibreSign\WPCustomizations\Settings\Secret; +use LibreSign\WPCustomizations\Subscription\StatusChange; + defined( 'ABSPATH' ) || exit; +require_once __DIR__ . '/src/Autoloader.php'; + +Autoloader::register(); + const LIBRESIGN_WP_REWRITE_VERSION = '9'; const LIBRESIGN_FAQ_URL = 'https://libresign.coop/faq/'; +/** + * The cipher protecting the settings stored encrypted. + */ +function libresign_plugin_secret() { + return Secret::from_salts( AUTH_KEY, SECURE_AUTH_SALT, NONCE_SALT ); +} + /** * Get the WooCommerce My Account page ID. */ @@ -124,59 +141,58 @@ function libresign_wp_add_noindex_meta_tag() { * Deploy the site after change the status of post */ function libresign_trigger_github_action_on_publish($new_status, $old_status, $post) { - if ($post->post_type === 'post' && ($new_status === 'publish' || $old_status === 'publish')) { - $encripted = get_option('libresign_github_deploy_token'); - $key = hash('sha256', AUTH_KEY . SECURE_AUTH_SALT); - $iv = substr(hash('sha256', NONCE_SALT), 0, 16); - $deploy_token = openssl_decrypt(base64_decode($encripted), 'AES-256-CBC', $key, 0, $iv); - $organizationRepository = get_option('libresign_github_deploy_organization_repository'); - $response = wp_remote_post('https://api.github.com/repos/' . $organizationRepository . '/dispatches', [ - 'body' => json_encode([ - 'event_type' => 'deploy-site', - ]), - 'headers' => [ - 'Authorization' => 'Bearer ' . $deploy_token, - 'Accept' => 'application/vnd.github+json', - 'User-Agent' => 'WordPress Hook', - ], - 'timeout' => 15, - ]); + if (!DeployDispatch::triggers_deploy($new_status, $old_status, $post->post_type)) { + return; + } - $post_data = [ - 'post_id' => $post->ID, - 'post_title' => get_the_title($post->ID), - 'language' => function_exists('pll_get_post_language') ? pll_get_post_language($post->ID) : 'indefinido', - ]; + $deploy_token = libresign_plugin_secret()->decrypt(get_option('libresign_github_deploy_token')); + $organizationRepository = get_option('libresign_github_deploy_organization_repository'); + $response = wp_remote_post('https://api.github.com/repos/' . $organizationRepository . '/dispatches', [ + 'body' => wp_json_encode([ + 'event_type' => 'deploy-site', + ]), + 'headers' => [ + 'Authorization' => 'Bearer ' . $deploy_token, + 'Accept' => 'application/vnd.github+json', + 'User-Agent' => 'WordPress Hook', + ], + 'timeout' => 15, + ]); + + $post_data = [ + 'post_id' => $post->ID, + 'post_title' => get_the_title($post->ID), + 'language' => function_exists('pll_get_post_language') ? pll_get_post_language($post->ID) : 'indefinido', + ]; - if (is_wp_error($response)) { + if (is_wp_error($response)) { + $post_data = array_merge($post_data, [ + 'type' => 'error', + 'message' => $response->get_error_message(), + ]); + } else { + $code = (int) wp_remote_retrieve_response_code($response); + if ($code === 204) { $post_data = array_merge($post_data, [ - 'type' => 'error', - 'message' => $response->get_error_message(), + 'type' => 'success', + 'message' => DeployDispatch::success_message($organizationRepository), ]); } else { - $code = wp_remote_retrieve_response_code($response); - if ($code === 204) { - $post_data = array_merge($post_data, [ - 'type' => 'success', - 'message' => 'Ação de deploy enviada com sucesso. Acompanhe aqui', - ]); - } else { - $body = json_decode($response['body'], true); - $post_data = array_merge($post_data, [ - 'type' => 'error', - 'message' => "Erro ao acionar deploy.
Código: $code.
Message: {$body['message']}", - ]); - } + $body = json_decode(wp_remote_retrieve_body($response), true); + $post_data = array_merge($post_data, [ + 'type' => 'error', + 'message' => DeployDispatch::failure_message($code, isset($body['message']) ? $body['message'] : ''), + ]); } + } - $transient_key = 'libresign_github_action_status_' . get_current_user_id(); - $status_list = get_transient($transient_key); - if (!is_array($status_list)) { - $status_list = []; - } - $status_list[] = $post_data; - set_transient($transient_key, $status_list, 60); + $transient_key = 'libresign_github_action_status_' . get_current_user_id(); + $status_list = get_transient($transient_key); + if (!is_array($status_list)) { + $status_list = []; } + $status_list[] = $post_data; + set_transient($transient_key, $status_list, 60); } add_action('transition_post_status', 'libresign_trigger_github_action_on_publish', 10, 3); @@ -384,14 +400,29 @@ class="regular-text" } /** - * Encode the deploy token at database + * Encrypt a setting before it is stored, keeping the stored one when nothing was typed. + * + * The field is rendered empty on every visit, so an empty submission means the + * value was left alone. */ -add_action('admin_init', function () { +function libresign_encrypt_setting($value, $option) { + $value = trim((string) $value); + + if (empty($value)) { + return get_option($option); + } + + return libresign_plugin_secret()->encrypt($value); +} + +/** + * Register the settings of the configuration page. + */ +function libresign_register_settings() { register_setting('libresign_settings_group', 'libresign_github_deploy_token', [ 'type' => 'string', 'sanitize_callback' => function ($value) { - $encrypted = libresign_encrypt_plugin_secret($value); - return '' === $encrypted ? get_option('libresign_github_deploy_token') : $encrypted; + return libresign_encrypt_setting($value, 'libresign_github_deploy_token'); }, ]); register_setting('libresign_settings_group', 'libresign_github_deploy_organization_repository', [ @@ -401,8 +432,7 @@ class="regular-text" register_setting('libresign_settings_group', 'libresign_github_webhook_secret', [ 'type' => 'string', 'sanitize_callback' => function ($value) { - $encrypted = libresign_encrypt_plugin_secret($value); - return '' === $encrypted ? get_option('libresign_github_webhook_secret') : $encrypted; + return libresign_encrypt_setting($value, 'libresign_github_webhook_secret'); }, ]); register_setting('libresign_settings_group', 'libresign_site_origin', [ @@ -426,7 +456,8 @@ class="regular-text" return '' === $value ? 'gh-pages' : $value; }, ]); -}); +} +add_action('admin_init', 'libresign_register_settings'); /** * Register WooCommerce account endpoints at the site root when the account page is the front page. @@ -616,28 +647,12 @@ function libresign_is_root_my_account_endpoint_request() { return false; } - $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; - $request_path = trim( (string) wp_parse_url( $request_uri, PHP_URL_PATH ), '/' ); - - if ( '' === $request_path ) { - return false; - } - - $segments = explode( '/', $request_path ); - $first_slug = reset( $segments ); - $query_vars = WC()->query->get_query_vars(); - - if ( 'my-account' === $first_slug ) { - return true; - } - - foreach ( $query_vars as $query_var ) { - if ( ! empty( $query_var ) && $query_var === $first_slug ) { - return true; - } - } + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; - return false; + return RootEndpoint::matches( + (string) wp_parse_url( $request_uri, PHP_URL_PATH ), + WC()->query->get_query_vars() + ); } /** @@ -724,26 +739,6 @@ function libresign_render_nextcloud_account_button() { } add_action( 'woocommerce_before_account_navigation', 'libresign_render_nextcloud_account_button', 20 ); -/** - * Confirmation strings for each subscription status change that requires an extra step. - */ -function libresign_get_subscription_confirmation_strings( $new_status ) { - $strings = array( - 'cancelled' => array( - 'question' => __( 'Are you sure you want to cancel your subscription? This action cannot be undone.', 'libresign-wp-customizations' ), - 'confirm' => __( 'Yes, cancel subscription', 'libresign-wp-customizations' ), - 'dismiss' => __( 'No, keep subscription', 'libresign-wp-customizations' ), - ), - 'active' => array( - 'question' => __( 'Are you sure you want to reactivate your subscription?', 'libresign-wp-customizations' ), - 'confirm' => __( 'Yes, reactivate subscription', 'libresign-wp-customizations' ), - 'dismiss' => __( 'No, go back', 'libresign-wp-customizations' ), - ), - ); - - return $strings[ $new_status ] ?? null; -} - /** * Resolve a subscription the current user is allowed to update to the given status, or null. */ @@ -781,7 +776,7 @@ function libresign_intercept_subscription_status_change() { $new_status = sanitize_text_field( wp_unslash( $_GET['change_subscription_to'] ) ); - if ( ! libresign_get_subscription_confirmation_strings( $new_status ) ) { + if ( ! StatusChange::confirmation_strings( $new_status ) ) { return; } @@ -823,7 +818,7 @@ function libresign_render_subscription_change_confirmation() { } $new_status = sanitize_text_field( wp_unslash( $_GET['libresign_confirm_change'] ) ); - $strings = libresign_get_subscription_confirmation_strings( $new_status ); + $strings = StatusChange::confirmation_strings( $new_status ); if ( ! $strings ) { return; diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 4f935cb..fea8d51 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -8,6 +8,7 @@ parameters: paths: - libresign-wp-customizations.php - includes + - src - tests excludePaths: analyseAndScan: diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 8f09ad0..c68846f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -17,6 +17,7 @@ libresign-wp-customizations.php includes + src diff --git a/src/Account/Navigation.php b/src/Account/Navigation.php new file mode 100644 index 0000000..d23074a --- /dev/null +++ b/src/Account/Navigation.php @@ -0,0 +1,133 @@ + + */ + public static function labels() { + return array( + 'subscriptions' => __( 'My subscription', 'libresign-wp-customizations' ), + 'orders' => __( 'Invoices', 'libresign-wp-customizations' ), + 'payment-methods' => __( 'Billing', 'libresign-wp-customizations' ), + ); + } + + /** + * Detail endpoints neither core nor Subscriptions highlights, mapped to their entry. + * + * @return array + */ + public static function aliases() { + return array( + 'subscriptions' => array( 'subscription-payment-method' ), + 'payment-methods' => array( 'edit-address' ), + ); + } + + /** + * Reorder and relabel the navigation, skipping unregistered endpoints. + * + * @param array $items Navigation handed over by WooCommerce. + * @return array + */ + public static function filter_items( array $items ) { + $labels = self::labels(); + $menu = array(); + + foreach ( self::order() as $endpoint ) { + if ( isset( $items[ $endpoint ] ) ) { + $menu[ $endpoint ] = isset( $labels[ $endpoint ] ) ? $labels[ $endpoint ] : $items[ $endpoint ]; + } + } + + return $menu; + } + + /** + * Highlight the entry a detail screen belongs to. + * + * @param string[] $classes Classes of the entry. + * @param string $endpoint Endpoint of the entry. + * @param array $query_vars Query vars of the request being rendered. + * @return string[] + */ + public static function filter_item_classes( array $classes, $endpoint, array $query_vars ) { + if ( in_array( 'is-active', $classes, true ) ) { + return $classes; + } + + $aliases = self::aliases(); + + if ( empty( $aliases[ $endpoint ] ) ) { + return $classes; + } + + foreach ( $aliases[ $endpoint ] as $alias ) { + if ( isset( $query_vars[ $alias ] ) ) { + $classes[] = 'is-active'; + break; + } + } + + return $classes; + } + + /** + * Page title of an endpoint whose navigation label was renamed. + * + * @param string $title Title WooCommerce would use. + * @param string $endpoint Endpoint being rendered. + * @param array $query_vars Query vars of the request being rendered. + * @return string + */ + public static function endpoint_title( $title, $endpoint, array $query_vars ) { + $labels = self::labels(); + + if ( ! isset( $labels[ $endpoint ] ) ) { + return $title; + } + + $page_number = isset( $query_vars[ $endpoint ] ) ? intval( $query_vars[ $endpoint ] ) : 0; + + if ( $page_number < 2 ) { + return $labels[ $endpoint ]; + } + + return sprintf( + /* translators: 1: navigation label, 2: page number */ + __( '%1$s (page %2$d)', 'libresign-wp-customizations' ), + $labels[ $endpoint ], + $page_number + ); + } +} diff --git a/src/Account/RootEndpoint.php b/src/Account/RootEndpoint.php new file mode 100644 index 0000000..b750ede --- /dev/null +++ b/src/Account/RootEndpoint.php @@ -0,0 +1,49 @@ + $query_vars Endpoints registered by WooCommerce. + * @return bool + */ + public static function matches( $request_path, array $query_vars ) { + $request_path = trim( (string) $request_path, '/' ); + + if ( '' === $request_path ) { + return false; + } + + $segments = explode( '/', $request_path ); + $first_slug = reset( $segments ); + + if ( 'my-account' === $first_slug ) { + return true; + } + + foreach ( $query_vars as $query_var ) { + if ( ! empty( $query_var ) && $query_var === $first_slug ) { + return true; + } + } + + return false; + } +} diff --git a/src/Autoloader.php b/src/Autoloader.php new file mode 100644 index 0000000..c9d74a6 --- /dev/null +++ b/src/Autoloader.php @@ -0,0 +1,49 @@ +aqui'; + } + + /** + * Notice shown when GitHub refused the deploy. + * + * @param int $code HTTP status code returned by GitHub. + * @param string $api_message Message carried by the response body. + * @return string + */ + public static function failure_message( $code, $api_message ) { + return "Erro ao acionar deploy.
Código: {$code}.
Message: {$api_message}"; + } +} diff --git a/src/Github/SiteDeploy.php b/src/Github/SiteDeploy.php new file mode 100644 index 0000000..2679344 --- /dev/null +++ b/src/Github/SiteDeploy.php @@ -0,0 +1,62 @@ +repository = (string) $repository; + $this->branch = (string) $branch; + $this->workflow = (string) $workflow; + } + + /** + * Whether the run is the one that just put the site live. + * + * @param WorkflowRun $run Run reported by GitHub. + * @return bool + */ + public function is_production_run( WorkflowRun $run ) { + return $this->repository === $run->repository() + && 'completed' === $run->action() + && 'success' === $run->conclusion() + && $this->branch === $run->head_branch() + && $this->workflow === $run->workflow_name(); + } +} diff --git a/src/Github/WebhookDecision.php b/src/Github/WebhookDecision.php new file mode 100644 index 0000000..0a03b0a --- /dev/null +++ b/src/Github/WebhookDecision.php @@ -0,0 +1,186 @@ + + */ + private $data = array(); + + /** + * Run to deploy from. + * + * @var WorkflowRun|null + */ + private $workflow_run; + + /** + * @param string $outcome One of the class constants. + */ + private function __construct( $outcome ) { + $this->outcome = $outcome; + } + + /** + * The delivery is not one this endpoint answers. + * + * @param string $code Error code. + * @param string $message Error message. + * @param int $status HTTP status. + * @return self + */ + public static function reject( $code, $message, $status ) { + $decision = new self( self::REJECT ); + $decision->code = (string) $code; + $decision->message = (string) $message; + $decision->status = (int) $status; + + return $decision; + } + + /** + * GitHub is checking the endpoint is alive. + * + * @return self + */ + public static function pong() { + return new self( self::PONG ); + } + + /** + * A delivery this endpoint accepts but has nothing to do about. + * + * @param string $reason Why it was ignored. + * @param array $details Details worth reporting back. + * @return self + */ + public static function ignore( $reason, array $details = array() ) { + $decision = new self( self::IGNORE ); + $decision->data = array_merge( array( 'reason' => (string) $reason ), $details ); + + return $decision; + } + + /** + * The site was just published and the fragments are stale. + * + * @param WorkflowRun $run Run that published the site. + * @return self + */ + public static function deploy( WorkflowRun $run ) { + $decision = new self( self::DEPLOY ); + $decision->workflow_run = $run; + + return $decision; + } + + /** + * @return bool + */ + public function is_rejected() { + return self::REJECT === $this->outcome; + } + + /** + * @return bool + */ + public function is_pong() { + return self::PONG === $this->outcome; + } + + /** + * @return bool + */ + public function is_ignored() { + return self::IGNORE === $this->outcome; + } + + /** + * @return bool + */ + public function is_deploy() { + return self::DEPLOY === $this->outcome; + } + + /** + * @return string + */ + public function code() { + return $this->code; + } + + /** + * @return string + */ + public function message() { + return $this->message; + } + + /** + * @return int + */ + public function status() { + return $this->status; + } + + /** + * @return array + */ + public function data() { + return $this->data; + } + + /** + * @return WorkflowRun|null + */ + public function workflow_run() { + return $this->workflow_run; + } +} diff --git a/src/Github/WebhookGate.php b/src/Github/WebhookGate.php new file mode 100644 index 0000000..311d269 --- /dev/null +++ b/src/Github/WebhookGate.php @@ -0,0 +1,108 @@ +secret = (string) $secret; + $this->site_deploy = $site_deploy; + } + + /** + * What to do with a delivery. + * + * @param WebhookRequest $request Delivery received. + * @return WebhookDecision + */ + public function decide( WebhookRequest $request ) { + if ( '' === trim( $this->secret ) ) { + return WebhookDecision::reject( + 'libresign_github_webhook_secret_missing', + __( 'The GitHub webhook secret is not configured.', 'libresign-wp-customizations' ), + 503 + ); + } + + if ( ! $request->is_from_github() ) { + return WebhookDecision::reject( + 'libresign_github_webhook_invalid_agent', + __( 'The webhook request does not look like a GitHub delivery.', 'libresign-wp-customizations' ), + 403 + ); + } + + if ( ! $request->has_valid_signature( $this->secret ) ) { + return WebhookDecision::reject( + 'libresign_github_webhook_invalid_signature', + __( 'Invalid GitHub webhook signature.', 'libresign-wp-customizations' ), + 403 + ); + } + + if ( 'ping' === $request->event() ) { + return WebhookDecision::pong(); + } + + if ( 'workflow_run' !== $request->event() ) { + return WebhookDecision::ignore( 'unsupported_event', array( 'event' => $request->event() ) ); + } + + $payload = $request->payload(); + + if ( null === $payload ) { + return WebhookDecision::reject( + 'libresign_github_webhook_invalid_payload', + __( 'The GitHub webhook payload must be valid JSON.', 'libresign-wp-customizations' ), + 400 + ); + } + + $run = WorkflowRun::from_payload( $payload ); + + if ( ! $this->site_deploy->is_production_run( $run ) ) { + return WebhookDecision::ignore( + 'not_production_deploy', + array( + 'repository' => $run->repository(), + 'workflow_name' => $run->workflow_name(), + 'head_branch' => $run->head_branch(), + 'conclusion' => $run->conclusion(), + ) + ); + } + + return WebhookDecision::deploy( $run ); + } +} diff --git a/src/Github/WebhookRequest.php b/src/Github/WebhookRequest.php new file mode 100644 index 0000000..8b6d52b --- /dev/null +++ b/src/Github/WebhookRequest.php @@ -0,0 +1,114 @@ +user_agent = (string) $user_agent; + $this->event = (string) $event; + $this->signature = (string) $signature; + $this->delivery_id = (string) $delivery_id; + $this->body = (string) $body; + } + + /** + * Whether the client identifies itself as GitHub Hookshot. + * + * @return bool + */ + public function is_from_github() { + return 0 === strpos( trim( $this->user_agent ), 'GitHub-Hookshot/' ); + } + + /** + * Whether the body carries the signature of the secret. + * + * @param string $secret Shared secret. + * @return bool + */ + public function has_valid_signature( $secret ) { + return WebhookSignature::matches( $this->body, $this->signature, $secret ); + } + + /** + * Event name, normalized. + * + * @return string + */ + public function event() { + return strtolower( trim( $this->event ) ); + } + + /** + * Delivery identifier, as GitHub sent it. + * + * @return string + */ + public function delivery_id() { + return $this->delivery_id; + } + + /** + * Parsed body, or null when it is not a JSON object. + * + * @return array|null + */ + public function payload() { + $payload = json_decode( $this->body, true ); + + return is_array( $payload ) ? $payload : null; + } +} diff --git a/src/Github/WebhookSignature.php b/src/Github/WebhookSignature.php new file mode 100644 index 0000000..d76b505 --- /dev/null +++ b/src/Github/WebhookSignature.php @@ -0,0 +1,44 @@ + + */ + private $payload; + + /** + * @param array $payload Parsed payload. + */ + private function __construct( array $payload ) { + $this->payload = $payload; + } + + /** + * @param array $payload Parsed payload. + * @return self + */ + public static function from_payload( array $payload ) { + return new self( $payload ); + } + + /** + * Repository the run belongs to, as owner/name. + * + * @return string + */ + public function repository() { + return $this->text( array( 'repository', 'full_name' ) ); + } + + /** + * What happened to the run: requested, in_progress or completed. + * + * @return string + */ + public function action() { + return $this->text( array( 'action' ) ); + } + + /** + * How the run ended: success, failure, cancelled and so on. + * + * @return string + */ + public function conclusion() { + return $this->text( array( 'workflow_run', 'conclusion' ) ); + } + + /** + * Branch the run was started from. + * + * @return string + */ + public function head_branch() { + return $this->text( array( 'workflow_run', 'head_branch' ) ); + } + + /** + * Commit the run was started from. + * + * @return string + */ + public function head_sha() { + return $this->text( array( 'workflow_run', 'head_sha' ) ); + } + + /** + * Address of the run on GitHub. + * + * @return string + */ + public function html_url() { + return $this->text( array( 'workflow_run', 'html_url' ) ); + } + + /** + * When GitHub last touched the run. + * + * @return string + */ + public function updated_at() { + return $this->text( array( 'workflow_run', 'updated_at' ) ); + } + + /** + * Name of the workflow, preferring the name the run itself carries. + * + * @return string + */ + public function workflow_name() { + $run_name = $this->text( array( 'workflow_run', 'name' ) ); + + return '' === $run_name ? $this->text( array( 'workflow', 'name' ) ) : $run_name; + } + + /** + * Value at a path of the payload, as a trimmed string. + * + * @param string[] $path Keys to walk. + * @return string + */ + private function text( array $path ) { + $value = $this->payload; + + foreach ( $path as $key ) { + if ( ! is_array( $value ) || ! isset( $value[ $key ] ) ) { + return ''; + } + + $value = $value[ $key ]; + } + + return is_scalar( $value ) ? trim( (string) $value ) : ''; + } +} diff --git a/src/Settings/Secret.php b/src/Settings/Secret.php new file mode 100644 index 0000000..652e9e7 --- /dev/null +++ b/src/Settings/Secret.php @@ -0,0 +1,106 @@ +key = (string) $key; + $this->iv = (string) $iv; + } + + /** + * Derive the key from the salts of the installation. + * + * @param string $auth_key AUTH_KEY. + * @param string $secure_auth_salt SECURE_AUTH_SALT. + * @param string $nonce_salt NONCE_SALT. + * @return self + */ + public static function from_salts( $auth_key, $secure_auth_salt, $nonce_salt ) { + return new self( + hash( 'sha256', $auth_key . $secure_auth_salt ), + substr( hash( 'sha256', $nonce_salt ), 0, 16 ) + ); + } + + /** + * Encrypt a value for storage, leaving an already encrypted one untouched. + * + * WordPress runs the sanitize callback of an option again when + * update_option() hands a value that does not exist yet to add_option(), so + * encrypting has to be idempotent or the value is stored encrypted twice. + * + * @param string $value Plain text value. + * @return string + */ + public function encrypt( $value ) { + $value = trim( (string) $value ); + + if ( $this->decrypt( $value ) !== $value ) { + return $value; + } + + return base64_encode( (string) openssl_encrypt( $value, self::CIPHER, $this->key, 0, $this->iv ) ); + } + + /** + * Read a stored value, returning it untouched when it is not encrypted. + * + * A value saved before the encryption existed, or by hand, is stored in + * plain text and has to keep working. + * + * @param string $value Stored value. + * @return string + */ + public function decrypt( $value ) { + $value = trim( (string) $value ); + + if ( '' === $value ) { + return ''; + } + + $decoded = base64_decode( $value, true ); + + if ( false === $decoded ) { + return $value; + } + + $decrypted = openssl_decrypt( $decoded, self::CIPHER, $this->key, 0, $this->iv ); + + return false === $decrypted ? $value : trim( $decrypted ); + } +} diff --git a/src/Subscription/StatusChange.php b/src/Subscription/StatusChange.php new file mode 100644 index 0000000..3110295 --- /dev/null +++ b/src/Subscription/StatusChange.php @@ -0,0 +1,39 @@ + array( + 'question' => __( 'Are you sure you want to cancel your subscription? This action cannot be undone.', 'libresign-wp-customizations' ), + 'confirm' => __( 'Yes, cancel subscription', 'libresign-wp-customizations' ), + 'dismiss' => __( 'No, keep subscription', 'libresign-wp-customizations' ), + ), + 'active' => array( + 'question' => __( 'Are you sure you want to reactivate your subscription?', 'libresign-wp-customizations' ), + 'confirm' => __( 'Yes, reactivate subscription', 'libresign-wp-customizations' ), + 'dismiss' => __( 'No, go back', 'libresign-wp-customizations' ), + ), + ); + + return $strings[ $new_status ] ?? null; + } +} diff --git a/tests/Integration/Includes/AccountNavigationTest.php b/tests/Integration/Includes/AccountNavigationTest.php index ed030ac..ba2ac5c 100644 --- a/tests/Integration/Includes/AccountNavigationTest.php +++ b/tests/Integration/Includes/AccountNavigationTest.php @@ -10,7 +10,9 @@ use WP_UnitTestCase; /** - * Covers the parts that read the request being rendered or hook into WooCommerce. + * Covers the wiring: the hooks registered and the query vars handed over to + * LibreSign\WPCustomizations\Account\Navigation, which decides on its own what + * the navigation looks like. * * WooCommerce is not loaded in the test suite, so what is asserted here is the * registration of the hooks and the behaviour of the callbacks when they are @@ -95,86 +97,34 @@ public function test_the_endpoint_title_filters_are_registered_for_the_renamed_e $this->assertSame( 20, has_filter( 'woocommerce_endpoint_view-order_title', 'libresign_filter_view_order_endpoint_title' ) ); } - public function test_the_endpoint_title_follows_the_navigation_label() { - $this->render_request_with( array( 'orders' => '' ) ); - - $this->assertSame( 'Invoices', libresign_filter_account_endpoint_title( 'Orders', 'orders' ) ); - } - - public function test_the_endpoint_title_of_a_paginated_screen_carries_the_page_number() { + public function test_the_endpoint_title_is_built_from_the_request_being_rendered() { $this->render_request_with( array( 'orders' => '3' ) ); $this->assertSame( 'Invoices (page 3)', libresign_filter_account_endpoint_title( 'Orders', 'orders' ) ); } - public function test_the_first_page_is_not_numbered() { - $this->render_request_with( array( 'orders' => '1' ) ); - - $this->assertSame( 'Invoices', libresign_filter_account_endpoint_title( 'Orders', 'orders' ) ); - } - - public function test_an_entry_that_was_not_renamed_keeps_the_woocommerce_title() { - $this->render_request_with( array( 'edit-account' => '' ) ); - - $this->assertSame( 'Account details', libresign_filter_account_endpoint_title( 'Account details', 'edit-account' ) ); - } - - /** - * @dataProvider provide_detail_screens - * - * @param array $query_vars Query vars of the request being rendered. - * @param string $endpoint Navigation entry being rendered. - * @param string[] $classes Classes WooCommerce computed. - * @param bool $active Whether the entry ends up highlighted. - */ - public function test_a_detail_screen_highlights_the_entry_it_belongs_to( $query_vars, $endpoint, $classes, $active ) { - $this->render_request_with( $query_vars ); - - $filtered = libresign_filter_account_menu_item_classes( $classes, $endpoint ); - - $this->assertSame( $active, in_array( 'is-active', $filtered, true ) ); - } + public function test_the_highlighted_entry_is_built_from_the_request_being_rendered() { + $this->render_request_with( array( 'edit-address' => 'billing' ) ); - /** - * @return iterable, 1: string, 2: string[], 3: bool}> - */ - public static function provide_detail_screens() { - yield 'changing the payment method of a subscription' => array( - array( 'subscription-payment-method' => '12' ), - 'subscriptions', - array( 'subscriptions' ), - true, - ); - yield 'editing an address' => array( - array( 'edit-address' => 'billing' ), - 'payment-methods', - array( 'payment-methods' ), - true, - ); - yield 'an entry without detail screens' => array( - array( 'edit-address' => 'billing' ), - 'orders', - array( 'orders' ), - false, - ); - yield 'an entry whose detail screen is not being rendered' => array( - array( 'orders' => '' ), - 'payment-methods', - array( 'payment-methods' ), - false, - ); - yield 'an entry WooCommerce already highlighted' => array( - array(), - 'orders', - array( 'orders', 'is-active' ), - true, + $this->assertContains( + 'is-active', + libresign_filter_account_menu_item_classes( array( 'payment-methods' ), 'payment-methods' ) ); } - public function test_the_classes_of_an_unrelated_entry_are_untouched() { - $this->render_request_with( array( 'edit-address' => 'billing' ) ); - - $this->assertSame( array( 'orders' ), libresign_filter_account_menu_item_classes( array( 'orders' ), 'orders' ) ); + public function test_the_navigation_is_reordered_when_woocommerce_filters_it() { + $this->assertSame( + array( 'orders', 'customer-logout' ), + array_keys( + libresign_filter_account_menu_items( + array( + 'dashboard' => 'Dashboard', + 'orders' => 'Orders', + 'customer-logout' => 'Log out', + ) + ) + ) + ); } public function test_the_addresses_are_described_in_terms_of_the_subscription() { diff --git a/tests/Integration/LibresignWpCustomizationsTest.php b/tests/Integration/LibresignWpCustomizationsTest.php index 1540b49..ce13c1f 100644 --- a/tests/Integration/LibresignWpCustomizationsTest.php +++ b/tests/Integration/LibresignWpCustomizationsTest.php @@ -324,6 +324,18 @@ public function test_the_rewrite_rules_are_flushed_once_per_version() { $this->assertSame( LIBRESIGN_WP_REWRITE_VERSION, get_option( 'libresign_root_my_account_rewrite_version' ) ); } + public function test_the_settings_link_comes_first_on_the_plugins_screen() { + $links = libresign_add_settings_link( array( 'Deactivate' ) ); + + $this->assertCount( 2, $links ); + $this->assertStringContainsString( 'options-general.php?page=libresign-config', $links[0] ); + $this->assertSame( 'Deactivate', $links[1] ); + } + + public function test_the_settings_are_registered_on_admin_init() { + $this->assertSame( 10, has_action( 'admin_init', 'libresign_register_settings' ) ); + } + public function test_the_post_author_is_exposed_with_a_gravatar_hash() { $user_id = (int) self::factory()->user->create( array( diff --git a/tests/Support/RegistersPluginSettings.php b/tests/Support/RegistersPluginSettings.php index 020627a..6b94702 100644 --- a/tests/Support/RegistersPluginSettings.php +++ b/tests/Support/RegistersPluginSettings.php @@ -7,15 +7,11 @@ namespace LibreSign\WPCustomizations\Tests\Support; -use Closure; -use ReflectionFunction; - /** - * Runs only the plugin callbacks hooked on admin_init. + * Registers the plugin settings without firing admin_init. * * The whole hook cannot be fired from a test because core sends headers there - * and warns about being called outside wp-admin. Registering the settings from - * a named function would make this helper unnecessary. + * and warns about being called outside wp-admin. */ trait RegistersPluginSettings { @@ -25,22 +21,6 @@ trait RegistersPluginSettings { * @return void */ protected function register_plugin_settings() { - global $wp_filter; - - $plugin_file = dirname( __DIR__, 2 ) . '/libresign-wp-customizations.php'; - - foreach ( $wp_filter['admin_init']->callbacks as $callbacks ) { - foreach ( $callbacks as $callback ) { - if ( ! $callback['function'] instanceof Closure ) { - continue; - } - - $declaration = new ReflectionFunction( $callback['function'] ); - - if ( $declaration->getFileName() === $plugin_file ) { - $declaration->invoke(); - } - } - } + libresign_register_settings(); } } diff --git a/tests/Unit/Account/NavigationTest.php b/tests/Unit/Account/NavigationTest.php new file mode 100644 index 0000000..556b4b1 --- /dev/null +++ b/tests/Unit/Account/NavigationTest.php @@ -0,0 +1,191 @@ + + */ + private static function woocommerce_menu() { + return array( + 'dashboard' => 'Dashboard', + 'orders' => 'Orders', + 'subscriptions' => 'Subscriptions', + 'downloads' => 'Downloads', + 'edit-address' => 'Addresses', + 'payment-methods' => 'Payment methods', + 'edit-account' => 'Account details', + 'customer-logout' => 'Log out', + ); + } + + public function test_keeps_five_entries_in_the_configured_order() { + $this->assertSame( + array( 'subscriptions', 'orders', 'payment-methods', 'edit-account', 'customer-logout' ), + array_keys( Navigation::filter_items( self::woocommerce_menu() ) ) + ); + } + + public function test_renames_only_the_three_entries_whose_meaning_changed() { + $menu = Navigation::filter_items( self::woocommerce_menu() ); + + $this->assertSame( 'My subscription', $menu['subscriptions'] ); + $this->assertSame( 'Invoices', $menu['orders'] ); + $this->assertSame( 'Billing', $menu['payment-methods'] ); + $this->assertSame( 'Account details', $menu['edit-account'] ); + $this->assertSame( 'Log out', $menu['customer-logout'] ); + } + + /** + * @dataProvider provide_menus + * + * @param array $items Navigation handed over by WooCommerce. + * @param string[] $expected Expected resulting keys. + */ + public function test_filter_items( $items, $expected ) { + $this->assertSame( $expected, array_keys( Navigation::filter_items( $items ) ) ); + } + + /** + * @return iterable, 1: string[]}> + */ + public static function provide_menus() { + yield 'entries outside the list are dropped' => array( + self::woocommerce_menu(), + array( 'subscriptions', 'orders', 'payment-methods', 'edit-account', 'customer-logout' ), + ); + yield 'unregistered endpoints are skipped' => array( + array( + 'dashboard' => 'Dashboard', + 'orders' => 'Orders', + 'customer-logout' => 'Log out', + ), + array( 'orders', 'customer-logout' ), + ); + yield 'the input order is irrelevant' => array( + array( + 'customer-logout' => 'Log out', + 'orders' => 'Orders', + 'subscriptions' => 'Subscriptions', + ), + array( 'subscriptions', 'orders', 'customer-logout' ), + ); + yield 'an empty navigation stays empty' => array( array(), array() ); + yield 'a navigation with nothing we keep' => array( + array( + 'dashboard' => 'Dashboard', + 'downloads' => 'Downloads', + ), + array(), + ); + } + + public function test_renamed_entries_are_the_ones_with_a_matching_endpoint_title() { + $this->assertSame( + array( 'subscriptions', 'orders', 'payment-methods' ), + array_keys( Navigation::labels() ) + ); + } + + public function test_detail_endpoints_are_mapped_to_the_entry_they_belong_to() { + $aliases = Navigation::aliases(); + + $this->assertSame( array( 'subscription-payment-method' ), $aliases['subscriptions'] ); + $this->assertSame( array( 'edit-address' ), $aliases['payment-methods'] ); + $this->assertSame( array( 'subscriptions', 'payment-methods' ), array_keys( $aliases ) ); + } + + /** + * @dataProvider provide_endpoint_titles + * + * @param string $title Title WooCommerce would use. + * @param string $endpoint Endpoint being rendered. + * @param array $query_vars Query vars of the request being rendered. + * @param string $expected Expected title. + */ + public function test_endpoint_title( $title, $endpoint, $query_vars, $expected ) { + $this->assertSame( $expected, Navigation::endpoint_title( $title, $endpoint, $query_vars ) ); + } + + /** + * @return iterable, 3: string}> + */ + public static function provide_endpoint_titles() { + yield 'the title follows the navigation label' => array( 'Orders', 'orders', array( 'orders' => '' ), 'Invoices' ); + yield 'a paginated screen carries the page number' => array( 'Orders', 'orders', array( 'orders' => '3' ), 'Invoices (page 3)' ); + yield 'the first page is not numbered' => array( 'Orders', 'orders', array( 'orders' => '1' ), 'Invoices' ); + yield 'an entry that was not renamed keeps its title' => array( 'Account details', 'edit-account', array( 'edit-account' => '' ), 'Account details' ); + yield 'an endpoint absent from the request is not numbered' => array( 'Orders', 'orders', array(), 'Invoices' ); + } + + /** + * @dataProvider provide_detail_screens + * + * @param string[] $classes Classes WooCommerce computed. + * @param string $endpoint Navigation entry being rendered. + * @param array $query_vars Query vars of the request being rendered. + * @param bool $active Whether the entry ends up highlighted. + */ + public function test_filter_item_classes( $classes, $endpoint, $query_vars, $active ) { + $filtered = Navigation::filter_item_classes( $classes, $endpoint, $query_vars ); + + $this->assertSame( $active, in_array( 'is-active', $filtered, true ) ); + } + + /** + * @return iterable, 3: bool}> + */ + public static function provide_detail_screens() { + yield 'changing the payment method of a subscription' => array( + array( 'subscriptions' ), + 'subscriptions', + array( 'subscription-payment-method' => '12' ), + true, + ); + yield 'editing an address' => array( + array( 'payment-methods' ), + 'payment-methods', + array( 'edit-address' => 'billing' ), + true, + ); + yield 'an entry without detail screens' => array( + array( 'orders' ), + 'orders', + array( 'edit-address' => 'billing' ), + false, + ); + yield 'an entry whose detail screen is not being rendered' => array( + array( 'payment-methods' ), + 'payment-methods', + array( 'orders' => '' ), + false, + ); + yield 'an entry WooCommerce already highlighted' => array( + array( 'orders', 'is-active' ), + 'orders', + array(), + true, + ); + } + + public function test_the_classes_of_an_unrelated_entry_are_untouched() { + $this->assertSame( + array( 'orders' ), + Navigation::filter_item_classes( array( 'orders' ), 'orders', array( 'edit-address' => 'billing' ) ) + ); + } +} diff --git a/tests/Unit/Account/RootEndpointTest.php b/tests/Unit/Account/RootEndpointTest.php new file mode 100644 index 0000000..3a8af46 --- /dev/null +++ b/tests/Unit/Account/RootEndpointTest.php @@ -0,0 +1,69 @@ + + */ + private static function query_vars() { + return array( + 'order-pay' => 'order-pay', + 'order-received' => 'order-received', + 'orders' => 'orders', + 'edit-account' => 'edit-account', + 'edit-address' => 'edit-address', + 'payment-methods' => 'payment-methods', + 'lost-password' => 'lost-password', + 'customer-logout' => 'customer-logout', + 'subscriptions' => 'subscriptions', + 'add-payment-method' => '', + ); + } + + /** + * @dataProvider provide_paths + * + * @param string $request_path Path of the request. + * @param bool $expected Whether it is an account endpoint. + */ + public function test_matches( $request_path, $expected ) { + $this->assertSame( $expected, RootEndpoint::matches( $request_path, self::query_vars() ) ); + } + + /** + * @return iterable + */ + public static function provide_paths() { + yield 'an endpoint at the root' => array( '/payment-methods/', true ); + yield 'an endpoint with a value' => array( '/edit-address/billing/', true ); + yield 'the my-account alias' => array( '/my-account/', true ); + yield 'an endpoint under the alias' => array( '/my-account/orders/', true ); + yield 'no slashes around the path' => array( 'lost-password', true ); + yield 'the front page itself' => array( '/', false ); + yield 'an empty path' => array( '', false ); + yield 'a page' => array( '/planos/', false ); + yield 'a post' => array( '/2026/09/hello-world/', false ); + yield 'an endpoint deeper in the path' => array( '/planos/orders/', false ); + yield 'an endpoint that is not registered' => array( '/add-payment-method/', false ); + } + + public function test_a_store_without_endpoints_only_knows_the_alias() { + $this->assertTrue( RootEndpoint::matches( '/my-account/', array() ) ); + $this->assertFalse( RootEndpoint::matches( '/orders/', array() ) ); + } +} diff --git a/tests/Unit/Github/DeployDispatchTest.php b/tests/Unit/Github/DeployDispatchTest.php new file mode 100644 index 0000000..c72f564 --- /dev/null +++ b/tests/Unit/Github/DeployDispatchTest.php @@ -0,0 +1,63 @@ +assertSame( $expected, DeployDispatch::triggers_deploy( $new_status, $old_status, $post_type ) ); + } + + /** + * @return iterable + */ + public static function provide_transitions() { + yield 'publishing a post' => array( 'publish', 'draft', 'post', true ); + yield 'publishing a page' => array( 'publish', 'draft', 'page', false ); + yield 'publishing a product' => array( 'publish', 'draft', 'product', false ); + yield 'updating a published post' => array( 'publish', 'publish', 'post', true ); + yield 'trashing a published post' => array( 'trash', 'publish', 'post', true ); + yield 'trashing a published page' => array( 'trash', 'publish', 'page', false ); + yield 'trashing a published product' => array( 'trash', 'publish', 'product', false ); + yield 'saving a draft' => array( 'draft', 'draft', 'post', false ); + yield 'scheduling a post' => array( 'future', 'draft', 'post', false ); + yield 'restoring a post from trash' => array( 'draft', 'trash', 'post', false ); + } + + public function test_the_success_notice_links_to_the_actions_of_the_repository() { + $this->assertStringContainsString( + 'https://github.com/LibreSign/site/actions', + DeployDispatch::success_message( 'LibreSign/site' ) + ); + } + + public function test_the_failure_notice_carries_the_status_and_the_message() { + $message = DeployDispatch::failure_message( 401, 'Bad credentials' ); + + $this->assertStringContainsString( '401', $message ); + $this->assertStringContainsString( 'Bad credentials', $message ); + } + + public function test_the_failure_notice_survives_a_response_without_a_message() { + $this->assertStringContainsString( '500', DeployDispatch::failure_message( 500, '' ) ); + } +} diff --git a/tests/Unit/Github/SiteDeployTest.php b/tests/Unit/Github/SiteDeployTest.php new file mode 100644 index 0000000..b521f82 --- /dev/null +++ b/tests/Unit/Github/SiteDeployTest.php @@ -0,0 +1,111 @@ + $overrides Values replacing the defaults. + * @return WorkflowRun + */ + private static function production_run( array $overrides = array() ) { + return WorkflowRun::from_payload( + array_replace_recursive( + array( + 'action' => 'completed', + 'repository' => array( 'full_name' => 'LibreSign/site' ), + 'workflow_run' => array( + 'name' => 'pages build and deployment', + 'conclusion' => 'success', + 'head_branch' => 'gh-pages', + ), + ), + $overrides + ) + ); + } + + public function test_the_production_deploy_is_recognized() { + $this->assertTrue( self::site_deploy()->is_production_run( self::production_run() ) ); + } + + public function test_the_workflow_name_may_come_from_the_workflow_instead_of_the_run() { + $run = WorkflowRun::from_payload( + array( + 'action' => 'completed', + 'repository' => array( 'full_name' => 'LibreSign/site' ), + 'workflow' => array( 'name' => 'pages build and deployment' ), + 'workflow_run' => array( + 'conclusion' => 'success', + 'head_branch' => 'gh-pages', + ), + ) + ); + + $this->assertTrue( self::site_deploy()->is_production_run( $run ) ); + } + + /** + * @dataProvider provide_other_runs + * + * @param array $overrides Values replacing the production defaults. + */ + public function test_any_other_run_is_not_the_production_deploy( $overrides ) { + $this->assertFalse( self::site_deploy()->is_production_run( self::production_run( $overrides ) ) ); + } + + /** + * @return iterable}> + */ + public static function provide_other_runs() { + yield 'another repository' => array( array( 'repository' => array( 'full_name' => 'LibreSign/libresign' ) ) ); + yield 'the run is starting' => array( array( 'action' => 'requested' ) ); + yield 'the run is in progress' => array( array( 'action' => 'in_progress' ) ); + yield 'the run failed' => array( array( 'workflow_run' => array( 'conclusion' => 'failure' ) ) ); + yield 'the run was cancelled' => array( array( 'workflow_run' => array( 'conclusion' => 'cancelled' ) ) ); + yield 'another branch' => array( array( 'workflow_run' => array( 'head_branch' => 'main' ) ) ); + yield 'another workflow' => array( array( 'workflow_run' => array( 'name' => 'tests' ) ) ); + } + + public function test_the_expected_run_is_the_configured_one() { + $deploy = new SiteDeploy( 'LibreSign/staging', 'main', 'deploy' ); + + $this->assertFalse( $deploy->is_production_run( self::production_run() ) ); + $this->assertTrue( + $deploy->is_production_run( + self::production_run( + array( + 'repository' => array( 'full_name' => 'LibreSign/staging' ), + 'workflow_run' => array( + 'name' => 'deploy', + 'head_branch' => 'main', + ), + ) + ) + ) + ); + } +} diff --git a/tests/Unit/Github/WebhookGateTest.php b/tests/Unit/Github/WebhookGateTest.php new file mode 100644 index 0000000..d6a0dad --- /dev/null +++ b/tests/Unit/Github/WebhookGateTest.php @@ -0,0 +1,217 @@ + $overrides Values replacing the defaults. + * @return string + */ + private static function production_payload( array $overrides = array() ) { + return (string) wp_json_encode( + array_replace_recursive( + array( + 'action' => 'completed', + 'repository' => array( 'full_name' => 'LibreSign/site' ), + 'workflow_run' => array( + 'name' => 'pages build and deployment', + 'conclusion' => 'success', + 'head_branch' => 'gh-pages', + 'head_sha' => '0f1e2d3', + ), + ), + $overrides + ) + ); + } + + /** + * The gate as configured in production. + * + * @param string $secret Secret shared with the repository webhook. + * @return WebhookGate + */ + private static function gate( $secret = self::SECRET ) { + return new WebhookGate( + $secret, + new SiteDeploy( 'LibreSign/site', 'gh-pages', 'pages build and deployment' ) + ); + } + + /** + * A delivery signed with the secret. + * + * @param string $event X-GitHub-Event header. + * @param string $body Raw request body. + * @return WebhookRequest + */ + private static function signed_delivery( $event, $body ) { + return new WebhookRequest( + 'GitHub-Hookshot/044aadd', + $event, + 'sha256=' . hash_hmac( 'sha256', $body, self::SECRET ), + 'delivery-1', + $body + ); + } + + public function test_an_unconfigured_secret_makes_the_endpoint_unavailable() { + $decision = self::gate( '' )->decide( self::signed_delivery( 'ping', '{}' ) ); + + $this->assertTrue( $decision->is_rejected() ); + $this->assertSame( 'libresign_github_webhook_secret_missing', $decision->code() ); + $this->assertSame( 503, $decision->status() ); + } + + public function test_a_blank_secret_makes_the_endpoint_unavailable() { + $decision = self::gate( ' ' )->decide( self::signed_delivery( 'ping', '{}' ) ); + + $this->assertSame( 'libresign_github_webhook_secret_missing', $decision->code() ); + } + + public function test_a_request_from_another_client_is_rejected() { + $decision = self::gate()->decide( new WebhookRequest( 'curl/8.7.1', 'ping', '', 'delivery-1', '{}' ) ); + + $this->assertTrue( $decision->is_rejected() ); + $this->assertSame( 'libresign_github_webhook_invalid_agent', $decision->code() ); + $this->assertSame( 403, $decision->status() ); + } + + public function test_a_wrongly_signed_request_is_rejected() { + $decision = self::gate()->decide( + new WebhookRequest( + 'GitHub-Hookshot/044aadd', + 'ping', + 'sha256=' . hash_hmac( 'sha256', '{}', 'another-secret' ), + 'delivery-1', + '{}' + ) + ); + + $this->assertTrue( $decision->is_rejected() ); + $this->assertSame( 'libresign_github_webhook_invalid_signature', $decision->code() ); + $this->assertSame( 403, $decision->status() ); + } + + public function test_the_client_is_checked_before_the_signature() { + $decision = self::gate()->decide( new WebhookRequest( 'curl/8.7.1', 'ping', 'sha256=deadbeef', 'delivery-1', '{}' ) ); + + $this->assertSame( 'libresign_github_webhook_invalid_agent', $decision->code() ); + } + + public function test_a_ping_is_answered() { + $decision = self::gate()->decide( self::signed_delivery( 'ping', '{"zen":"Design for failure."}' ) ); + + $this->assertTrue( $decision->is_pong() ); + } + + public function test_another_event_is_ignored() { + $decision = self::gate()->decide( self::signed_delivery( 'push', '{"ref":"refs/heads/main"}' ) ); + + $this->assertTrue( $decision->is_ignored() ); + $this->assertSame( + array( + 'reason' => 'unsupported_event', + 'event' => 'push', + ), + $decision->data() + ); + } + + public function test_a_body_that_is_not_json_is_rejected() { + $decision = self::gate()->decide( self::signed_delivery( 'workflow_run', 'not json' ) ); + + $this->assertTrue( $decision->is_rejected() ); + $this->assertSame( 'libresign_github_webhook_invalid_payload', $decision->code() ); + $this->assertSame( 400, $decision->status() ); + } + + /** + * @dataProvider provide_non_production_payloads + * + * @param array $overrides Values replacing the production defaults. + * @param array $reported Details expected in the answer. + */ + public function test_a_run_that_is_not_the_production_deploy_is_ignored( $overrides, $reported ) { + $decision = self::gate()->decide( self::signed_delivery( 'workflow_run', self::production_payload( $overrides ) ) ); + + $this->assertTrue( $decision->is_ignored() ); + $this->assertSame( array_merge( array( 'reason' => 'not_production_deploy' ), $reported ), $decision->data() ); + } + + /** + * @return iterable, 1: array}> + */ + public static function provide_non_production_payloads() { + yield 'another repository' => array( + array( 'repository' => array( 'full_name' => 'LibreSign/libresign' ) ), + array( + 'repository' => 'LibreSign/libresign', + 'workflow_name' => 'pages build and deployment', + 'head_branch' => 'gh-pages', + 'conclusion' => 'success', + ), + ); + yield 'the run is starting' => array( + array( 'action' => 'requested' ), + array( + 'repository' => 'LibreSign/site', + 'workflow_name' => 'pages build and deployment', + 'head_branch' => 'gh-pages', + 'conclusion' => 'success', + ), + ); + yield 'the run failed' => array( + array( 'workflow_run' => array( 'conclusion' => 'failure' ) ), + array( + 'repository' => 'LibreSign/site', + 'workflow_name' => 'pages build and deployment', + 'head_branch' => 'gh-pages', + 'conclusion' => 'failure', + ), + ); + yield 'another branch' => array( + array( 'workflow_run' => array( 'head_branch' => 'main' ) ), + array( + 'repository' => 'LibreSign/site', + 'workflow_name' => 'pages build and deployment', + 'head_branch' => 'main', + 'conclusion' => 'success', + ), + ); + yield 'another workflow' => array( + array( 'workflow_run' => array( 'name' => 'tests' ) ), + array( + 'repository' => 'LibreSign/site', + 'workflow_name' => 'tests', + 'head_branch' => 'gh-pages', + 'conclusion' => 'success', + ), + ); + } + + public function test_a_production_deploy_is_handed_over_with_the_run() { + $decision = self::gate()->decide( self::signed_delivery( 'workflow_run', self::production_payload() ) ); + + $this->assertTrue( $decision->is_deploy() ); + $this->assertSame( '0f1e2d3', $decision->workflow_run()->head_sha() ); + } +} diff --git a/tests/Unit/Github/WebhookRequestTest.php b/tests/Unit/Github/WebhookRequestTest.php new file mode 100644 index 0000000..d2ecc8c --- /dev/null +++ b/tests/Unit/Github/WebhookRequestTest.php @@ -0,0 +1,124 @@ + $headers Values replacing the empty defaults. + * @return WebhookRequest + */ + private static function delivery( array $headers = array() ) { + $headers = array_merge( + array( + 'user_agent' => 'GitHub-Hookshot/044aadd', + 'event' => 'workflow_run', + 'signature' => '', + 'delivery_id' => 'delivery-1', + 'body' => '{}', + ), + $headers + ); + + return new WebhookRequest( + $headers['user_agent'], + $headers['event'], + $headers['signature'], + $headers['delivery_id'], + $headers['body'] + ); + } + + /** + * @dataProvider provide_user_agents + * + * @param string $user_agent User agent header. + * @param bool $expected Expected result. + */ + public function test_is_from_github( $user_agent, $expected ) { + $this->assertSame( $expected, self::delivery( array( 'user_agent' => $user_agent ) )->is_from_github() ); + } + + /** + * @return iterable + */ + public static function provide_user_agents() { + yield 'the agent GitHub sends' => array( 'GitHub-Hookshot/044aadd', true ); + yield 'surrounding whitespace' => array( " GitHub-Hookshot/044aadd\n", true ); + yield 'the match is case sensitive' => array( 'github-hookshot/044aadd', false ); + yield 'the prefix has to come first' => array( 'curl/8.7.1 GitHub-Hookshot/044aadd', false ); + yield 'another client' => array( 'curl/8.7.1', false ); + yield 'no user agent at all' => array( '', false ); + } + + /** + * @dataProvider provide_events + * + * @param string $event X-GitHub-Event header. + * @param string $expected Normalized event name. + */ + public function test_event( $event, $expected ) { + $this->assertSame( $expected, self::delivery( array( 'event' => $event ) )->event() ); + } + + /** + * @return iterable + */ + public static function provide_events() { + yield 'as GitHub sends it' => array( 'workflow_run', 'workflow_run' ); + yield 'uppercase' => array( 'Workflow_Run', 'workflow_run' ); + yield 'padded' => array( " ping \n", 'ping' ); + yield 'absent' => array( '', '' ); + } + + public function test_the_delivery_identifier_is_kept_as_received() { + $this->assertSame( ' delivery-1 ', self::delivery( array( 'delivery_id' => ' delivery-1 ' ) )->delivery_id() ); + } + + public function test_the_signature_is_checked_against_the_body() { + $delivery = self::delivery( + array( + 'body' => '{"action":"completed"}', + 'signature' => 'sha256=' . hash_hmac( 'sha256', '{"action":"completed"}', 'the-secret' ), + ) + ); + + $this->assertTrue( $delivery->has_valid_signature( 'the-secret' ) ); + $this->assertFalse( $delivery->has_valid_signature( 'another-secret' ) ); + } + + /** + * @dataProvider provide_bodies + * + * @param string $body Raw request body. + * @param array|null $expected Expected payload. + */ + public function test_payload( $body, $expected ) { + $this->assertSame( $expected, self::delivery( array( 'body' => $body ) )->payload() ); + } + + /** + * @return iterable|null}> + */ + public static function provide_bodies() { + yield 'a json object' => array( '{"action":"completed"}', array( 'action' => 'completed' ) ); + yield 'an empty object' => array( '{}', array() ); + yield 'a json array' => array( '[1,2]', array( 1, 2 ) ); + yield 'not json at all' => array( 'not json', null ); + yield 'a json scalar' => array( '"completed"', null ); + yield 'an empty body' => array( '', null ); + } +} diff --git a/tests/Unit/Github/WebhookSignatureTest.php b/tests/Unit/Github/WebhookSignatureTest.php new file mode 100644 index 0000000..012d581 --- /dev/null +++ b/tests/Unit/Github/WebhookSignatureTest.php @@ -0,0 +1,57 @@ +assertSame( $expected, WebhookSignature::matches( $body, $signature, $secret ) ); + } + + /** + * @return iterable + */ + public static function provide_signatures() { + yield 'signature sent the way GitHub sends it' => array( self::BODY, 'sha256=' . self::digest(), self::SECRET, true ); + yield 'prefix is optional' => array( self::BODY, self::digest(), self::SECRET, true ); + yield 'prefix is case insensitive' => array( self::BODY, 'SHA256=' . self::digest(), self::SECRET, true ); + yield 'hexadecimal is case insensitive' => array( self::BODY, strtoupper( self::digest() ), self::SECRET, true ); + yield 'surrounding whitespace is trimmed' => array( self::BODY, ' sha256=' . self::digest() . "\n", ' ' . self::SECRET . ' ', true ); + yield 'another secret does not match' => array( self::BODY, 'sha256=' . self::digest(), 'another-secret', false ); + yield 'a tampered body does not match' => array( self::BODY . ' ', 'sha256=' . self::digest(), self::SECRET, false ); + yield 'an empty body is never valid' => array( '', 'sha256=' . self::digest(), self::SECRET, false ); + yield 'an empty secret is never valid' => array( self::BODY, 'sha256=' . self::digest(), '', false ); + yield 'an empty signature is never valid' => array( self::BODY, '', self::SECRET, false ); + yield 'a non hexadecimal signature is refused' => array( self::BODY, 'sha256=not-a-digest', self::SECRET, false ); + yield 'a truncated signature is refused' => array( self::BODY, 'sha256=' . substr( self::digest(), 0, 32 ), self::SECRET, false ); + } +} diff --git a/tests/Unit/Github/WorkflowRunTest.php b/tests/Unit/Github/WorkflowRunTest.php new file mode 100644 index 0000000..d550afb --- /dev/null +++ b/tests/Unit/Github/WorkflowRunTest.php @@ -0,0 +1,115 @@ + 'completed', + 'repository' => array( 'full_name' => 'LibreSign/site' ), + 'workflow_run' => array( + 'name' => 'pages build and deployment', + 'conclusion' => 'success', + 'head_branch' => 'gh-pages', + 'head_sha' => '0f1e2d3', + 'html_url' => 'https://github.com/LibreSign/site/actions/runs/1', + 'updated_at' => '2026-09-14T12:00:00Z', + ), + ) + ); + + $this->assertSame( 'LibreSign/site', $run->repository() ); + $this->assertSame( 'completed', $run->action() ); + $this->assertSame( 'success', $run->conclusion() ); + $this->assertSame( 'gh-pages', $run->head_branch() ); + $this->assertSame( '0f1e2d3', $run->head_sha() ); + $this->assertSame( 'https://github.com/LibreSign/site/actions/runs/1', $run->html_url() ); + $this->assertSame( '2026-09-14T12:00:00Z', $run->updated_at() ); + $this->assertSame( 'pages build and deployment', $run->workflow_name() ); + } + + public function test_an_empty_payload_answers_every_field_with_an_empty_string() { + $run = WorkflowRun::from_payload( array() ); + + $this->assertSame( '', $run->repository() ); + $this->assertSame( '', $run->action() ); + $this->assertSame( '', $run->conclusion() ); + $this->assertSame( '', $run->head_branch() ); + $this->assertSame( '', $run->head_sha() ); + $this->assertSame( '', $run->html_url() ); + $this->assertSame( '', $run->updated_at() ); + $this->assertSame( '', $run->workflow_name() ); + } + + /** + * @dataProvider provide_payloads + * + * @param array $payload Parsed webhook payload. + * @param string $expected Expected workflow name. + */ + public function test_workflow_name( $payload, $expected ) { + $this->assertSame( $expected, WorkflowRun::from_payload( $payload )->workflow_name() ); + } + + /** + * @return iterable, 1: string}> + */ + public static function provide_payloads() { + yield 'the run name wins' => array( + array( + 'workflow_run' => array( 'name' => 'pages build and deployment' ), + 'workflow' => array( 'name' => 'deploy' ), + ), + 'pages build and deployment', + ); + yield 'falls back to the workflow name' => array( + array( 'workflow' => array( 'name' => 'deploy' ) ), + 'deploy', + ); + yield 'a blank run name falls back too' => array( + array( + 'workflow_run' => array( 'name' => ' ' ), + 'workflow' => array( 'name' => 'deploy' ), + ), + 'deploy', + ); + yield 'names are trimmed' => array( + array( 'workflow_run' => array( 'name' => " deploy \n" ) ), + 'deploy', + ); + yield 'neither is present' => array( array( 'action' => 'completed' ), '' ); + yield 'an empty payload' => array( array(), '' ); + } + + /** + * @dataProvider provide_unexpected_shapes + * + * @param array $payload Payload in a shape GitHub does not send. + */ + public function test_a_field_that_is_not_a_string_is_read_as_empty( $payload ) { + $this->assertSame( '', WorkflowRun::from_payload( $payload )->head_branch() ); + } + + /** + * @return iterable}> + */ + public static function provide_unexpected_shapes() { + yield 'the run is not an array' => array( array( 'workflow_run' => 'gh-pages' ) ); + yield 'the branch is an array' => array( array( 'workflow_run' => array( 'head_branch' => array( 'gh-pages' ) ) ) ); + yield 'the branch is null' => array( array( 'workflow_run' => array( 'head_branch' => null ) ) ); + yield 'the run key is missing' => array( array( 'repository' => array( 'full_name' => 'LibreSign/site' ) ) ); + } +} diff --git a/tests/Unit/Includes/AccountNavigationTest.php b/tests/Unit/Includes/AccountNavigationTest.php deleted file mode 100644 index 1eef2b4..0000000 --- a/tests/Unit/Includes/AccountNavigationTest.php +++ /dev/null @@ -1,110 +0,0 @@ - - */ - private static function woocommerce_menu() { - return array( - 'dashboard' => 'Dashboard', - 'orders' => 'Orders', - 'subscriptions' => 'Subscriptions', - 'downloads' => 'Downloads', - 'edit-address' => 'Addresses', - 'payment-methods' => 'Payment methods', - 'edit-account' => 'Account details', - 'customer-logout' => 'Log out', - ); - } - - public function test_keeps_five_entries_in_the_configured_order() { - $this->assertSame( - array( 'subscriptions', 'orders', 'payment-methods', 'edit-account', 'customer-logout' ), - array_keys( libresign_filter_account_menu_items( self::woocommerce_menu() ) ) - ); - } - - public function test_renames_only_the_three_entries_whose_meaning_changed() { - $menu = libresign_filter_account_menu_items( self::woocommerce_menu() ); - - $this->assertSame( 'My subscription', $menu['subscriptions'] ); - $this->assertSame( 'Invoices', $menu['orders'] ); - $this->assertSame( 'Billing', $menu['payment-methods'] ); - $this->assertSame( 'Account details', $menu['edit-account'] ); - $this->assertSame( 'Log out', $menu['customer-logout'] ); - } - - /** - * @dataProvider provide_menus - * - * @param array $items Navigation handed over by WooCommerce. - * @param string[] $expected Expected resulting keys. - */ - public function test_filter_account_menu_items( $items, $expected ) { - $this->assertSame( $expected, array_keys( libresign_filter_account_menu_items( $items ) ) ); - } - - /** - * @return iterable, 1: string[]}> - */ - public static function provide_menus() { - yield 'entries outside the list are dropped' => array( - self::woocommerce_menu(), - array( 'subscriptions', 'orders', 'payment-methods', 'edit-account', 'customer-logout' ), - ); - yield 'unregistered endpoints are skipped' => array( - array( - 'dashboard' => 'Dashboard', - 'orders' => 'Orders', - 'customer-logout' => 'Log out', - ), - array( 'orders', 'customer-logout' ), - ); - yield 'the input order is irrelevant' => array( - array( - 'customer-logout' => 'Log out', - 'orders' => 'Orders', - 'subscriptions' => 'Subscriptions', - ), - array( 'subscriptions', 'orders', 'customer-logout' ), - ); - yield 'an empty navigation stays empty' => array( array(), array() ); - yield 'a navigation with nothing we keep' => array( - array( - 'dashboard' => 'Dashboard', - 'downloads' => 'Downloads', - ), - array(), - ); - } - - public function test_renamed_entries_are_the_ones_with_a_matching_endpoint_title() { - $this->assertSame( - array_keys( libresign_get_account_menu_labels() ), - array( 'subscriptions', 'orders', 'payment-methods' ) - ); - } - - public function test_detail_endpoints_are_mapped_to_the_entry_they_belong_to() { - $aliases = libresign_get_account_menu_item_aliases(); - - $this->assertSame( array( 'subscription-payment-method' ), $aliases['subscriptions'] ); - $this->assertSame( array( 'edit-address' ), $aliases['payment-methods'] ); - $this->assertSame( array( 'subscriptions', 'payment-methods' ), array_keys( $aliases ) ); - } -} diff --git a/tests/Unit/Includes/GithubSiteWebhookTest.php b/tests/Unit/Includes/GithubSiteWebhookTest.php deleted file mode 100644 index 310122c..0000000 --- a/tests/Unit/Includes/GithubSiteWebhookTest.php +++ /dev/null @@ -1,118 +0,0 @@ -assertSame( $expected, libresign_verify_github_webhook_signature( $body, $signature, $secret ) ); - } - - /** - * @return iterable - */ - public static function provide_signatures() { - yield 'signature sent the way GitHub sends it' => array( self::BODY, 'sha256=' . self::digest(), self::SECRET, true ); - yield 'prefix is optional' => array( self::BODY, self::digest(), self::SECRET, true ); - yield 'prefix is case insensitive' => array( self::BODY, 'SHA256=' . self::digest(), self::SECRET, true ); - yield 'hexadecimal is case insensitive' => array( self::BODY, strtoupper( self::digest() ), self::SECRET, true ); - yield 'surrounding whitespace is trimmed' => array( self::BODY, ' sha256=' . self::digest() . "\n", ' ' . self::SECRET . ' ', true ); - yield 'another secret does not match' => array( self::BODY, 'sha256=' . self::digest(), 'another-secret', false ); - yield 'a tampered body does not match' => array( self::BODY . ' ', 'sha256=' . self::digest(), self::SECRET, false ); - yield 'an empty body is never valid' => array( '', 'sha256=' . self::digest(), self::SECRET, false ); - yield 'an empty secret is never valid' => array( self::BODY, 'sha256=' . self::digest(), '', false ); - yield 'an empty signature is never valid' => array( self::BODY, '', self::SECRET, false ); - yield 'a non hexadecimal signature is refused' => array( self::BODY, 'sha256=not-a-digest', self::SECRET, false ); - yield 'a truncated signature is refused' => array( self::BODY, 'sha256=' . substr( self::digest(), 0, 32 ), self::SECRET, false ); - } - - /** - * @dataProvider provide_user_agents - * - * @param string $user_agent User agent header. - * @param bool $expected Expected result. - */ - public function test_is_github_hookshot_user_agent( $user_agent, $expected ) { - $this->assertSame( $expected, libresign_is_github_hookshot_user_agent( $user_agent ) ); - } - - /** - * @return iterable - */ - public static function provide_user_agents() { - yield 'the agent GitHub sends' => array( 'GitHub-Hookshot/044aadd', true ); - yield 'surrounding whitespace' => array( " GitHub-Hookshot/044aadd\n", true ); - yield 'the match is case sensitive' => array( 'github-hookshot/044aadd', false ); - yield 'the prefix has to come first' => array( 'curl/8.7.1 GitHub-Hookshot/044aadd', false ); - yield 'another client' => array( 'curl/8.7.1', false ); - yield 'no user agent at all' => array( '', false ); - } - - /** - * @dataProvider provide_payloads - * - * @param array $payload Parsed webhook payload. - * @param string $expected Expected workflow name. - */ - public function test_site_deploy_workflow_name_from_payload( $payload, $expected ) { - $this->assertSame( $expected, libresign_site_deploy_workflow_name_from_payload( $payload ) ); - } - - /** - * @return iterable, 1: string}> - */ - public static function provide_payloads() { - yield 'the run name wins' => array( - array( - 'workflow_run' => array( 'name' => 'pages build and deployment' ), - 'workflow' => array( 'name' => 'deploy' ), - ), - 'pages build and deployment', - ); - yield 'falls back to the workflow name' => array( - array( 'workflow' => array( 'name' => 'deploy' ) ), - 'deploy', - ); - yield 'a blank run name falls back too' => array( - array( - 'workflow_run' => array( 'name' => ' ' ), - 'workflow' => array( 'name' => 'deploy' ), - ), - 'deploy', - ); - yield 'names are trimmed' => array( - array( 'workflow_run' => array( 'name' => " deploy \n" ) ), - 'deploy', - ); - yield 'neither is present' => array( array( 'action' => 'completed' ), '' ); - yield 'an empty payload' => array( array(), '' ); - } -} diff --git a/tests/Unit/LibresignWpCustomizationsTest.php b/tests/Unit/LibresignWpCustomizationsTest.php deleted file mode 100644 index aa05af8..0000000 --- a/tests/Unit/LibresignWpCustomizationsTest.php +++ /dev/null @@ -1,65 +0,0 @@ -assertNull( $strings ); - - return; - } - - $this->assertSame( array( 'question', 'confirm', 'dismiss' ), array_keys( $strings ) ); - $this->assertNotSame( '', trim( $strings['question'] ) ); - $this->assertNotSame( '', trim( $strings['confirm'] ) ); - $this->assertNotSame( '', trim( $strings['dismiss'] ) ); - } - - /** - * @return iterable - */ - public static function provide_statuses() { - yield 'cancelling asks for confirmation' => array( 'cancelled', true ); - yield 'reactivating asks for confirmation' => array( 'active', true ); - yield 'on hold goes straight through' => array( 'on-hold', false ); - yield 'pending cancel goes straight through' => array( 'pending-cancel', false ); - yield 'an unknown status goes through' => array( 'whatever', false ); - yield 'no status at all goes through' => array( '', false ); - } - - public function test_cancelling_and_reactivating_do_not_share_strings() { - $cancel = libresign_get_subscription_confirmation_strings( 'cancelled' ); - $reactivate = libresign_get_subscription_confirmation_strings( 'active' ); - - $this->assertNotSame( $cancel['question'], $reactivate['question'] ); - $this->assertNotSame( $cancel['confirm'], $reactivate['confirm'] ); - } - - public function test_settings_link_comes_first_on_the_plugins_screen() { - $links = libresign_add_settings_link( array( 'Deactivate' ) ); - - $this->assertCount( 2, $links ); - $this->assertStringContainsString( 'options-general.php?page=libresign-config', $links[0] ); - $this->assertSame( 'Deactivate', $links[1] ); - } -} diff --git a/tests/Unit/Settings/SecretTest.php b/tests/Unit/Settings/SecretTest.php new file mode 100644 index 0000000..44b9896 --- /dev/null +++ b/tests/Unit/Settings/SecretTest.php @@ -0,0 +1,80 @@ +assertSame( $value, $secret->decrypt( $secret->encrypt( $value ) ) ); + } + + /** + * @return iterable + */ + public static function provide_values() { + yield 'a personal access token' => array( 'ghp_0123456789abcdefghijklmnopqrstuvwxyz' ); + yield 'a webhook secret' => array( 'a-shared-secret' ); + yield 'punctuation' => array( '{"not":"json"} + / = %' ); + yield 'accents' => array( 'segredo com acentuação' ); + yield 'a single character' => array( 'x' ); + } + + public function test_the_stored_value_is_not_the_value() { + $this->assertNotSame( 'a-shared-secret', self::secret()->encrypt( 'a-shared-secret' ) ); + } + + public function test_another_installation_cannot_read_the_value() { + $stored = self::secret()->encrypt( 'a-shared-secret' ); + $other = Secret::from_salts( 'another-auth-key', 'another-secure-auth-salt', 'another-nonce-salt' ); + + $this->assertNotSame( 'a-shared-secret', $other->decrypt( $stored ) ); + } + + /** + * @dataProvider provide_stored_values + * + * @param string $stored Value found in the database. + * @param string $expected Value the plugin uses. + */ + public function test_a_value_that_is_not_encrypted_is_used_as_it_is( $stored, $expected ) { + $this->assertSame( $expected, self::secret()->decrypt( $stored ) ); + } + + /** + * @return iterable + */ + public static function provide_stored_values() { + yield 'a secret saved by hand' => array( 'plain-secret', 'plain-secret' ); + yield 'surrounding whitespace' => array( " plain-secret \n", 'plain-secret' ); + yield 'nothing saved yet' => array( '', '' ); + yield 'blanks only' => array( ' ', '' ); + yield 'base64 that is not a secret' => array( 'cGxhaW4tc2VjcmV0', 'cGxhaW4tc2VjcmV0' ); + } +} diff --git a/tests/Unit/Subscription/StatusChangeTest.php b/tests/Unit/Subscription/StatusChangeTest.php new file mode 100644 index 0000000..50530e0 --- /dev/null +++ b/tests/Unit/Subscription/StatusChangeTest.php @@ -0,0 +1,58 @@ +assertNull( $strings ); + + return; + } + + $this->assertSame( array( 'question', 'confirm', 'dismiss' ), array_keys( $strings ) ); + $this->assertNotSame( '', trim( $strings['question'] ) ); + $this->assertNotSame( '', trim( $strings['confirm'] ) ); + $this->assertNotSame( '', trim( $strings['dismiss'] ) ); + } + + /** + * @return iterable + */ + public static function provide_statuses() { + yield 'cancelling asks for confirmation' => array( 'cancelled', true ); + yield 'reactivating asks for confirmation' => array( 'active', true ); + yield 'on hold goes straight through' => array( 'on-hold', false ); + yield 'pending cancel goes straight through' => array( 'pending-cancel', false ); + yield 'an unknown status goes through' => array( 'whatever', false ); + yield 'no status at all goes through' => array( '', false ); + } + + public function test_cancelling_and_reactivating_do_not_share_strings() { + $cancel = StatusChange::confirmation_strings( 'cancelled' ); + $reactivate = StatusChange::confirmation_strings( 'active' ); + + $this->assertNotSame( $cancel['question'], $reactivate['question'] ); + $this->assertNotSame( $cancel['confirm'], $reactivate['confirm'] ); + } +} diff --git a/tests/bootstrap-unit.php b/tests/bootstrap-unit.php index 265bd55..40b6ec0 100644 --- a/tests/bootstrap-unit.php +++ b/tests/bootstrap-unit.php @@ -42,6 +42,16 @@ function plugin_basename( $file ) { return basename( dirname( $file ) ) . '/' . basename( $file ); } +/** + * JSON encoding, which WordPress only wraps to pick its own default flags. + * + * @param mixed $data Value to encode. + * @return string|false + */ +function wp_json_encode( $data ) { + return json_encode( $data ); +} + /** * Translation, which without a text domain loaded returns the original string. * From 4316f3b92913470287945c8630ede9ef52ada4aa Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 14 Sep 2026 18:25:14 -0400 Subject: [PATCH 02/16] docs: describe the layout of src and tests Signed-off-by: YvesCesar --- README.md | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 11ed897..d4083a3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,33 @@ Customizations at WordPress relative to website libresign.coop - Create the GitHub token and add at configuration page - Set the organization and repository that have the deploy action +## Architecture + +`src/` holds the decisions and `includes/` plus the main plugin file hold the +wiring: the hooks, the options and the effects. A decision receives values and +returns values — the navigation receives the query vars instead of reading +`global $wp`, the webhook gate receives the headers instead of a +`WP_REST_Request` — so it is covered by a data provider and no test double. + +``` +src/ +├── Account/Navigation.php # entries, labels and the active one +├── Account/RootEndpoint.php # account screens served from the site root +├── Github/DeployDispatch.php # when publishing asks GitHub for a deploy +├── Github/SiteDeploy.php # which run publishes the site +├── Github/WebhookDecision.php # what to answer a delivery +├── Github/WebhookGate.php # inspection of a delivery +├── Github/WebhookRequest.php # headers and body of a delivery +├── Github/WebhookSignature.php # the HMAC GitHub signs with +├── Github/WorkflowRun.php # the run a payload describes +├── Settings/Secret.php # the cipher of the token and the secret +└── Subscription/StatusChange.php # changes that ask for a confirmation +``` + +The plugin is installed by cloning the repository, so Composer never runs on the +server: `src/Autoloader.php` maps the namespace to `src/` and is the only file +the plugin requires by hand. + ## Development Every check is a Composer script: @@ -51,13 +78,14 @@ docker exec -w /var/www/html/wp-content/plugins/libresign-wp-customizations \ wordpress-docker-wordpress-1 composer test ``` -`tests/` mirrors the plugin file by file, with `Test.php` appended: -`includes/github-site-webhook.php` is covered by -`tests/Unit/Includes/GithubSiteWebhookTest.php` and -`tests/Integration/Includes/GithubSiteWebhookTest.php`. A file belongs to -`Unit/` when it only feeds values to a function and reads the returned value, -and to `Integration/` when it goes through WordPress: options, hooks, the REST -server or the database. +`tests/Unit/` mirrors `src/` and `tests/Integration/` mirrors the plugin files, +in both cases file by file with `Test.php` appended: +`src/Github/WebhookGate.php` is covered by +`tests/Unit/Github/WebhookGateTest.php`, and the endpoint wiring it serves, +`includes/github-site-webhook.php`, by +`tests/Integration/Includes/GithubSiteWebhookTest.php`. A decision is covered by +a unit test, and the wiring by an integration test going through WordPress: +options, hooks, the REST server or the database. Nothing is mocked. Outgoing HTTP is answered through the `pre_http_request` filter (`tests/Support/FakeHttp.php`), which is WordPress' own extension point, From 1acae439bb60b0cae21575b7ef604ec467c5d6f5 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Mon, 14 Sep 2026 18:29:34 -0400 Subject: [PATCH 03/16] refactor: name the variables after what they hold Signed-off-by: YvesCesar --- includes/github-site-webhook.php | 26 +++++++-------- libresign-wp-customizations.php | 11 +++--- src/Autoloader.php | 6 ++-- src/Github/SiteDeploy.php | 30 ++++++++--------- src/Github/WebhookDecision.php | 48 +++++++++++++-------------- src/Github/WebhookGate.php | 14 ++++---- src/Github/WorkflowRun.php | 14 ++++---- src/Settings/Secret.php | 46 +++++++++++++------------ tests/Unit/Github/SiteDeployTest.php | 10 +++--- tests/Unit/Github/WebhookGateTest.php | 6 ++-- tests/Unit/Github/WorkflowRunTest.php | 36 ++++++++++---------- tests/Unit/Settings/SecretTest.php | 12 +++---- 12 files changed, 132 insertions(+), 127 deletions(-) diff --git a/includes/github-site-webhook.php b/includes/github-site-webhook.php index bc65b8f..62710a0 100644 --- a/includes/github-site-webhook.php +++ b/includes/github-site-webhook.php @@ -173,9 +173,9 @@ function libresign_record_site_fragment_sync_result( $status, $payload ) { * @return WP_REST_Response|WP_Error */ function libresign_receive_github_site_deploy_webhook( $request ) { - $gate = new WebhookGate( libresign_github_webhook_secret(), libresign_site_deploy() ); + $webhook_gate = new WebhookGate( libresign_github_webhook_secret(), libresign_site_deploy() ); - $decision = $gate->decide( + $decision = $webhook_gate->decide( new WebhookRequest( (string) $request->get_header( 'user-agent' ), (string) $request->get_header( 'x-github-event' ), @@ -206,7 +206,7 @@ function libresign_receive_github_site_deploy_webhook( $request ) { return libresign_github_site_webhook_ignored_response( $decision->data() ); } - $run = $decision->workflow_run(); + $workflow_run = $decision->workflow_run(); $delivery_id = (string) $request->get_header( 'x-github-delivery' ); if ( ! libresign_mark_github_delivery_once( $delivery_id ) ) { @@ -222,9 +222,9 @@ function libresign_receive_github_site_deploy_webhook( $request ) { libresign_site_origin(), array( 'header', 'footer' ), array( - 'generated_at' => '' === $run->updated_at() ? current_time( 'mysql', true ) : $run->updated_at(), - 'source_sha' => $run->head_sha(), - 'source_url' => $run->html_url(), + 'generated_at' => '' === $workflow_run->updated_at() ? current_time( 'mysql', true ) : $workflow_run->updated_at(), + 'source_sha' => $workflow_run->head_sha(), + 'source_url' => $workflow_run->html_url(), ) ); @@ -244,11 +244,11 @@ function libresign_receive_github_site_deploy_webhook( $request ) { 'synced', array( 'delivery_id' => $delivery_id, - 'repository' => $run->repository(), - 'workflow' => $run->workflow_name(), - 'head_branch' => $run->head_branch(), - 'source_sha' => $run->head_sha(), - 'source_url' => $run->html_url(), + 'repository' => $workflow_run->repository(), + 'workflow' => $workflow_run->workflow_name(), + 'head_branch' => $workflow_run->head_branch(), + 'source_sha' => $workflow_run->head_sha(), + 'source_url' => $workflow_run->html_url(), 'synced' => $sync_result['synced'], ) ); @@ -257,8 +257,8 @@ function libresign_receive_github_site_deploy_webhook( $request ) { array( 'status' => 'synced', 'delivery_id' => $delivery_id, - 'repository' => $run->repository(), - 'workflow' => $run->workflow_name(), + 'repository' => $workflow_run->repository(), + 'workflow' => $workflow_run->workflow_name(), 'origin' => $sync_result['origin'], 'synced' => $sync_result['synced'], ) diff --git a/libresign-wp-customizations.php b/libresign-wp-customizations.php index 2986d07..46bfdea 100644 --- a/libresign-wp-customizations.php +++ b/libresign-wp-customizations.php @@ -171,17 +171,20 @@ function libresign_trigger_github_action_on_publish($new_status, $old_status, $p 'message' => $response->get_error_message(), ]); } else { - $code = (int) wp_remote_retrieve_response_code($response); - if ($code === 204) { + $response_code = (int) wp_remote_retrieve_response_code($response); + if ($response_code === 204) { $post_data = array_merge($post_data, [ 'type' => 'success', 'message' => DeployDispatch::success_message($organizationRepository), ]); } else { - $body = json_decode(wp_remote_retrieve_body($response), true); + $response_body = json_decode(wp_remote_retrieve_body($response), true); $post_data = array_merge($post_data, [ 'type' => 'error', - 'message' => DeployDispatch::failure_message($code, isset($body['message']) ? $body['message'] : ''), + 'message' => DeployDispatch::failure_message( + $response_code, + isset($response_body['message']) ? $response_body['message'] : '' + ), ]); } } diff --git a/src/Autoloader.php b/src/Autoloader.php index c9d74a6..30cbb43 100644 --- a/src/Autoloader.php +++ b/src/Autoloader.php @@ -40,10 +40,10 @@ public static function load( $class_name ) { return; } - $path = __DIR__ . '/' . str_replace( '\\', '/', substr( $class_name, strlen( self::PREFIX ) ) ) . '.php'; + $file_path = __DIR__ . '/' . str_replace( '\\', '/', substr( $class_name, strlen( self::PREFIX ) ) ) . '.php'; - if ( is_readable( $path ) ) { - require_once $path; + if ( is_readable( $file_path ) ) { + require_once $file_path; } } } diff --git a/src/Github/SiteDeploy.php b/src/Github/SiteDeploy.php index 2679344..1eedde4 100644 --- a/src/Github/SiteDeploy.php +++ b/src/Github/SiteDeploy.php @@ -26,37 +26,37 @@ final class SiteDeploy { * * @var string */ - private $branch; + private $branch_name; /** * Name of the workflow publishing the site. * * @var string */ - private $workflow; + private $workflow_name; /** * @param string $repository Repository publishing the site. - * @param string $branch Branch the site is published from. - * @param string $workflow Name of the workflow publishing the site. + * @param string $branch_name Branch the site is published from. + * @param string $workflow_name Name of the workflow publishing the site. */ - public function __construct( $repository, $branch, $workflow ) { - $this->repository = (string) $repository; - $this->branch = (string) $branch; - $this->workflow = (string) $workflow; + public function __construct( $repository, $branch_name, $workflow_name ) { + $this->repository = (string) $repository; + $this->branch_name = (string) $branch_name; + $this->workflow_name = (string) $workflow_name; } /** * Whether the run is the one that just put the site live. * - * @param WorkflowRun $run Run reported by GitHub. + * @param WorkflowRun $workflow_run Run reported by GitHub. * @return bool */ - public function is_production_run( WorkflowRun $run ) { - return $this->repository === $run->repository() - && 'completed' === $run->action() - && 'success' === $run->conclusion() - && $this->branch === $run->head_branch() - && $this->workflow === $run->workflow_name(); + public function is_production_run( WorkflowRun $workflow_run ) { + return $this->repository === $workflow_run->repository() + && 'completed' === $workflow_run->action() + && 'success' === $workflow_run->conclusion() + && $this->branch_name === $workflow_run->head_branch() + && $this->workflow_name === $workflow_run->workflow_name(); } } diff --git a/src/Github/WebhookDecision.php b/src/Github/WebhookDecision.php index 0a03b0a..a1f958f 100644 --- a/src/Github/WebhookDecision.php +++ b/src/Github/WebhookDecision.php @@ -31,28 +31,28 @@ final class WebhookDecision { * * @var string */ - private $code = ''; + private $error_code = ''; /** * Error message of a rejection. * * @var string */ - private $message = ''; + private $error_message = ''; /** * HTTP status of a rejection. * * @var int */ - private $status = 0; + private $http_status = 0; /** * Why the delivery was ignored, and the details worth reporting. * * @var array */ - private $data = array(); + private $response_data = array(); /** * Run to deploy from. @@ -71,16 +71,16 @@ private function __construct( $outcome ) { /** * The delivery is not one this endpoint answers. * - * @param string $code Error code. - * @param string $message Error message. - * @param int $status HTTP status. + * @param string $error_code Error code. + * @param string $error_message Error message. + * @param int $http_status HTTP status. * @return self */ - public static function reject( $code, $message, $status ) { - $decision = new self( self::REJECT ); - $decision->code = (string) $code; - $decision->message = (string) $message; - $decision->status = (int) $status; + public static function reject( $error_code, $error_message, $http_status ) { + $decision = new self( self::REJECT ); + $decision->error_code = (string) $error_code; + $decision->error_message = (string) $error_message; + $decision->http_status = (int) $http_status; return $decision; } @@ -97,13 +97,13 @@ public static function pong() { /** * A delivery this endpoint accepts but has nothing to do about. * - * @param string $reason Why it was ignored. - * @param array $details Details worth reporting back. + * @param string $ignored_reason Why it was ignored. + * @param array $details Details worth reporting back. * @return self */ - public static function ignore( $reason, array $details = array() ) { - $decision = new self( self::IGNORE ); - $decision->data = array_merge( array( 'reason' => (string) $reason ), $details ); + public static function ignore( $ignored_reason, array $details = array() ) { + $decision = new self( self::IGNORE ); + $decision->response_data = array_merge( array( 'reason' => (string) $ignored_reason ), $details ); return $decision; } @@ -111,12 +111,12 @@ public static function ignore( $reason, array $details = array() ) { /** * The site was just published and the fragments are stale. * - * @param WorkflowRun $run Run that published the site. + * @param WorkflowRun $workflow_run Run that published the site. * @return self */ - public static function deploy( WorkflowRun $run ) { + public static function deploy( WorkflowRun $workflow_run ) { $decision = new self( self::DEPLOY ); - $decision->workflow_run = $run; + $decision->workflow_run = $workflow_run; return $decision; } @@ -153,28 +153,28 @@ public function is_deploy() { * @return string */ public function code() { - return $this->code; + return $this->error_code; } /** * @return string */ public function message() { - return $this->message; + return $this->error_message; } /** * @return int */ public function status() { - return $this->status; + return $this->http_status; } /** * @return array */ public function data() { - return $this->data; + return $this->response_data; } /** diff --git a/src/Github/WebhookGate.php b/src/Github/WebhookGate.php index 311d269..d97f469 100644 --- a/src/Github/WebhookGate.php +++ b/src/Github/WebhookGate.php @@ -89,20 +89,20 @@ public function decide( WebhookRequest $request ) { ); } - $run = WorkflowRun::from_payload( $payload ); + $workflow_run = WorkflowRun::from_payload( $payload ); - if ( ! $this->site_deploy->is_production_run( $run ) ) { + if ( ! $this->site_deploy->is_production_run( $workflow_run ) ) { return WebhookDecision::ignore( 'not_production_deploy', array( - 'repository' => $run->repository(), - 'workflow_name' => $run->workflow_name(), - 'head_branch' => $run->head_branch(), - 'conclusion' => $run->conclusion(), + 'repository' => $workflow_run->repository(), + 'workflow_name' => $workflow_run->workflow_name(), + 'head_branch' => $workflow_run->head_branch(), + 'conclusion' => $workflow_run->conclusion(), ) ); } - return WebhookDecision::deploy( $run ); + return WebhookDecision::deploy( $workflow_run ); } } diff --git a/src/Github/WorkflowRun.php b/src/Github/WorkflowRun.php index 0a11d01..384a927 100644 --- a/src/Github/WorkflowRun.php +++ b/src/Github/WorkflowRun.php @@ -113,20 +113,20 @@ public function workflow_name() { /** * Value at a path of the payload, as a trimmed string. * - * @param string[] $path Keys to walk. + * @param string[] $field_path Keys to walk. * @return string */ - private function text( array $path ) { - $value = $this->payload; + private function text( array $field_path ) { + $field_value = $this->payload; - foreach ( $path as $key ) { - if ( ! is_array( $value ) || ! isset( $value[ $key ] ) ) { + foreach ( $field_path as $field_name ) { + if ( ! is_array( $field_value ) || ! isset( $field_value[ $field_name ] ) ) { return ''; } - $value = $value[ $key ]; + $field_value = $field_value[ $field_name ]; } - return is_scalar( $value ) ? trim( (string) $value ) : ''; + return is_scalar( $field_value ) ? trim( (string) $field_value ) : ''; } } diff --git a/src/Settings/Secret.php b/src/Settings/Secret.php index 652e9e7..e6b861f 100644 --- a/src/Settings/Secret.php +++ b/src/Settings/Secret.php @@ -24,22 +24,22 @@ final class Secret { * * @var string */ - private $key; + private $encryption_key; /** * Initialization vector. * * @var string */ - private $iv; + private $initialization_vector; /** - * @param string $key Encryption key. - * @param string $iv Initialization vector. + * @param string $encryption_key Encryption key. + * @param string $initialization_vector Initialization vector. */ - public function __construct( $key, $iv ) { - $this->key = (string) $key; - $this->iv = (string) $iv; + public function __construct( $encryption_key, $initialization_vector ) { + $this->encryption_key = (string) $encryption_key; + $this->initialization_vector = (string) $initialization_vector; } /** @@ -64,17 +64,19 @@ public static function from_salts( $auth_key, $secure_auth_salt, $nonce_salt ) { * update_option() hands a value that does not exist yet to add_option(), so * encrypting has to be idempotent or the value is stored encrypted twice. * - * @param string $value Plain text value. + * @param string $plain_value Plain text value. * @return string */ - public function encrypt( $value ) { - $value = trim( (string) $value ); + public function encrypt( $plain_value ) { + $plain_value = trim( (string) $plain_value ); - if ( $this->decrypt( $value ) !== $value ) { - return $value; + if ( $this->decrypt( $plain_value ) !== $plain_value ) { + return $plain_value; } - return base64_encode( (string) openssl_encrypt( $value, self::CIPHER, $this->key, 0, $this->iv ) ); + return base64_encode( + (string) openssl_encrypt( $plain_value, self::CIPHER, $this->encryption_key, 0, $this->initialization_vector ) + ); } /** @@ -83,24 +85,24 @@ public function encrypt( $value ) { * A value saved before the encryption existed, or by hand, is stored in * plain text and has to keep working. * - * @param string $value Stored value. + * @param string $stored_value Stored value. * @return string */ - public function decrypt( $value ) { - $value = trim( (string) $value ); + public function decrypt( $stored_value ) { + $stored_value = trim( (string) $stored_value ); - if ( '' === $value ) { + if ( '' === $stored_value ) { return ''; } - $decoded = base64_decode( $value, true ); + $decoded_value = base64_decode( $stored_value, true ); - if ( false === $decoded ) { - return $value; + if ( false === $decoded_value ) { + return $stored_value; } - $decrypted = openssl_decrypt( $decoded, self::CIPHER, $this->key, 0, $this->iv ); + $decrypted_value = openssl_decrypt( $decoded_value, self::CIPHER, $this->encryption_key, 0, $this->initialization_vector ); - return false === $decrypted ? $value : trim( $decrypted ); + return false === $decrypted_value ? $stored_value : trim( $decrypted_value ); } } diff --git a/tests/Unit/Github/SiteDeployTest.php b/tests/Unit/Github/SiteDeployTest.php index b521f82..733cf5d 100644 --- a/tests/Unit/Github/SiteDeployTest.php +++ b/tests/Unit/Github/SiteDeployTest.php @@ -53,7 +53,7 @@ public function test_the_production_deploy_is_recognized() { } public function test_the_workflow_name_may_come_from_the_workflow_instead_of_the_run() { - $run = WorkflowRun::from_payload( + $workflow_run = WorkflowRun::from_payload( array( 'action' => 'completed', 'repository' => array( 'full_name' => 'LibreSign/site' ), @@ -65,7 +65,7 @@ public function test_the_workflow_name_may_come_from_the_workflow_instead_of_the ) ); - $this->assertTrue( self::site_deploy()->is_production_run( $run ) ); + $this->assertTrue( self::site_deploy()->is_production_run( $workflow_run ) ); } /** @@ -91,11 +91,11 @@ public static function provide_other_runs() { } public function test_the_expected_run_is_the_configured_one() { - $deploy = new SiteDeploy( 'LibreSign/staging', 'main', 'deploy' ); + $site_deploy = new SiteDeploy( 'LibreSign/staging', 'main', 'deploy' ); - $this->assertFalse( $deploy->is_production_run( self::production_run() ) ); + $this->assertFalse( $site_deploy->is_production_run( self::production_run() ) ); $this->assertTrue( - $deploy->is_production_run( + $site_deploy->is_production_run( self::production_run( array( 'repository' => array( 'full_name' => 'LibreSign/staging' ), diff --git a/tests/Unit/Github/WebhookGateTest.php b/tests/Unit/Github/WebhookGateTest.php index d6a0dad..c19a872 100644 --- a/tests/Unit/Github/WebhookGateTest.php +++ b/tests/Unit/Github/WebhookGateTest.php @@ -148,13 +148,13 @@ public function test_a_body_that_is_not_json_is_rejected() { * @dataProvider provide_non_production_payloads * * @param array $overrides Values replacing the production defaults. - * @param array $reported Details expected in the answer. + * @param array $reported_details Details expected in the answer. */ - public function test_a_run_that_is_not_the_production_deploy_is_ignored( $overrides, $reported ) { + public function test_a_run_that_is_not_the_production_deploy_is_ignored( $overrides, $reported_details ) { $decision = self::gate()->decide( self::signed_delivery( 'workflow_run', self::production_payload( $overrides ) ) ); $this->assertTrue( $decision->is_ignored() ); - $this->assertSame( array_merge( array( 'reason' => 'not_production_deploy' ), $reported ), $decision->data() ); + $this->assertSame( array_merge( array( 'reason' => 'not_production_deploy' ), $reported_details ), $decision->data() ); } /** diff --git a/tests/Unit/Github/WorkflowRunTest.php b/tests/Unit/Github/WorkflowRunTest.php index d550afb..7e8c320 100644 --- a/tests/Unit/Github/WorkflowRunTest.php +++ b/tests/Unit/Github/WorkflowRunTest.php @@ -16,7 +16,7 @@ final class WorkflowRunTest extends TestCase { public function test_reads_the_fields_the_deploy_reports() { - $run = WorkflowRun::from_payload( + $workflow_run = WorkflowRun::from_payload( array( 'action' => 'completed', 'repository' => array( 'full_name' => 'LibreSign/site' ), @@ -31,27 +31,27 @@ public function test_reads_the_fields_the_deploy_reports() { ) ); - $this->assertSame( 'LibreSign/site', $run->repository() ); - $this->assertSame( 'completed', $run->action() ); - $this->assertSame( 'success', $run->conclusion() ); - $this->assertSame( 'gh-pages', $run->head_branch() ); - $this->assertSame( '0f1e2d3', $run->head_sha() ); - $this->assertSame( 'https://github.com/LibreSign/site/actions/runs/1', $run->html_url() ); - $this->assertSame( '2026-09-14T12:00:00Z', $run->updated_at() ); - $this->assertSame( 'pages build and deployment', $run->workflow_name() ); + $this->assertSame( 'LibreSign/site', $workflow_run->repository() ); + $this->assertSame( 'completed', $workflow_run->action() ); + $this->assertSame( 'success', $workflow_run->conclusion() ); + $this->assertSame( 'gh-pages', $workflow_run->head_branch() ); + $this->assertSame( '0f1e2d3', $workflow_run->head_sha() ); + $this->assertSame( 'https://github.com/LibreSign/site/actions/runs/1', $workflow_run->html_url() ); + $this->assertSame( '2026-09-14T12:00:00Z', $workflow_run->updated_at() ); + $this->assertSame( 'pages build and deployment', $workflow_run->workflow_name() ); } public function test_an_empty_payload_answers_every_field_with_an_empty_string() { - $run = WorkflowRun::from_payload( array() ); + $workflow_run = WorkflowRun::from_payload( array() ); - $this->assertSame( '', $run->repository() ); - $this->assertSame( '', $run->action() ); - $this->assertSame( '', $run->conclusion() ); - $this->assertSame( '', $run->head_branch() ); - $this->assertSame( '', $run->head_sha() ); - $this->assertSame( '', $run->html_url() ); - $this->assertSame( '', $run->updated_at() ); - $this->assertSame( '', $run->workflow_name() ); + $this->assertSame( '', $workflow_run->repository() ); + $this->assertSame( '', $workflow_run->action() ); + $this->assertSame( '', $workflow_run->conclusion() ); + $this->assertSame( '', $workflow_run->head_branch() ); + $this->assertSame( '', $workflow_run->head_sha() ); + $this->assertSame( '', $workflow_run->html_url() ); + $this->assertSame( '', $workflow_run->updated_at() ); + $this->assertSame( '', $workflow_run->workflow_name() ); } /** diff --git a/tests/Unit/Settings/SecretTest.php b/tests/Unit/Settings/SecretTest.php index 44b9896..3c54e09 100644 --- a/tests/Unit/Settings/SecretTest.php +++ b/tests/Unit/Settings/SecretTest.php @@ -51,20 +51,20 @@ public function test_the_stored_value_is_not_the_value() { } public function test_another_installation_cannot_read_the_value() { - $stored = self::secret()->encrypt( 'a-shared-secret' ); - $other = Secret::from_salts( 'another-auth-key', 'another-secure-auth-salt', 'another-nonce-salt' ); + $stored_value = self::secret()->encrypt( 'a-shared-secret' ); + $another_installation = Secret::from_salts( 'another-auth-key', 'another-secure-auth-salt', 'another-nonce-salt' ); - $this->assertNotSame( 'a-shared-secret', $other->decrypt( $stored ) ); + $this->assertNotSame( 'a-shared-secret', $another_installation->decrypt( $stored_value ) ); } /** * @dataProvider provide_stored_values * - * @param string $stored Value found in the database. + * @param string $stored_value Value found in the database. * @param string $expected Value the plugin uses. */ - public function test_a_value_that_is_not_encrypted_is_used_as_it_is( $stored, $expected ) { - $this->assertSame( $expected, self::secret()->decrypt( $stored ) ); + public function test_a_value_that_is_not_encrypted_is_used_as_it_is( $stored_value, $expected ) { + $this->assertSame( $expected, self::secret()->decrypt( $stored_value ) ); } /** From fb784d1114c67548413dd80efd674eeb06bf6b8b Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 17:19:56 -0400 Subject: [PATCH 04/16] fix: keep an unreadable deploy token from being sent to github Signed-off-by: YvesCesar --- libresign-wp-customizations.php | 10 +- src/Settings/Secret.php | 104 ++++++++---------- .../LibresignWpCustomizationsTest.php | 9 ++ tests/Support/PluginSecret.php | 18 +++ tests/Unit/Settings/SecretTest.php | 41 ++++++- 5 files changed, 116 insertions(+), 66 deletions(-) diff --git a/libresign-wp-customizations.php b/libresign-wp-customizations.php index 46bfdea..b22df65 100644 --- a/libresign-wp-customizations.php +++ b/libresign-wp-customizations.php @@ -39,7 +39,7 @@ const LIBRESIGN_FAQ_URL = 'https://libresign.coop/faq/'; /** - * The cipher protecting the settings stored encrypted. + * Cipher of the settings stored encrypted. */ function libresign_plugin_secret() { return Secret::from_salts( AUTH_KEY, SECURE_AUTH_SALT, NONCE_SALT ); @@ -145,7 +145,7 @@ function libresign_trigger_github_action_on_publish($new_status, $old_status, $p return; } - $deploy_token = libresign_plugin_secret()->decrypt(get_option('libresign_github_deploy_token')); + $deploy_token = libresign_plugin_secret()->decrypt_or_discard(get_option('libresign_github_deploy_token')); $organizationRepository = get_option('libresign_github_deploy_organization_repository'); $response = wp_remote_post('https://api.github.com/repos/' . $organizationRepository . '/dispatches', [ 'body' => wp_json_encode([ @@ -403,10 +403,10 @@ class="regular-text" } /** - * Encrypt a setting before it is stored, keeping the stored one when nothing was typed. + * Encrypt a setting before it is stored. * - * The field is rendered empty on every visit, so an empty submission means the - * value was left alone. + * The field is rendered empty on every visit, so a submission with nothing in + * it means the value was left alone and the stored one is kept. */ function libresign_encrypt_setting($value, $option) { $value = trim((string) $value); diff --git a/src/Settings/Secret.php b/src/Settings/Secret.php index e6b861f..9e4847c 100644 --- a/src/Settings/Secret.php +++ b/src/Settings/Secret.php @@ -9,48 +9,19 @@ defined( 'ABSPATH' ) || exit; -/** - * Encrypts and decrypts the deploy token and the webhook secret. - * - * The key is derived from the salts of the installation, so a database copied - * to another installation carries values that cannot be read there. - */ final class Secret { private const CIPHER = 'AES-256-CBC'; - /** - * Encryption key. - * - * @var string - */ - private $encryption_key; - - /** - * Initialization vector. - * - * @var string - */ - private $initialization_vector; + private string $encryption_key; + private string $initialization_vector; - /** - * @param string $encryption_key Encryption key. - * @param string $initialization_vector Initialization vector. - */ - public function __construct( $encryption_key, $initialization_vector ) { - $this->encryption_key = (string) $encryption_key; - $this->initialization_vector = (string) $initialization_vector; + public function __construct( string $encryption_key, string $initialization_vector ) { + $this->encryption_key = $encryption_key; + $this->initialization_vector = $initialization_vector; } - /** - * Derive the key from the salts of the installation. - * - * @param string $auth_key AUTH_KEY. - * @param string $secure_auth_salt SECURE_AUTH_SALT. - * @param string $nonce_salt NONCE_SALT. - * @return self - */ - public static function from_salts( $auth_key, $secure_auth_salt, $nonce_salt ) { + public static function from_salts( string $auth_key, string $secure_auth_salt, string $nonce_salt ): self { return new self( hash( 'sha256', $auth_key . $secure_auth_salt ), substr( hash( 'sha256', $nonce_salt ), 0, 16 ) @@ -58,17 +29,13 @@ public static function from_salts( $auth_key, $secure_auth_salt, $nonce_salt ) { } /** - * Encrypt a value for storage, leaving an already encrypted one untouched. - * - * WordPress runs the sanitize callback of an option again when - * update_option() hands a value that does not exist yet to add_option(), so - * encrypting has to be idempotent or the value is stored encrypted twice. - * - * @param string $plain_value Plain text value. - * @return string + * An already encrypted value is left as it is: WordPress runs the sanitize + * callback of an option again when update_option() hands a value that does + * not exist yet to add_option(), and encrypting twice stores a value that + * nothing can read back. */ - public function encrypt( $plain_value ) { - $plain_value = trim( (string) $plain_value ); + public function encrypt( string $plain_value ): string { + $plain_value = trim( $plain_value ); if ( $this->decrypt( $plain_value ) !== $plain_value ) { return $plain_value; @@ -80,29 +47,48 @@ public function encrypt( $plain_value ) { } /** - * Read a stored value, returning it untouched when it is not encrypted. - * - * A value saved before the encryption existed, or by hand, is stored in - * plain text and has to keep working. - * - * @param string $stored_value Stored value. - * @return string + * Read a stored value, returning it untouched when it cannot be deciphered, + * so a value saved in plain text keeps working. Only for a value that stays + * on the site: decrypt_or_discard() is the one for a value that is sent. */ - public function decrypt( $stored_value ) { - $stored_value = trim( (string) $stored_value ); + public function decrypt( string $stored_value ): string { + $decoded_value = $this->decode( $stored_value ); - if ( '' === $stored_value ) { - return ''; + if ( false === $decoded_value ) { + return trim( $stored_value ); } - $decoded_value = base64_decode( $stored_value, true ); + return $this->decipher( $decoded_value ) ?? trim( $stored_value ); + } + + /** + * A value that is not base64 was never encrypted and is used as it is. A + * value that is base64 but does not decipher was encrypted with salts this + * installation no longer has: it is not the value and nobody outside may + * see it, so it is dropped instead of being sent. + */ + public function decrypt_or_discard( string $stored_value ): string { + $decoded_value = $this->decode( $stored_value ); if ( false === $decoded_value ) { - return $stored_value; + return trim( $stored_value ); } + return (string) $this->decipher( $decoded_value ); + } + + /** + * @return string|false + */ + private function decode( string $stored_value ) { + $stored_value = trim( $stored_value ); + + return '' === $stored_value ? false : base64_decode( $stored_value, true ); + } + + private function decipher( string $decoded_value ): ?string { $decrypted_value = openssl_decrypt( $decoded_value, self::CIPHER, $this->encryption_key, 0, $this->initialization_vector ); - return false === $decrypted_value ? $stored_value : trim( $decrypted_value ); + return false === $decrypted_value ? null : trim( $decrypted_value ); } } diff --git a/tests/Integration/LibresignWpCustomizationsTest.php b/tests/Integration/LibresignWpCustomizationsTest.php index ce13c1f..1dd48d3 100644 --- a/tests/Integration/LibresignWpCustomizationsTest.php +++ b/tests/Integration/LibresignWpCustomizationsTest.php @@ -194,6 +194,15 @@ public function test_a_token_saved_for_the_first_time_is_stored_encrypted() { $this->assertSame( 'Bearer a-personal-access-token', $this->github->args()['headers']['Authorization'] ); } + public function test_a_deploy_token_that_cannot_be_decrypted_is_not_sent_to_github() { + update_option( 'libresign_github_deploy_token', PluginSecret::encrypt_with_other_salts( 'a-personal-access-token' ) ); + $this->github->answer_with( FakeHttp::response( 204 ) ); + + self::factory()->post->create( array( 'post_status' => 'publish' ) ); + + $this->assertSame( 'Bearer ', $this->github->args()['headers']['Authorization'] ); + } + public function test_saving_an_empty_deploy_token_keeps_the_previous_one() { update_option( 'libresign_github_deploy_token', 'placeholder' ); $this->register_plugin_settings(); diff --git a/tests/Support/PluginSecret.php b/tests/Support/PluginSecret.php index a8a7e0d..0a8d228 100644 --- a/tests/Support/PluginSecret.php +++ b/tests/Support/PluginSecret.php @@ -29,4 +29,22 @@ public static function encrypt( $value ) { ) ); } + + /** + * Encrypt the way another installation would, so this one cannot read it. + * + * @param string $value Plain text value. + * @return string + */ + public static function encrypt_with_other_salts( $value ) { + return base64_encode( + openssl_encrypt( + $value, + 'AES-256-CBC', + hash( 'sha256', 'another-auth-key' . 'another-secure-auth-salt' ), + 0, + substr( hash( 'sha256', 'another-nonce-salt' ), 0, 16 ) + ) + ); + } } diff --git a/tests/Unit/Settings/SecretTest.php b/tests/Unit/Settings/SecretTest.php index 3c54e09..cdae5f6 100644 --- a/tests/Unit/Settings/SecretTest.php +++ b/tests/Unit/Settings/SecretTest.php @@ -16,8 +16,6 @@ final class SecretTest extends TestCase { /** - * A cipher with fixed salts, so the expectations do not depend on the installation. - * * @return Secret */ private static function secret() { @@ -46,6 +44,45 @@ public static function provide_values() { yield 'a single character' => array( 'x' ); } + /** + * @dataProvider provide_values + * + * @param string $value Value to store. + */ + public function test_a_stored_value_is_read_back_for_sending( $value ) { + $secret = self::secret(); + + $this->assertSame( $value, $secret->decrypt_or_discard( $secret->encrypt( $value ) ) ); + } + + public function test_a_value_encrypted_elsewhere_is_discarded_instead_of_sent() { + $stored_value = Secret::from_salts( 'another-auth-key', 'another-secure-auth-salt', 'another-nonce-salt' ) + ->encrypt( 'ghp_0123456789abcdefghijklmnopqrstuvwxyz' ); + + $this->assertSame( $stored_value, self::secret()->decrypt( $stored_value ) ); + $this->assertSame( '', self::secret()->decrypt_or_discard( $stored_value ) ); + } + + /** + * @dataProvider provide_plain_tokens + * + * @param string $stored_value Value found in the database. + */ + public function test_a_token_saved_by_hand_is_still_sent( $stored_value ) { + $this->assertSame( $stored_value, self::secret()->decrypt_or_discard( $stored_value ) ); + } + + /** + * A GitHub token carries an underscore, which base64 does not. + * + * @return iterable + */ + public static function provide_plain_tokens() { + yield 'a classic personal access token' => array( 'ghp_0123456789abcdefghijklmnopqrstuvwxyz' ); + yield 'a fine grained token' => array( 'github_pat_11ABCDEFG0abcdefghijklmn' ); + yield 'nothing saved yet' => array( '' ); + } + public function test_the_stored_value_is_not_the_value() { $this->assertNotSame( 'a-shared-secret', self::secret()->encrypt( 'a-shared-secret' ) ); } From efa66854e5b7cfb4d0b948de9bd99c362cba3193 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 17:19:56 -0400 Subject: [PATCH 05/16] fix: store a setting of "0" instead of taking it for a blank field Signed-off-by: YvesCesar --- libresign-wp-customizations.php | 2 +- tests/Integration/LibresignWpCustomizationsTest.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/libresign-wp-customizations.php b/libresign-wp-customizations.php index b22df65..e66f63e 100644 --- a/libresign-wp-customizations.php +++ b/libresign-wp-customizations.php @@ -411,7 +411,7 @@ class="regular-text" function libresign_encrypt_setting($value, $option) { $value = trim((string) $value); - if (empty($value)) { + if ('' === $value) { return get_option($option); } diff --git a/tests/Integration/LibresignWpCustomizationsTest.php b/tests/Integration/LibresignWpCustomizationsTest.php index 1dd48d3..3591ca8 100644 --- a/tests/Integration/LibresignWpCustomizationsTest.php +++ b/tests/Integration/LibresignWpCustomizationsTest.php @@ -203,6 +203,15 @@ public function test_a_deploy_token_that_cannot_be_decrypted_is_not_sent_to_gith $this->assertSame( 'Bearer ', $this->github->args()['headers']['Authorization'] ); } + public function test_a_setting_of_zero_is_stored_instead_of_being_taken_for_a_blank_field() { + $this->register_plugin_settings(); + + update_option( 'libresign_github_webhook_secret', 'the-previous-secret' ); + update_option( 'libresign_github_webhook_secret', '0' ); + + $this->assertSame( '0', libresign_github_webhook_secret() ); + } + public function test_saving_an_empty_deploy_token_keeps_the_previous_one() { update_option( 'libresign_github_deploy_token', 'placeholder' ); $this->register_plugin_settings(); From 3ecec39b7446a36d529525e8936ac4e23b81e964 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 17:19:56 -0400 Subject: [PATCH 06/16] refactor: let an impossible decision fail loudly instead of on null Signed-off-by: YvesCesar --- src/Github/WebhookDecision.php | 138 ++++++-------------------- tests/Unit/Github/WebhookGateTest.php | 9 ++ 2 files changed, 41 insertions(+), 106 deletions(-) diff --git a/src/Github/WebhookDecision.php b/src/Github/WebhookDecision.php index a1f958f..60ef907 100644 --- a/src/Github/WebhookDecision.php +++ b/src/Github/WebhookDecision.php @@ -7,11 +7,10 @@ namespace LibreSign\WPCustomizations\Github; +use LogicException; + defined( 'ABSPATH' ) || exit; -/** - * The outcome of inspecting a delivery, with no answer sent yet. - */ final class WebhookDecision { private const REJECT = 'reject'; @@ -19,168 +18,95 @@ final class WebhookDecision { private const IGNORE = 'ignore'; private const DEPLOY = 'deploy'; - /** - * One of the constants above. - * - * @var string - */ - private $outcome; - - /** - * Error code of a rejection. - * - * @var string - */ - private $error_code = ''; - - /** - * Error message of a rejection. - * - * @var string - */ - private $error_message = ''; - - /** - * HTTP status of a rejection. - * - * @var int - */ - private $http_status = 0; + private string $outcome; + private string $error_code = ''; + private string $error_message = ''; + private int $http_status = 0; /** - * Why the delivery was ignored, and the details worth reporting. - * * @var array */ - private $response_data = array(); + private array $response_data = array(); - /** - * Run to deploy from. - * - * @var WorkflowRun|null - */ - private $workflow_run; + private ?WorkflowRun $workflow_run = null; - /** - * @param string $outcome One of the class constants. - */ - private function __construct( $outcome ) { + private function __construct( string $outcome ) { $this->outcome = $outcome; } - /** - * The delivery is not one this endpoint answers. - * - * @param string $error_code Error code. - * @param string $error_message Error message. - * @param int $http_status HTTP status. - * @return self - */ - public static function reject( $error_code, $error_message, $http_status ) { + public static function reject( string $error_code, string $error_message, int $http_status ): self { $decision = new self( self::REJECT ); - $decision->error_code = (string) $error_code; - $decision->error_message = (string) $error_message; - $decision->http_status = (int) $http_status; + $decision->error_code = $error_code; + $decision->error_message = $error_message; + $decision->http_status = $http_status; return $decision; } - /** - * GitHub is checking the endpoint is alive. - * - * @return self - */ - public static function pong() { + public static function pong(): self { return new self( self::PONG ); } /** - * A delivery this endpoint accepts but has nothing to do about. - * - * @param string $ignored_reason Why it was ignored. - * @param array $details Details worth reporting back. - * @return self + * @param array $details Details worth reporting back. */ - public static function ignore( $ignored_reason, array $details = array() ) { + public static function ignore( string $ignored_reason, array $details = array() ): self { $decision = new self( self::IGNORE ); - $decision->response_data = array_merge( array( 'reason' => (string) $ignored_reason ), $details ); + $decision->response_data = array_merge( array( 'reason' => $ignored_reason ), $details ); return $decision; } - /** - * The site was just published and the fragments are stale. - * - * @param WorkflowRun $workflow_run Run that published the site. - * @return self - */ - public static function deploy( WorkflowRun $workflow_run ) { + public static function deploy( WorkflowRun $workflow_run ): self { $decision = new self( self::DEPLOY ); $decision->workflow_run = $workflow_run; return $decision; } - /** - * @return bool - */ - public function is_rejected() { + public function is_rejected(): bool { return self::REJECT === $this->outcome; } - /** - * @return bool - */ - public function is_pong() { + public function is_pong(): bool { return self::PONG === $this->outcome; } - /** - * @return bool - */ - public function is_ignored() { + public function is_ignored(): bool { return self::IGNORE === $this->outcome; } - /** - * @return bool - */ - public function is_deploy() { + public function is_deploy(): bool { return self::DEPLOY === $this->outcome; } - /** - * @return string - */ - public function code() { + public function code(): string { return $this->error_code; } - /** - * @return string - */ - public function message() { + public function message(): string { return $this->error_message; } - /** - * @return int - */ - public function status() { + public function status(): int { return $this->http_status; } /** * @return array */ - public function data() { + public function data(): array { return $this->response_data; } /** - * @return WorkflowRun|null + * @throws LogicException When the decision is not a deploy. */ - public function workflow_run() { + public function workflow_run(): WorkflowRun { + if ( null === $this->workflow_run ) { + throw new LogicException( 'Only a deploy decision carries a workflow run.' ); + } + return $this->workflow_run; } } diff --git a/tests/Unit/Github/WebhookGateTest.php b/tests/Unit/Github/WebhookGateTest.php index c19a872..d056d5e 100644 --- a/tests/Unit/Github/WebhookGateTest.php +++ b/tests/Unit/Github/WebhookGateTest.php @@ -10,6 +10,7 @@ use LibreSign\WPCustomizations\Github\SiteDeploy; use LibreSign\WPCustomizations\Github\WebhookGate; use LibreSign\WPCustomizations\Github\WebhookRequest; +use LogicException; use PHPUnit\Framework\TestCase; /** @@ -208,6 +209,14 @@ public static function provide_non_production_payloads() { ); } + public function test_a_decision_that_is_not_a_deploy_has_no_workflow_run() { + $decision = self::gate()->decide( self::signed_delivery( 'push', '{"ref":"refs/heads/main"}' ) ); + + $this->expectException( LogicException::class ); + + $decision->workflow_run(); + } + public function test_a_production_deploy_is_handed_over_with_the_run() { $decision = self::gate()->decide( self::signed_delivery( 'workflow_run', self::production_payload() ) ); From a8eb7407eed249951b5eee659b9d0c3bdf810640 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 17:19:56 -0400 Subject: [PATCH 07/16] refactor: type the plugin classes and cut the comments they replaced Signed-off-by: YvesCesar --- includes/account-navigation.php | 3 - includes/github-site-webhook.php | 10 +- src/Account/Navigation.php | 31 ++---- src/Account/RootEndpoint.php | 14 +-- src/Autoloader.php | 18 +-- src/Github/DeployDispatch.php | 29 +---- src/Github/SiteDeploy.php | 47 ++------ src/Github/WebhookGate.php | 35 +----- src/Github/WebhookRequest.php | 104 ++++-------------- src/Github/WebhookSignature.php | 18 +-- src/Github/WorkflowRun.php | 77 +++---------- src/Subscription/StatusChange.php | 8 +- .../Includes/AccountNavigationTest.php | 4 - 13 files changed, 76 insertions(+), 322 deletions(-) diff --git a/includes/account-navigation.php b/includes/account-navigation.php index 7be4fcc..5f4f103 100644 --- a/includes/account-navigation.php +++ b/includes/account-navigation.php @@ -6,9 +6,6 @@ * endpoint page titles in sync with those labels, and merges the addresses * screen into the billing screen. * - * The navigation itself is decided by LibreSign\WPCustomizations\Account\Navigation; - * what is left here is the wiring into WooCommerce. - * * @package LibreSign_WP_Customizations */ diff --git a/includes/github-site-webhook.php b/includes/github-site-webhook.php index 62710a0..a2e65c3 100644 --- a/includes/github-site-webhook.php +++ b/includes/github-site-webhook.php @@ -2,10 +2,6 @@ /** * GitHub webhook receiver for production site deploy synchronization. * - * What a delivery means is decided by LibreSign\WPCustomizations\Github\WebhookGate; - * what is left here is the endpoint, the settings it reads and the effects it - * applies once the decision is taken. - * * @package LibreSign_WP_Customizations */ @@ -99,8 +95,6 @@ function libresign_site_deploy_branch_name() { } /** - * The run that publishes the site, as configured. - * * @return SiteDeploy */ function libresign_site_deploy() { @@ -206,8 +200,8 @@ function libresign_receive_github_site_deploy_webhook( $request ) { return libresign_github_site_webhook_ignored_response( $decision->data() ); } - $workflow_run = $decision->workflow_run(); - $delivery_id = (string) $request->get_header( 'x-github-delivery' ); + $workflow_run = $decision->workflow_run(); + $delivery_id = (string) $request->get_header( 'x-github-delivery' ); if ( ! libresign_mark_github_delivery_once( $delivery_id ) ) { return libresign_github_site_webhook_ignored_response( diff --git a/src/Account/Navigation.php b/src/Account/Navigation.php index d23074a..c3b4fc3 100644 --- a/src/Account/Navigation.php +++ b/src/Account/Navigation.php @@ -10,31 +10,25 @@ defined( 'ABSPATH' ) || exit; /** - * Which entries the customer sees, how they are named and which one is active. - * * Every method takes the query vars of the request being rendered instead of * reading them, so none of this depends on the request. */ final class Navigation { /** - * Customer entries kept, in display order. - * * @return string[] */ - public static function order() { + public static function order(): array { return array( 'subscriptions', 'orders', 'payment-methods', 'edit-account', 'customer-logout' ); } /** - * Entries renamed because their meaning changed here. - * * Whatever is left out keeps the WooCommerce label, and with it the * translation WooCommerce already ships for every locale. * * @return array */ - public static function labels() { + public static function labels(): array { return array( 'subscriptions' => __( 'My subscription', 'libresign-wp-customizations' ), 'orders' => __( 'Invoices', 'libresign-wp-customizations' ), @@ -43,11 +37,12 @@ public static function labels() { } /** - * Detail endpoints neither core nor Subscriptions highlights, mapped to their entry. + * Detail endpoints neither core nor Subscriptions highlights, mapped to the + * entry they belong to. * * @return array */ - public static function aliases() { + public static function aliases(): array { return array( 'subscriptions' => array( 'subscription-payment-method' ), 'payment-methods' => array( 'edit-address' ), @@ -55,12 +50,10 @@ public static function aliases() { } /** - * Reorder and relabel the navigation, skipping unregistered endpoints. - * * @param array $items Navigation handed over by WooCommerce. * @return array */ - public static function filter_items( array $items ) { + public static function filter_items( array $items ): array { $labels = self::labels(); $menu = array(); @@ -74,14 +67,11 @@ public static function filter_items( array $items ) { } /** - * Highlight the entry a detail screen belongs to. - * * @param string[] $classes Classes of the entry. - * @param string $endpoint Endpoint of the entry. * @param array $query_vars Query vars of the request being rendered. * @return string[] */ - public static function filter_item_classes( array $classes, $endpoint, array $query_vars ) { + public static function filter_item_classes( array $classes, string $endpoint, array $query_vars ): array { if ( in_array( 'is-active', $classes, true ) ) { return $classes; } @@ -103,14 +93,9 @@ public static function filter_item_classes( array $classes, $endpoint, array $qu } /** - * Page title of an endpoint whose navigation label was renamed. - * - * @param string $title Title WooCommerce would use. - * @param string $endpoint Endpoint being rendered. * @param array $query_vars Query vars of the request being rendered. - * @return string */ - public static function endpoint_title( $title, $endpoint, array $query_vars ) { + public static function endpoint_title( string $title, string $endpoint, array $query_vars ): string { $labels = self::labels(); if ( ! isset( $labels[ $endpoint ] ) ) { diff --git a/src/Account/RootEndpoint.php b/src/Account/RootEndpoint.php index b750ede..8722aab 100644 --- a/src/Account/RootEndpoint.php +++ b/src/Account/RootEndpoint.php @@ -10,22 +10,16 @@ defined( 'ABSPATH' ) || exit; /** - * Recognizes a request for an account endpoint sitting at the site root. - * * With My Account as the front page, /payment-methods/ and /lost-password/ are - * valid screens and must not be collapsed back to / by a canonical redirect. + * screens and must not be collapsed back to / by a canonical redirect. */ final class RootEndpoint { /** - * Whether the path is the account page or one of its endpoints. - * - * @param string $request_path Path of the request, with or without slashes around it. - * @param array $query_vars Endpoints registered by WooCommerce. - * @return bool + * @param array $query_vars Endpoints registered by WooCommerce. */ - public static function matches( $request_path, array $query_vars ) { - $request_path = trim( (string) $request_path, '/' ); + public static function matches( string $request_path, array $query_vars ): bool { + $request_path = trim( $request_path, '/' ); if ( '' === $request_path ) { return false; diff --git a/src/Autoloader.php b/src/Autoloader.php index 30cbb43..15079b8 100644 --- a/src/Autoloader.php +++ b/src/Autoloader.php @@ -13,29 +13,17 @@ * Maps LibreSign\WPCustomizations\* to src/*.php. * * The plugin is installed by cloning the repository, so Composer never runs on - * the server and its autoloader is not available at runtime. The autoload - * section of composer.json describes the same mapping for the tooling. + * the server and its autoloader does not exist at runtime. */ final class Autoloader { private const PREFIX = 'LibreSign\\WPCustomizations\\'; - /** - * Start autoloading the plugin classes. - * - * @return void - */ - public static function register() { + public static function register(): void { spl_autoload_register( array( self::class, 'load' ) ); } - /** - * Load a single class, ignoring everything outside the plugin namespace. - * - * @param string $class_name Fully qualified class name. - * @return void - */ - public static function load( $class_name ) { + public static function load( string $class_name ): void { if ( 0 !== strpos( $class_name, self::PREFIX ) ) { return; } diff --git a/src/Github/DeployDispatch.php b/src/Github/DeployDispatch.php index 09b9639..401b798 100644 --- a/src/Github/DeployDispatch.php +++ b/src/Github/DeployDispatch.php @@ -9,44 +9,21 @@ defined( 'ABSPATH' ) || exit; -/** - * When to ask GitHub for a deploy, and what to tell the editor afterwards. - */ final class DeployDispatch { /** - * Whether a post status transition asks for a deploy. - * * Only an entry of the blog deploys the site, whether it is being published * or leaving the publish status. - * - * @param string $new_status Status the post moved to. - * @param string $old_status Status the post came from. - * @param string $post_type Post type. - * @return bool */ - public static function triggers_deploy( $new_status, $old_status, $post_type ) { + public static function triggers_deploy( string $new_status, string $old_status, string $post_type ): bool { return 'post' === $post_type && ( 'publish' === $new_status || 'publish' === $old_status ); } - /** - * Notice shown when GitHub accepted the deploy. - * - * @param string $repository Repository the deploy was requested from. - * @return string - */ - public static function success_message( $repository ) { + public static function success_message( string $repository ): string { return 'Ação de deploy enviada com sucesso. Acompanhe aqui'; } - /** - * Notice shown when GitHub refused the deploy. - * - * @param int $code HTTP status code returned by GitHub. - * @param string $api_message Message carried by the response body. - * @return string - */ - public static function failure_message( $code, $api_message ) { + public static function failure_message( int $code, string $api_message ): string { return "Erro ao acionar deploy.
Código: {$code}.
Message: {$api_message}"; } } diff --git a/src/Github/SiteDeploy.php b/src/Github/SiteDeploy.php index 1eedde4..fe38c65 100644 --- a/src/Github/SiteDeploy.php +++ b/src/Github/SiteDeploy.php @@ -9,50 +9,19 @@ defined( 'ABSPATH' ) || exit; -/** - * Recognizes the production deploy among every run GitHub reports. - */ final class SiteDeploy { - /** - * Repository publishing the site, as owner/name. - * - * @var string - */ - private $repository; - - /** - * Branch the site is published from. - * - * @var string - */ - private $branch_name; - - /** - * Name of the workflow publishing the site. - * - * @var string - */ - private $workflow_name; + private string $repository; + private string $branch_name; + private string $workflow_name; - /** - * @param string $repository Repository publishing the site. - * @param string $branch_name Branch the site is published from. - * @param string $workflow_name Name of the workflow publishing the site. - */ - public function __construct( $repository, $branch_name, $workflow_name ) { - $this->repository = (string) $repository; - $this->branch_name = (string) $branch_name; - $this->workflow_name = (string) $workflow_name; + public function __construct( string $repository, string $branch_name, string $workflow_name ) { + $this->repository = $repository; + $this->branch_name = $branch_name; + $this->workflow_name = $workflow_name; } - /** - * Whether the run is the one that just put the site live. - * - * @param WorkflowRun $workflow_run Run reported by GitHub. - * @return bool - */ - public function is_production_run( WorkflowRun $workflow_run ) { + public function is_production_run( WorkflowRun $workflow_run ): bool { return $this->repository === $workflow_run->repository() && 'completed' === $workflow_run->action() && 'success' === $workflow_run->conclusion() diff --git a/src/Github/WebhookGate.php b/src/Github/WebhookGate.php index d97f469..c7e067b 100644 --- a/src/Github/WebhookGate.php +++ b/src/Github/WebhookGate.php @@ -10,43 +10,20 @@ defined( 'ABSPATH' ) || exit; /** - * Turns a delivery into the decision the endpoint acts on. - * * Everything the endpoint refuses is refused here, so the endpoint itself only - * has to turn a decision into a response. + * turns a decision into a response. */ final class WebhookGate { - /** - * Secret shared with the repository webhook. - * - * @var string - */ - private $secret; - - /** - * The run that publishes the site. - * - * @var SiteDeploy - */ - private $site_deploy; + private string $secret; + private SiteDeploy $site_deploy; - /** - * @param string $secret Secret shared with the repository webhook. - * @param SiteDeploy $site_deploy The run that publishes the site. - */ - public function __construct( $secret, SiteDeploy $site_deploy ) { - $this->secret = (string) $secret; + public function __construct( string $secret, SiteDeploy $site_deploy ) { + $this->secret = $secret; $this->site_deploy = $site_deploy; } - /** - * What to do with a delivery. - * - * @param WebhookRequest $request Delivery received. - * @return WebhookDecision - */ - public function decide( WebhookRequest $request ) { + public function decide( WebhookRequest $request ): WebhookDecision { if ( '' === trim( $this->secret ) ) { return WebhookDecision::reject( 'libresign_github_webhook_secret_missing', diff --git a/src/Github/WebhookRequest.php b/src/Github/WebhookRequest.php index 8b6d52b..bcf4f46 100644 --- a/src/Github/WebhookRequest.php +++ b/src/Github/WebhookRequest.php @@ -9,104 +9,48 @@ defined( 'ABSPATH' ) || exit; -/** - * The headers and the body of a delivery, with nothing read from the request. - */ final class WebhookRequest { - /** - * User-Agent header. - * - * @var string - */ - private $user_agent; - - /** - * X-GitHub-Event header. - * - * @var string - */ - private $event; - - /** - * X-Hub-Signature-256 header. - * - * @var string - */ - private $signature; - - /** - * X-GitHub-Delivery header. - * - * @var string - */ - private $delivery_id; - - /** - * Raw request body. - * - * @var string - */ - private $body; - - /** - * @param string $user_agent User-Agent header. - * @param string $event X-GitHub-Event header. - * @param string $signature X-Hub-Signature-256 header. - * @param string $delivery_id X-GitHub-Delivery header. - * @param string $body Raw request body. - */ - public function __construct( $user_agent, $event, $signature, $delivery_id, $body ) { - $this->user_agent = (string) $user_agent; - $this->event = (string) $event; - $this->signature = (string) $signature; - $this->delivery_id = (string) $delivery_id; - $this->body = (string) $body; + private string $user_agent; + private string $event; + private string $signature; + private string $delivery_id; + private string $body; + + public function __construct( + string $user_agent, + string $event, + string $signature, + string $delivery_id, + string $body + ) { + $this->user_agent = $user_agent; + $this->event = $event; + $this->signature = $signature; + $this->delivery_id = $delivery_id; + $this->body = $body; } - /** - * Whether the client identifies itself as GitHub Hookshot. - * - * @return bool - */ - public function is_from_github() { + public function is_from_github(): bool { return 0 === strpos( trim( $this->user_agent ), 'GitHub-Hookshot/' ); } - /** - * Whether the body carries the signature of the secret. - * - * @param string $secret Shared secret. - * @return bool - */ - public function has_valid_signature( $secret ) { + public function has_valid_signature( string $secret ): bool { return WebhookSignature::matches( $this->body, $this->signature, $secret ); } - /** - * Event name, normalized. - * - * @return string - */ - public function event() { + public function event(): string { return strtolower( trim( $this->event ) ); } - /** - * Delivery identifier, as GitHub sent it. - * - * @return string - */ - public function delivery_id() { + public function delivery_id(): string { return $this->delivery_id; } /** - * Parsed body, or null when it is not a JSON object. - * - * @return array|null + * @return array|null */ - public function payload() { + public function payload(): ?array { $payload = json_decode( $this->body, true ); return is_array( $payload ) ? $payload : null; diff --git a/src/Github/WebhookSignature.php b/src/Github/WebhookSignature.php index d76b505..02a73b4 100644 --- a/src/Github/WebhookSignature.php +++ b/src/Github/WebhookSignature.php @@ -9,23 +9,11 @@ defined( 'ABSPATH' ) || exit; -/** - * The HMAC GitHub sends in X-Hub-Signature-256. - */ final class WebhookSignature { - /** - * Whether the signature is the one the secret produces for the body. - * - * @param string $body Raw request body. - * @param string $signature Signature header value. - * @param string $secret Shared secret. - * @return bool - */ - public static function matches( $body, $signature, $secret ) { - $body = (string) $body; - $secret = trim( (string) $secret ); - $signature = trim( (string) $signature ); + public static function matches( string $body, string $signature, string $secret ): bool { + $secret = trim( $secret ); + $signature = trim( $signature ); if ( '' === $body || '' === $secret || '' === $signature ) { return false; diff --git a/src/Github/WorkflowRun.php b/src/Github/WorkflowRun.php index 384a927..c6d59b6 100644 --- a/src/Github/WorkflowRun.php +++ b/src/Github/WorkflowRun.php @@ -9,114 +9,65 @@ defined( 'ABSPATH' ) || exit; -/** - * Reads the fields the site deploy cares about out of a workflow_run payload. - */ final class WorkflowRun { /** - * Parsed payload. - * - * @var array + * @var array */ - private $payload; + private array $payload; /** - * @param array $payload Parsed payload. + * @param array $payload Parsed payload. */ private function __construct( array $payload ) { $this->payload = $payload; } /** - * @param array $payload Parsed payload. - * @return self + * @param array $payload Parsed payload. */ - public static function from_payload( array $payload ) { + public static function from_payload( array $payload ): self { return new self( $payload ); } - /** - * Repository the run belongs to, as owner/name. - * - * @return string - */ - public function repository() { + public function repository(): string { return $this->text( array( 'repository', 'full_name' ) ); } - /** - * What happened to the run: requested, in_progress or completed. - * - * @return string - */ - public function action() { + public function action(): string { return $this->text( array( 'action' ) ); } - /** - * How the run ended: success, failure, cancelled and so on. - * - * @return string - */ - public function conclusion() { + public function conclusion(): string { return $this->text( array( 'workflow_run', 'conclusion' ) ); } - /** - * Branch the run was started from. - * - * @return string - */ - public function head_branch() { + public function head_branch(): string { return $this->text( array( 'workflow_run', 'head_branch' ) ); } - /** - * Commit the run was started from. - * - * @return string - */ - public function head_sha() { + public function head_sha(): string { return $this->text( array( 'workflow_run', 'head_sha' ) ); } - /** - * Address of the run on GitHub. - * - * @return string - */ - public function html_url() { + public function html_url(): string { return $this->text( array( 'workflow_run', 'html_url' ) ); } - /** - * When GitHub last touched the run. - * - * @return string - */ - public function updated_at() { + public function updated_at(): string { return $this->text( array( 'workflow_run', 'updated_at' ) ); } - /** - * Name of the workflow, preferring the name the run itself carries. - * - * @return string - */ - public function workflow_name() { + public function workflow_name(): string { $run_name = $this->text( array( 'workflow_run', 'name' ) ); return '' === $run_name ? $this->text( array( 'workflow', 'name' ) ) : $run_name; } /** - * Value at a path of the payload, as a trimmed string. - * * @param string[] $field_path Keys to walk. - * @return string */ - private function text( array $field_path ) { + private function text( array $field_path ): string { $field_value = $this->payload; foreach ( $field_path as $field_name ) { diff --git a/src/Subscription/StatusChange.php b/src/Subscription/StatusChange.php index 3110295..f7d259e 100644 --- a/src/Subscription/StatusChange.php +++ b/src/Subscription/StatusChange.php @@ -9,18 +9,12 @@ defined( 'ABSPATH' ) || exit; -/** - * What to ask the customer before a status change goes through. - */ final class StatusChange { /** - * Strings of the confirmation prompt, or null when the change needs none. - * - * @param string $new_status Status the customer asked for. * @return array{question: string, confirm: string, dismiss: string}|null */ - public static function confirmation_strings( $new_status ) { + public static function confirmation_strings( string $new_status ): ?array { $strings = array( 'cancelled' => array( 'question' => __( 'Are you sure you want to cancel your subscription? This action cannot be undone.', 'libresign-wp-customizations' ), diff --git a/tests/Integration/Includes/AccountNavigationTest.php b/tests/Integration/Includes/AccountNavigationTest.php index ba2ac5c..02911e6 100644 --- a/tests/Integration/Includes/AccountNavigationTest.php +++ b/tests/Integration/Includes/AccountNavigationTest.php @@ -10,10 +10,6 @@ use WP_UnitTestCase; /** - * Covers the wiring: the hooks registered and the query vars handed over to - * LibreSign\WPCustomizations\Account\Navigation, which decides on its own what - * the navigation looks like. - * * WooCommerce is not loaded in the test suite, so what is asserted here is the * registration of the hooks and the behaviour of the callbacks when they are * called with the arguments WooCommerce passes them. From b492a89c9493fb0935c0cf8f84b31cc2e416c1f1 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Thu, 17 Sep 2026 17:13:51 -0400 Subject: [PATCH 08/16] fix: do not ask github for a deploy without a token Signed-off-by: YvesCesar --- libresign-wp-customizations.php | 4 ++++ tests/Integration/LibresignWpCustomizationsTest.php | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/libresign-wp-customizations.php b/libresign-wp-customizations.php index e66f63e..e9df804 100644 --- a/libresign-wp-customizations.php +++ b/libresign-wp-customizations.php @@ -146,6 +146,10 @@ function libresign_trigger_github_action_on_publish($new_status, $old_status, $p } $deploy_token = libresign_plugin_secret()->decrypt_or_discard(get_option('libresign_github_deploy_token')); + if ($deploy_token === '') { + return; + } + $organizationRepository = get_option('libresign_github_deploy_organization_repository'); $response = wp_remote_post('https://api.github.com/repos/' . $organizationRepository . '/dispatches', [ 'body' => wp_json_encode([ diff --git a/tests/Integration/LibresignWpCustomizationsTest.php b/tests/Integration/LibresignWpCustomizationsTest.php index 3591ca8..f41f79b 100644 --- a/tests/Integration/LibresignWpCustomizationsTest.php +++ b/tests/Integration/LibresignWpCustomizationsTest.php @@ -41,6 +41,7 @@ public function set_up() { do_action( 'rest_api_init', $wp_rest_server ); update_option( 'libresign_github_deploy_organization_repository', 'LibreSign/site' ); + update_option( 'libresign_github_deploy_token', PluginSecret::encrypt( 'deploy-token' ) ); } public function tear_down() { @@ -200,7 +201,7 @@ public function test_a_deploy_token_that_cannot_be_decrypted_is_not_sent_to_gith self::factory()->post->create( array( 'post_status' => 'publish' ) ); - $this->assertSame( 'Bearer ', $this->github->args()['headers']['Authorization'] ); + $this->assertSame( array(), $this->github->urls() ); } public function test_a_setting_of_zero_is_stored_instead_of_being_taken_for_a_blank_field() { From 7933a8a642828c363f198915e9aa13008c81ea1c Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 17:47:21 -0400 Subject: [PATCH 09/16] test: cover the account and subscription flows with playwright Signed-off-by: YvesCesar --- .gitignore | 4 + .wp-env.json | 16 + README.md | 48 +- package-lock.json | 5385 +++++++++++++++++ package.json | 16 + phpstan.neon.dist | 2 + playwright.config.ts | 41 + tests/E2E/Account/Navigation.spec.ts | 103 + tests/E2E/Account/RootEndpoint.spec.ts | 51 + tests/E2E/Subscription/StatusChange.spec.ts | 64 + tests/E2E/customer.setup.ts | 18 + .../support/saved-payment-methods-gateway.php | 30 + tests/E2E/support/seed.php | 127 + tests/E2E/support/site.ts | 67 + tests/E2E/support/wp-cli.ts | 16 + 15 files changed, 5985 insertions(+), 3 deletions(-) create mode 100644 .wp-env.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 tests/E2E/Account/Navigation.spec.ts create mode 100644 tests/E2E/Account/RootEndpoint.spec.ts create mode 100644 tests/E2E/Subscription/StatusChange.spec.ts create mode 100644 tests/E2E/customer.setup.ts create mode 100644 tests/E2E/support/saved-payment-methods-gateway.php create mode 100644 tests/E2E/support/seed.php create mode 100644 tests/E2E/support/site.ts create mode 100644 tests/E2E/support/wp-cli.ts diff --git a/.gitignore b/.gitignore index 7f78132..20e7113 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ /vendor/ /.phpunit.result.cache +node_modules/ +/tests/E2E/.state/ +/tests/E2E/.results/ +/tests/E2E/.report/ diff --git a/.wp-env.json b/.wp-env.json new file mode 100644 index 0000000..e26247e --- /dev/null +++ b/.wp-env.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://schemas.wp.org/trunk/wp-env.json", + "phpVersion": "8.3", + "plugins": [ + ".", + "https://downloads.wordpress.org/plugin/woocommerce.10.9.4.zip", + "pronamic/woocommerce-subscriptions#main" + ], + "config": { + "WP_DEBUG": true, + "WP_DEBUG_DISPLAY": false + }, + "mappings": { + "wp-content/mu-plugins/libresign-e2e-saved-payment-methods-gateway.php": "./tests/E2E/support/saved-payment-methods-gateway.php" + } +} diff --git a/README.md b/README.md index d4083a3..bf3dee4 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,48 @@ filter (`tests/Support/FakeHttp.php`), which is WordPress' own extension point, and any request that is not answered that way fails the test instead of reaching the network. -WooCommerce is not installed in the test suite, so the screens that only exist -with WooCommerce loaded — the root account endpoints, the invoice title, the -addresses redirect and the subscription confirmation flow — are still uncovered. +WooCommerce is not installed in this suite, so the screens that only exist with +WooCommerce loaded are covered by the browser tests instead. + +### Browser tests + +`tests/E2E/` mirrors `src/` the same way, with `.spec.ts` in place of +`Test.php`: `src/Account/Navigation.php` is covered end to end by +`tests/E2E/Account/Navigation.spec.ts`. What lives here is what PHPUnit cannot +reach without WooCommerce, the rewrite rules and a browser — the account +navigation, the account screens served from the site root and the confirmation +of a subscription status change. + +The suite runs against a WordPress that is already up, described by three +variables: + +| Variable | Default | +|---|---| +| `WP_BASE_URL` | `http://localhost` | +| `WP_CLI` | `docker exec -i -u www-data wordpress-docker-wordpress-1 wp --path=/var/www/html` | +| `WP_E2E_CUSTOMER_PASSWORD` | `libresign-e2e` | + +The defaults are the local SaaS stack again, so there it takes no arguments: + +```bash +npm install +npx playwright install chromium +npm run test:e2e +``` + +`tests/E2E/support/seed.php` puts the site in the state the specs expect — My +Account as the front page, a customer with that password and an active +subscription — and runs again before each test that changes it, so a failed run +leaves nothing behind. + +Anywhere else, point the first two at the site under test. `.wp-env.json` +describes a disposable one, which is what CI runs: + +```bash +npm run env:start +WP_BASE_URL=http://localhost:8888 WP_CLI="npx wp-env run cli wp" npm run test:e2e +``` + +WooCommerce only offers the billing screen when one of the available gateways +keeps payment methods. The local stack has Stripe for that; `.wp-env.json` maps +a mu-plugin that declares one. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7256f2d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5385 @@ +{ + "name": "libresign-wp-customizations", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "libresign-wp-customizations", + "license": "GPL-2.0-or-later", + "devDependencies": { + "@playwright/test": "^1.50.0", + "@wordpress/env": "^10.16.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/file-exists/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@kwsites/file-exists/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@octokit/app": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@octokit/app/-/app-14.1.0.tgz", + "integrity": "sha512-g3uEsGOQCBl1+W1rgfwoRFUIR6PtvB2T1E4RpygeUU5LrLvlOqcxrt5lfykIeRpUPpupreGJUYl70fqMDXdTpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-app": "^6.0.0", + "@octokit/auth-unauthenticated": "^5.0.0", + "@octokit/core": "^5.0.0", + "@octokit/oauth-app": "^6.0.0", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/types": "^12.0.0", + "@octokit/webhooks": "^12.0.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-app": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-6.1.4.tgz", + "integrity": "sha512-QkXkSOHZK4dA5oUqY5Dk3S+5pN2s1igPjEASNQV8/vgJgW034fQWR16u7VsNOK/EljA00eyjYF5mWNxWKWhHRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^7.1.0", + "@octokit/auth-oauth-user": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.1.0", + "deprecation": "^2.3.1", + "lru-cache": "npm:@wolfy1339/lru-cache@^11.0.2-patch.1", + "universal-github-app-jwt": "^1.1.2", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-app/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/auth-app/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/auth-oauth-app": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-7.1.0.tgz", + "integrity": "sha512-w+SyJN/b0l/HEb4EOPRudo7uUOSW51jcK1jwLa+4r7PA8FPFpoxEnHBHMITqCsc/3Vo2qqFjgQfz/xUUvsSQnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^6.1.0", + "@octokit/auth-oauth-user": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "@types/btoa-lite": "^1.0.0", + "btoa-lite": "^1.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/auth-oauth-device": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-6.1.0.tgz", + "integrity": "sha512-FNQ7cb8kASufd6Ej4gnJ3f1QB5vJitkoV1O0/g6e6lUsQ7+VsSNRHRmFScN2tV4IgKA12frrr/cegUs0t+0/Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/oauth-methods": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/auth-oauth-user": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-4.1.0.tgz", + "integrity": "sha512-FrEp8mtFuS/BrJyjpur+4GARteUCrPeR/tZJzD8YourzoVhRics7u7we/aDcKv+yywRNwNi/P4fRi631rG/OyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-device": "^6.1.0", + "@octokit/oauth-methods": "^4.1.0", + "@octokit/request": "^8.3.1", + "@octokit/types": "^13.0.0", + "btoa-lite": "^1.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/auth-unauthenticated": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@octokit/auth-unauthenticated/-/auth-unauthenticated-5.0.1.tgz", + "integrity": "sha512-oxeWzmBFxWd+XolxKTc4zr+h3mt+yofn4r7OfoIkR/Cj/o70eEGmPsFbueyJE2iBAGpjgTnEOKM3pnuEGVmiqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", + "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/core/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/endpoint/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/graphql/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/oauth-app": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-app/-/oauth-app-6.1.0.tgz", + "integrity": "sha512-nIn/8eUJ/BKUVzxUXd5vpzl1rwaVxMyYbQkNZjHrF7Vk/yu98/YDF/N2KeWO7uZ0g3b5EyiFXFkZI8rJ+DH1/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-oauth-app": "^7.0.0", + "@octokit/auth-oauth-user": "^4.0.0", + "@octokit/auth-unauthenticated": "^5.0.0", + "@octokit/core": "^5.0.0", + "@octokit/oauth-authorization-url": "^6.0.2", + "@octokit/oauth-methods": "^4.0.0", + "@types/aws-lambda": "^8.10.83", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-authorization-url": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-6.0.2.tgz", + "integrity": "sha512-CdoJukjXXxqLNK4y/VOiVzQVjibqoj/xHgInekviUJV73y/BSIcwvJ/4aNHPBPKcPWFnd4/lO9uqRV65jXhcLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-methods": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/oauth-methods/-/oauth-methods-4.1.0.tgz", + "integrity": "sha512-4tuKnCRecJ6CG6gr0XcEXdZtkTDbfbnD5oaHBmLERTjTMZNi2CbfEHZxPU41xXLDG4DfKf+sonu00zvKI9NSbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/oauth-authorization-url": "^6.0.2", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/types": "^13.0.0", + "btoa-lite": "^1.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/oauth-methods/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/oauth-methods/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-20.0.0.tgz", + "integrity": "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-graphql": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-graphql/-/plugin-paginate-graphql-4.0.1.tgz", + "integrity": "sha512-R8ZQNmrIKKpHWC6V2gum4x9LG2qF1RxRjo27gjQcG3j+vf2tLsEfE7I/wRWEPzYMaenr1M+qDAtNcwZve1ce1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": ">=5" + } + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.2.2.tgz", + "integrity": "sha512-u3KYkGF7GcZnSD/3UP0S7K5XUFT2FkOQdcfXZGZQPGv3lm4F2Xbf71lvjldr8c1H3nNbF+33cLEkWYbokGWqiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.4.1.tgz", + "integrity": "sha512-xV1b+ceKV9KytQe3zCVqjg+8GTGfDYwaT1ATU5isiUyVtlVAO3HNdzpS4sr4GBx4hxQ46s7ITtZrAsxG22+rVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.6.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-retry": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-6.1.0.tgz", + "integrity": "sha512-WrO3bvq4E1Xh1r2mT9w6SDFg01gFmP81nIG77+p/MqW1JeXXgL++6umim3t6x0Zj5pZm3rXAN+0HEjmmdhIRig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^13.0.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-retry/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/plugin-throttling": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-throttling/-/plugin-throttling-8.2.0.tgz", + "integrity": "sha512-nOpWtLayKFpgqmgD0y3GqXafMFuKcA4tRPZIfu7BArd2lEZeb1988nhWhwx4aZWmjDmUfdgVf7W+Tt4AmvRmMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^12.2.0", + "bottleneck": "^2.15.3" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/request-error/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/request/node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/request/node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@octokit/types": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.6.0.tgz", + "integrity": "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^20.0.0" + } + }, + "node_modules/@octokit/webhooks": { + "version": "12.3.2", + "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-12.3.2.tgz", + "integrity": "sha512-exj1MzVXoP7xnAcAB3jZ97pTvVPkQF9y6GA/dvYC47HV7vLv+24XRS6b/v/XnyikpEuvMhugEXdGtAlU086WkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request-error": "^5.0.0", + "@octokit/webhooks-methods": "^4.1.0", + "@octokit/webhooks-types": "7.6.1", + "aggregate-error": "^3.1.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/webhooks-methods": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-methods/-/webhooks-methods-4.1.0.tgz", + "integrity": "sha512-zoQyKw8h9STNPqtm28UGOYFE7O6D4Il8VJwhAtMHFt2C4L0VQT1qGKLeefUOqHNs1mNRYSadVv7x0z8U2yyeWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/webhooks-types": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-7.6.1.tgz", + "integrity": "sha512-S8u2cJzklBC0FgTwWVLaM8tMrDuDMVE4xiTK4EYXM9GntyvrdbSoxqDQa+Fh57CCNApyIpyeqPhhFEmHPfrXgw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dev": true, + "license": "MIT" + }, + "node_modules/@php-wasm/cli-util": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/cli-util/-/cli-util-3.1.54.tgz", + "integrity": "sha512-FFDZDzIJz0cePI2NlWIEC1OZxZvifi5EmyPik8Qh1z+H2mVPyMjgi1xYlQXTLc/J0CPH427VfmOMwy9XqpVY7Q==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/util": "3.1.54", + "fast-xml-parser": "^5.8.0", + "jsonc-parser": "3.3.1" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/logger": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/logger/-/logger-3.1.54.tgz", + "integrity": "sha512-ZrgBSSHitJkR+DU1U5dz/ezXzvU9PlhNT2GTSQJnRliNVPba84G0hudRpmnT+CH4aVThAefE3PazpGD/SG1kWA==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node/-/node-3.1.54.tgz", + "integrity": "sha512-7OuYnmNCiMgH01JOPAxX1QqDut66+Wz140O8AM1GZVKismjLJhxlL+vx2vDJjUQH6puvhgFwK2QOU4wpWDSUkw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/cli-util": "3.1.54", + "@php-wasm/logger": "3.1.54", + "@php-wasm/node-5-2": "3.1.54", + "@php-wasm/node-7-4": "3.1.54", + "@php-wasm/node-8-0": "3.1.54", + "@php-wasm/node-8-1": "3.1.54", + "@php-wasm/node-8-2": "3.1.54", + "@php-wasm/node-8-3": "3.1.54", + "@php-wasm/node-8-4": "3.1.54", + "@php-wasm/node-8-5": "3.1.54", + "@php-wasm/universal": "3.1.54", + "@php-wasm/util": "3.1.54", + "fs-ext-extra-prebuilt": "2.2.7", + "wasm-feature-detect": "1.8.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node-5-2": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node-5-2/-/node-5-2-3.1.54.tgz", + "integrity": "sha512-dqGbGACtrUYn7F3urhFsXwY8SIMdok5iqzDOsi766C8RDHHEZV1+INVaDdPVkgFfwpszfQTHrRRareqNlUWS9g==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node-7-4": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node-7-4/-/node-7-4-3.1.54.tgz", + "integrity": "sha512-X9i3UCopzF1sfZ2Ix50cT06LTZgAYbpi1g9mlhrDE5+JyyezV/YmJzY/8BIeiD1VN80rr26um+k+2CYqsgabxQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node-8-0": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-0/-/node-8-0-3.1.54.tgz", + "integrity": "sha512-wh57ixwcacO6rqKir6Cp5NyYbncIqyt3sUD7gdXVt9/ZGI5q5ysuR5UQ4mfERKBA2rfnaCpYKpQgVPmrs92oaQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node-8-1": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-1/-/node-8-1-3.1.54.tgz", + "integrity": "sha512-MLJ9lLGMnfitXwf6diQYJNG+k7Pv3Fzh/ZcbYsqwdITBPd4gy05BDwirIoboh3tvu3NLMhzydVz4dpJ5UYbSKw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node-8-2": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-2/-/node-8-2-3.1.54.tgz", + "integrity": "sha512-a01+IU7ZPxYPvgwnIYzC6ob4Eu0MFwbQ1OH0JKzm/pdFnaiRNC3bvHF4bp3pjaL6266vb8D3ojBvP3hMYiX3AA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node-8-3": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-3/-/node-8-3-3.1.54.tgz", + "integrity": "sha512-vZhfYqySKtpxb1+JvOHdGvZG82vhD7WgH5PYm7xjOYxQiXftqOtBLjwN5ZPg4562MMKrWFOLKHnW2JrxtvfyoQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node-8-4": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-4/-/node-8-4-3.1.54.tgz", + "integrity": "sha512-I0CKNOqxq2MfW+GDiKnKRMdVKZlYqtkHtmnAHbdMDBoE72DO73m/79z7CI0HNzVcQsHfCkbXj2XxS3/yEztjkA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/node-8-5": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/node-8-5/-/node-8-5-3.1.54.tgz", + "integrity": "sha512-n0R+XHuv7rkCmZ+y156khHjrni1Ij/jqVU41MDsie/aCC+gRqBLM+BXOOJ5CSejkFd0pj83kZcFJHrqVy8I/nw==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "wasm-feature-detect": "1.8.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/progress": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/progress/-/progress-3.1.54.tgz", + "integrity": "sha512-JBxgmvy8zUHSl1l7DI5b0dFNp0ZhqdSRunPey6TLbKbM3wZ1Gh6XLYlu+hF/ZVOySnC5NKidg67W1IdgtRPZEg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "3.1.54" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/scopes": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/scopes/-/scopes-3.1.54.tgz", + "integrity": "sha512-H2nmmCtrStLyUnAhTr3gmvwcOIZR4hGgL09IVs0BkFMv3bwyAKBrrmf7tSsmf7++ACC8pGX2GkbzvVdlb5GkbA==", + "dev": true, + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/stream-compression": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/stream-compression/-/stream-compression-3.1.54.tgz", + "integrity": "sha512-+HDzZh+vzDqBNv4ggCfDz4qgigheNhRtNpsVLBI/NqxuPN3lCTvrWgswliPBZg5AA+iKkGGoaoj/IMOR+AFgeQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/util": "3.1.54" + } + }, + "node_modules/@php-wasm/universal": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/universal/-/universal-3.1.54.tgz", + "integrity": "sha512-ZHBzAFhLQJRXnF5rjd3DsbDnf2YZhlEQDf0g3M8mIylzYVX/NZk3en3v/hKA3vhaMVxg6KIr2URA2mzPKKboYg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "3.1.54", + "@php-wasm/progress": "3.1.54", + "@php-wasm/stream-compression": "3.1.54", + "@php-wasm/util": "3.1.54", + "ini": "4.1.2" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/util": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/util/-/util-3.1.54.tgz", + "integrity": "sha512-UdDiYnXDJ6g+mamRukQ4k0UoJBq9BQdaXpSemqfNAqX8CHfjuhW9T6iMJCCtLwY0aXvKc8a4qVoGQrrc+SQHow==", + "dev": true, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/web-service-worker": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/web-service-worker/-/web-service-worker-3.1.54.tgz", + "integrity": "sha512-3DQ6LLmVYiqC1gauyXhbLlns6NMo8pLKBGOf1hpKH+RzGA8pwAMyD0jvFPAo6C/62Xp1A//Cqt0+mYhE94Z6Hg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/scopes": "3.1.54", + "@php-wasm/universal": "3.1.54" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/xdebug-bridge": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@php-wasm/xdebug-bridge/-/xdebug-bridge-3.1.54.tgz", + "integrity": "sha512-Fm6LchCegBPAWrcom6iiWG3ZgmMO6D+ROJ7jpz2gF8W5AmQ7rMI5sKp8l5A2V0ObWabU+uTsuMB5lGr+e0XTNQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "3.1.54", + "@php-wasm/universal": "3.1.54", + "ws": "8.21.0", + "xml2js": "0.6.2", + "yargs": "17.7.2" + }, + "bin": { + "xdebug-bridge": "xdebug-bridge.js" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@php-wasm/xdebug-bridge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@php-wasm/xdebug-bridge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@php-wasm/xdebug-bridge/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/aws-lambda": { + "version": "8.10.163", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.163.tgz", + "integrity": "sha512-+4zuoEB3S8RIhimtOFT7zAEk2SbpwrKjjGl9CyYnQR6k08uynxfauIIB0pDU1ssr05F8oyGiETwpn+8eXZMqPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/btoa-lite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.2.tgz", + "integrity": "sha512-ZYbcE2x7yrvNFJiU7xJGrpF/ihpkM7zKgw8bha3LNJSesvTtUNxbpzaT7WXBIryf6jovisrxTBvymxMeLLj1Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz", + "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@wordpress/env": { + "version": "10.39.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-10.39.0.tgz", + "integrity": "sha512-Hgl2RQAAzXFMqkpegGWT1/KkX88OVikRroPidWkij1WtU8p+AZniTcncWmlWqbdLdfGbPqQS5ZkqDZCzrQjgnA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@inquirer/prompts": "^7.2.0", + "@wp-playground/cli": "^3.0.0", + "chalk": "^4.0.0", + "copy-dir": "^1.3.0", + "cross-spawn": "^7.0.6", + "docker-compose": "^0.24.3", + "extract-zip": "^1.6.7", + "got": "^11.8.5", + "js-yaml": "^3.13.1", + "ora": "^4.0.2", + "rimraf": "^5.0.10", + "simple-git": "^3.5.0", + "terminal-link": "^2.0.0", + "yargs": "^17.3.0" + }, + "bin": { + "wp-env": "bin/wp-env" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wp-playground/blueprints": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@wp-playground/blueprints/-/blueprints-3.1.54.tgz", + "integrity": "sha512-lsFx6igM+QLEwdiCncGg6e9GwxcIubSk+iJ+h0xgeq+e/3V90ORVMDXNRE5SfNTA0gIyxNXINz8J+p/XR07N/w==", + "dev": true, + "dependencies": { + "@php-wasm/logger": "3.1.54", + "@php-wasm/progress": "3.1.54", + "@php-wasm/stream-compression": "3.1.54", + "@php-wasm/universal": "3.1.54", + "@php-wasm/util": "3.1.54", + "@php-wasm/web-service-worker": "3.1.54", + "@wp-playground/common": "3.1.54", + "@wp-playground/storage": "3.1.54", + "@wp-playground/wordpress": "3.1.54", + "ajv": "8.18.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@wp-playground/cli": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@wp-playground/cli/-/cli-3.1.54.tgz", + "integrity": "sha512-XxwG1WlOdz7xxv8eM7FDOg3rEBwsMEtVAa7Z8UOZqzHMzbmFTH2eFnAu0Ri6rg24aasVPRE72kdeNWkyCvTAog==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/cli-util": "3.1.54", + "@php-wasm/logger": "3.1.54", + "@php-wasm/node": "3.1.54", + "@php-wasm/progress": "3.1.54", + "@php-wasm/universal": "3.1.54", + "@php-wasm/util": "3.1.54", + "@php-wasm/xdebug-bridge": "3.1.54", + "@wp-playground/blueprints": "3.1.54", + "@wp-playground/common": "3.1.54", + "@wp-playground/storage": "3.1.54", + "@wp-playground/tools": "3.1.54", + "@wp-playground/wordpress": "3.1.54", + "express": "4.22.2", + "fs-extra": "11.1.1", + "tmp-promise": "3.0.3", + "wasm-feature-detect": "1.8.0", + "yargs": "17.7.2" + }, + "bin": { + "wp-playground-cli": "wp-playground.js" + } + }, + "node_modules/@wp-playground/cli/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@wp-playground/cli/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wp-playground/cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@wp-playground/common": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@wp-playground/common/-/common-3.1.54.tgz", + "integrity": "sha512-Ev3s1664g9WK9v7jfS1IuiDQZyCSY7o4LlvEVy/85xlPNbwaAmJuIFBdX2amHWxXEv6CUWGsEBJFF94P/wne/g==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/universal": "3.1.54", + "@php-wasm/util": "3.1.54" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@wp-playground/storage": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@wp-playground/storage/-/storage-3.1.54.tgz", + "integrity": "sha512-B5lMfaTGdJ2//aY6dg1F3F+Zn3F6Ilh3k7aHV6Vmx0gZJ+r7N0c/c7teoLT799gz8OGEcK+N0BLpsyELMQ6xoA==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/stream-compression": "3.1.54", + "@php-wasm/universal": "3.1.54", + "@php-wasm/util": "3.1.54", + "@zip.js/zip.js": "2.7.57", + "isomorphic-git": "1.37.6", + "octokit": "3.1.2", + "pako": "^1.0.10", + "sha.js": "2.4.12" + } + }, + "node_modules/@wp-playground/tools": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@wp-playground/tools/-/tools-3.1.54.tgz", + "integrity": "sha512-12g4WnTtrQc9d7/rRY0onBgCArLcoNbV4APWcwbTdrJBmrWc8kvOTYrs0g3lm9vTqm38JHDN8ZJgL3Y9PGtnCg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/util": "3.1.54", + "@wp-playground/blueprints": "3.1.54" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@wp-playground/wordpress": { + "version": "3.1.54", + "resolved": "https://registry.npmjs.org/@wp-playground/wordpress/-/wordpress-3.1.54.tgz", + "integrity": "sha512-a1McQQPimZBL+pDfsg/O2GrSC/y3yBMr/c5uXCEn5dYrseqJ2Tni2AzE1oEW8IxPX/gAq+9XRiLCm7sxRdyVYg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@php-wasm/logger": "3.1.54", + "@php-wasm/universal": "3.1.54", + "@php-wasm/util": "3.1.54", + "@wp-playground/common": "3.1.54", + "zstddec": "^0.2.0" + }, + "engines": { + "node": ">=20.10.0", + "npm": ">=10.2.3" + } + }, + "node_modules/@zip.js/zip.js": { + "version": "2.7.57", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.57.tgz", + "integrity": "sha512-BtonQ1/jDnGiMed6OkV6rZYW78gLmLswkHOzyMrMb+CAR7CZO8phOHO6c2qw6qb1g1betN7kwEHhhZk30dv+NA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=16.5.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dev": true, + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bottleneck": { + "version": "2.19.5", + "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", + "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/btoa-lite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/btoa-lite/-/btoa-lite-1.0.0.tgz", + "integrity": "sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/copy-dir/-/copy-dir-1.3.0.tgz", + "integrity": "sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/docker-compose": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.8.tgz", + "integrity": "sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.2.2" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-ext-extra-prebuilt": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/fs-ext-extra-prebuilt/-/fs-ext-extra-prebuilt-2.2.7.tgz", + "integrity": "sha512-Q7rayYRBDIvDF01HWOwSSjoaP+05N1g+o3BXL1Zf8Frw2JkjSmi4EtvCBITuW30l6hB2m2TW1pehdh8wyU/+gw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "nan": "^2.24.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.2.tgz", + "integrity": "sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isomorphic-git": { + "version": "1.37.6", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.37.6.tgz", + "integrity": "sha512-qr1NFCPsVTZ6YGqTXw0CzamnsHyH9QQ1OTEfeXIweSljRUMzuHFCJdUn0wc6OcjtTDns6knxjPb7N6LmJeftOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "pify": "^4.0.1", + "readable-stream": "^4.0.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/isomorphic-git/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "dev": true, + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/isomorphic-git/node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", + "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "name": "@wolfy1339/lru-cache", + "version": "11.0.2-patch.1", + "resolved": "https://registry.npmjs.org/@wolfy1339/lru-cache/-/lru-cache-11.0.2-patch.1.tgz", + "integrity": "sha512-BgYZfL2ADCXKOw2wJtkM3slhHotawWkgIRRxq4wEybnZQPjvAp71SPX35xepMykTw8gXlzWcWPTY31hlbnRsDA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "18 >=18.20 || 20 || >=22" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/octokit": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/octokit/-/octokit-3.1.2.tgz", + "integrity": "sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/app": "^14.0.2", + "@octokit/core": "^5.0.0", + "@octokit/oauth-app": "^6.0.0", + "@octokit/plugin-paginate-graphql": "^4.0.0", + "@octokit/plugin-paginate-rest": "^9.0.0", + "@octokit/plugin-rest-endpoint-methods": "^10.0.0", + "@octokit/plugin-retry": "^6.0.0", + "@octokit/plugin-throttling": "^8.0.0", + "@octokit/request-error": "^5.0.0", + "@octokit/types": "^12.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-4.1.1.tgz", + "integrity": "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^3.0.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.2.0", + "is-interactive": "^1.0.0", + "log-symbols": "^3.0.0", + "mute-stream": "0.0.8", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "dev": true, + "license": "ISC" + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "dev": true, + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-git": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, + "node_modules/simple-git/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/simple-git/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-github-app-jwt": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/universal-github-app-jwt/-/universal-github-app-jwt-1.2.0.tgz", + "integrity": "sha512-dncpMpnsKBk0eetwfN8D8OUHGfiDhhJ+mtsbMl+7PfW7mYjiH8LIcqRmYMtzYLgSh47HjfdBtrBwIQ/gizKR3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.0", + "jsonwebtoken": "^9.0.2" + } + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "dev": true, + "license": "MIT AND BSD-3-Clause" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f46bd38 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "libresign-wp-customizations", + "private": true, + "license": "GPL-2.0-or-later", + "description": "Browser tests for the LibreSign WordPress customizations", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "env:start": "wp-env start", + "env:stop": "wp-env stop" + }, + "devDependencies": { + "@playwright/test": "^1.50.0", + "@wordpress/env": "^10.16.0" + } +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist index fea8d51..ae1be54 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,8 @@ parameters: - src - tests excludePaths: + analyse: + - tests/E2E/support/*.php analyseAndScan: # Answers the WordPress functions the plugin reaches while it loads, # so the unit suite runs without WordPress. Analysing it would take diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..aa489b4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test'; + +import { CUSTOMER_STATE } from './tests/E2E/support/site'; + +export default defineConfig( { + testDir: 'tests/E2E', + outputDir: 'tests/E2E/.results', + workers: 1, + fullyParallel: false, + forbidOnly: Boolean( process.env.CI ), + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [ + [ 'github' ], + [ + 'html', + { open: 'never', outputFolder: 'tests/E2E/.report' }, + ], + ] + : [ [ 'list' ] ], + use: { + baseURL: process.env.WP_BASE_URL ?? 'http://localhost', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'setup', + testMatch: /.*\.setup\.ts/, + use: { ...devices[ 'Desktop Chrome' ] }, + }, + { + name: 'customer', + dependencies: [ 'setup' ], + use: { + ...devices[ 'Desktop Chrome' ], + storageState: CUSTOMER_STATE, + }, + }, + ], +} ); diff --git a/tests/E2E/Account/Navigation.spec.ts b/tests/E2E/Account/Navigation.spec.ts new file mode 100644 index 0000000..47b085c --- /dev/null +++ b/tests/E2E/Account/Navigation.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from '@playwright/test'; + +import { seededSite } from '../support/site'; + +const ENTRY = '.woocommerce-MyAccount-navigation-link'; + +test.describe( 'My Account navigation', () => { + test( 'keeps five entries, in the order the plugin sets', async ( { + page, + } ) => { + await page.goto( '/' ); + + const entries = page.locator( ENTRY ); + await expect( entries ).toHaveCount( 5 ); + + const order = [ + 'subscriptions', + 'orders', + 'payment-methods', + 'edit-account', + 'customer-logout', + ]; + + for ( const [ position, endpoint ] of order.entries() ) { + await expect( entries.nth( position ) ).toHaveClass( + new RegExp( `${ ENTRY.slice( 1 ) }--${ endpoint }(\\s|$)` ) + ); + } + } ); + + test( 'renames the three entries it relabels', async ( { page } ) => { + await page.goto( '/' ); + + await expect( page.locator( `${ ENTRY }--subscriptions a` ) ).toHaveText( + 'My subscription' + ); + await expect( page.locator( `${ ENTRY }--orders a` ) ).toHaveText( + 'Invoices' + ); + await expect( + page.locator( `${ ENTRY }--payment-methods a` ) + ).toHaveText( 'Billing' ); + } ); + + test( 'titles a screen with the label its entry carries', async ( { + page, + } ) => { + await page.goto( '/orders/' ); + + await expect( + page.getByRole( 'heading', { name: 'Invoices' } ) + ).toBeVisible(); + } ); + + test( 'shows the payment methods and the addresses on the billing screen', async ( { + page, + } ) => { + await page.goto( '/payment-methods/' ); + + await expect( + page.locator( '.libresign-account-section-title' ) + ).toHaveText( [ 'Payment methods', 'Addresses' ] ); + + await expect( + page.getByText( + 'These addresses are used on your invoices and subscription charges.' + ) + ).toBeVisible(); + } ); + + test( 'sends the standalone addresses list to the billing screen', async ( { + page, + } ) => { + await page.goto( '/edit-address/' ); + + await expect( page ).toHaveURL( '/payment-methods/' ); + } ); + + test( 'keeps billing highlighted while an address is being edited', async ( { + page, + } ) => { + await page.goto( '/edit-address/billing/' ); + + await expect( + page.locator( `${ ENTRY }--payment-methods` ) + ).toHaveClass( /is-active/ ); + } ); + + test( 'points the account screens at the signature system', async ( { + page, + } ) => { + await page.goto( '/' ); + + const callToAction = page.locator( '.libresign-nextcloud-account-cta' ); + + await expect( callToAction ).toBeVisible(); + await expect( + callToAction.getByRole( 'link', { + name: 'Ir para o sistema de assinaturas', + } ) + ).toHaveAttribute( 'href', seededSite().nextcloudHost ); + } ); +} ); diff --git a/tests/E2E/Account/RootEndpoint.spec.ts b/tests/E2E/Account/RootEndpoint.spec.ts new file mode 100644 index 0000000..82e2445 --- /dev/null +++ b/tests/E2E/Account/RootEndpoint.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from '@playwright/test'; + +test.describe( 'Account screens served from the site root', () => { + test( 'serves the invoices screen at /orders/', async ( { page } ) => { + await page.goto( '/orders/' ); + + await expect( page ).toHaveURL( '/orders/' ); + await expect( + page.locator( '.woocommerce-MyAccount-navigation-link--orders' ) + ).toHaveClass( /is-active/ ); + } ); + + test( 'serves the billing screen at /payment-methods/', async ( { + page, + } ) => { + await page.goto( '/payment-methods/' ); + + await expect( page ).toHaveURL( '/payment-methods/' ); + await expect( + page.locator( + '.woocommerce-MyAccount-navigation-link--payment-methods' + ) + ).toHaveClass( /is-active/ ); + } ); + + test( 'keeps the /my-account/ permalink working as an alias', async ( { + page, + } ) => { + await page.goto( '/my-account/' ); + + await expect( page ).toHaveURL( '/my-account/' ); + await expect( + page.locator( '.woocommerce-MyAccount-navigation' ) + ).toBeVisible(); + } ); + + test.describe( 'signed out', () => { + test.use( { storageState: { cookies: [], origins: [] } } ); + + test( 'serves the lost password screen at /lost-password/', async ( { + page, + } ) => { + await page.goto( '/lost-password/' ); + + await expect( page ).toHaveURL( '/lost-password/' ); + await expect( + page.locator( 'form.lost_reset_password' ) + ).toBeVisible(); + } ); + } ); +} ); diff --git a/tests/E2E/Subscription/StatusChange.spec.ts b/tests/E2E/Subscription/StatusChange.spec.ts new file mode 100644 index 0000000..f94689f --- /dev/null +++ b/tests/E2E/Subscription/StatusChange.spec.ts @@ -0,0 +1,64 @@ +import { expect, Page, test } from '@playwright/test'; + +import { SeededSite, seedSite, subscriptionStatus } from '../support/site'; + +const CANCELLATION_QUESTION = + 'Are you sure you want to cancel your subscription? This action cannot be undone.'; + +const cancelAction = ( page: Page ) => + page.getByRole( 'button', { name: 'Cancel', exact: true } ); + +test.describe( 'Confirming a subscription status change', () => { + let site: SeededSite; + + test.beforeEach( () => { + site = seedSite(); + } ); + + test( 'asks before cancelling instead of cancelling right away', async ( { + page, + } ) => { + await page.goto( `/view-subscription/${ site.subscriptionId }/` ); + await cancelAction( page ).click(); + + await expect( page.getByText( CANCELLATION_QUESTION ) ).toBeVisible(); + await expect( + page.getByRole( 'link', { name: 'Yes, cancel subscription' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'link', { name: 'No, keep subscription' } ) + ).toBeVisible(); + + expect( subscriptionStatus( site.subscriptionId ) ).toBe( 'active' ); + } ); + + test( 'leaves the subscription active when the customer backs out', async ( { + page, + } ) => { + await page.goto( `/view-subscription/${ site.subscriptionId }/` ); + await cancelAction( page ).click(); + await page + .getByRole( 'link', { name: 'No, keep subscription' } ) + .click(); + + await expect( page.getByText( CANCELLATION_QUESTION ) ).toBeHidden(); + + expect( subscriptionStatus( site.subscriptionId ) ).toBe( 'active' ); + } ); + + test( 'stops the renewals once the customer confirms', async ( { + page, + } ) => { + await page.goto( `/view-subscription/${ site.subscriptionId }/` ); + await cancelAction( page ).click(); + await page + .getByRole( 'link', { name: 'Yes, cancel subscription' } ) + .click(); + + await expect( cancelAction( page ) ).toBeHidden(); + + expect( subscriptionStatus( site.subscriptionId ) ).toBe( + 'pending-cancel' + ); + } ); +} ); diff --git a/tests/E2E/customer.setup.ts b/tests/E2E/customer.setup.ts new file mode 100644 index 0000000..762b3ed --- /dev/null +++ b/tests/E2E/customer.setup.ts @@ -0,0 +1,18 @@ +import { expect, test as setup } from '@playwright/test'; + +import { CUSTOMER_PASSWORD, CUSTOMER_STATE, seedSite } from './support/site'; + +setup( 'seed the site and sign the customer in', async ( { page } ) => { + const site = seedSite(); + + await page.goto( '/wp-login.php?redirect_to=%2F' ); + await page.locator( '#user_login' ).fill( site.customerLogin ); + await page.locator( '#user_pass' ).fill( CUSTOMER_PASSWORD ); + await page.locator( '#wp-submit' ).click(); + + await expect( + page.locator( '.woocommerce-MyAccount-navigation' ) + ).toBeVisible(); + + await page.context().storageState( { path: CUSTOMER_STATE } ); +} ); diff --git a/tests/E2E/support/saved-payment-methods-gateway.php b/tests/E2E/support/saved-payment-methods-gateway.php new file mode 100644 index 0000000..9ea7311 --- /dev/null +++ b/tests/E2E/support/saved-payment-methods-gateway.php @@ -0,0 +1,30 @@ +id = 'libresign_e2e_saved_payment_methods'; + $this->method_title = 'LibreSign E2E saved payment methods'; + $this->title = 'LibreSign E2E saved payment methods'; + $this->has_fields = false; + $this->enabled = 'yes'; + $this->supports = array( 'products', 'tokenization', 'add_payment_method' ); + } + } + + add_filter( 'woocommerce_payment_gateways', 'libresign_e2e_add_saved_payment_methods_gateway' ); +} +add_action( 'plugins_loaded', 'libresign_e2e_define_saved_payment_methods_gateway' ); diff --git a/tests/E2E/support/seed.php b/tests/E2E/support/seed.php new file mode 100644 index 0000000..d564d11 --- /dev/null +++ b/tests/E2E/support/seed.php @@ -0,0 +1,127 @@ +' ); +} + +if ( ! function_exists( 'wcs_create_subscription' ) ) { + WP_CLI::error( 'WooCommerce Subscriptions is not active on the site under test.' ); +} + +$customer_login = 'libresign_e2e_customer'; +$customer_email = 'e2e-customer@libresign.test'; +$product_sku = 'LIBRESIGN-E2E-PLAN'; + +if ( '' === (string) get_option( 'permalink_structure' ) ) { + update_option( 'permalink_structure', '/%postname%/' ); +} + +$account_page_id = (int) wc_get_page_id( 'myaccount' ); + +if ( $account_page_id <= 0 ) { + WP_CLI::error( 'WooCommerce has no My Account page; install the WooCommerce pages first.' ); +} + +update_option( 'show_on_front', 'page' ); +update_option( 'page_on_front', $account_page_id ); + +delete_option( 'libresign_root_my_account_rewrite_version' ); + +if ( '' === libresign_get_nextcloud_public_host() ) { + update_option( 'nextcloud_public_host', 'https://nextcloud.libresign.test' ); +} + +$customer = get_user_by( 'login', $customer_login ); + +if ( $customer instanceof WP_User ) { + $customer_id = $customer->ID; + $password_already_matches = wp_check_password( $customer_password, $customer->user_pass, $customer_id ); + + if ( ! $password_already_matches ) { + wp_set_password( $customer_password, $customer_id ); + } +} else { + $customer_id = wp_insert_user( + array( + 'user_login' => $customer_login, + 'user_email' => $customer_email, + 'user_pass' => $customer_password, + 'first_name' => 'Ana', + 'last_name' => 'Lima', + 'role' => 'customer', + ) + ); + + if ( is_wp_error( $customer_id ) ) { + WP_CLI::error( $customer_id->get_error_message() ); + } + + $customer_id = (int) $customer_id; +} + +update_user_meta( $customer_id, 'billing_first_name', 'Ana' ); +update_user_meta( $customer_id, 'billing_last_name', 'Lima' ); +update_user_meta( $customer_id, 'billing_email', $customer_email ); +update_user_meta( $customer_id, 'billing_country', 'BR' ); + +$product_id = (int) wc_get_product_id_by_sku( $product_sku ); + +if ( $product_id <= 0 ) { + $new_product = new WC_Product_Subscription(); + $new_product->set_name( 'LibreSign E2E plan' ); + $new_product->set_slug( 'libresign-e2e-plan' ); + $new_product->set_sku( $product_sku ); + $new_product->set_status( 'publish' ); + $new_product->set_catalog_visibility( 'hidden' ); + $new_product->set_regular_price( '10' ); + $new_product->update_meta_data( '_subscription_price', '10' ); + $new_product->update_meta_data( '_subscription_period', 'month' ); + $new_product->update_meta_data( '_subscription_period_interval', '1' ); + $new_product->update_meta_data( '_subscription_length', '0' ); + $product_id = (int) $new_product->save(); +} + +foreach ( wcs_get_users_subscriptions( $customer_id ) as $subscription_of_a_previous_run ) { + $subscription_of_a_previous_run->delete( true ); +} + +$subscription = wcs_create_subscription( + array( + 'customer_id' => $customer_id, + 'status' => 'pending', + 'billing_period' => 'month', + 'billing_interval' => 1, + 'start_date' => gmdate( 'Y-m-d H:i:s', time() - MONTH_IN_SECONDS ), + ) +); + +if ( is_wp_error( $subscription ) ) { + WP_CLI::error( $subscription->get_error_message() ); +} + +$subscription->set_requires_manual_renewal( true ); +$subscription->set_billing_first_name( 'Ana' ); +$subscription->set_billing_last_name( 'Lima' ); +$subscription->set_billing_email( $customer_email ); +$subscription->add_product( wc_get_product( $product_id ), 1 ); +$subscription->calculate_totals(); +$subscription->save(); +$subscription->update_status( 'active' ); + +WP_CLI::line( + (string) wp_json_encode( + array( + 'customerId' => $customer_id, + 'customerLogin' => $customer_login, + 'customerEmail' => $customer_email, + 'productId' => $product_id, + 'subscriptionId' => $subscription->get_id(), + 'accountPageId' => $account_page_id, + 'nextcloudHost' => libresign_get_nextcloud_public_host(), + ) + ) +); diff --git a/tests/E2E/support/site.ts b/tests/E2E/support/site.ts new file mode 100644 index 0000000..0b940f2 --- /dev/null +++ b/tests/E2E/support/site.ts @@ -0,0 +1,67 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { runWpCli } from './wp-cli'; + +const SEED_SCRIPT = join( __dirname, 'seed.php' ); +const STATE_DIR = join( __dirname, '..', '.state' ); +const SITE_STATE = join( STATE_DIR, 'site.json' ); + +export const CUSTOMER_STATE = join( STATE_DIR, 'customer.json' ); + +export const CUSTOMER_PASSWORD = + process.env.WP_E2E_CUSTOMER_PASSWORD ?? 'libresign-e2e'; + +export interface SeededSite { + customerId: number; + customerLogin: string; + customerEmail: string; + productId: number; + subscriptionId: number; + accountPageId: number; + nextcloudHost: string; +} + +export function seedSite(): SeededSite { + const output = runWpCli( + [ 'eval-file', '-', CUSTOMER_PASSWORD ], + readFileSync( SEED_SCRIPT, 'utf8' ) + ); + + runWpCli( [ 'rewrite', 'flush', '--hard' ] ); + + const description = output + .split( '\n' ) + .reverse() + .find( ( line ) => line.startsWith( '{' ) ); + + if ( ! description ) { + throw new Error( `The seed printed no site description:\n${ output }` ); + } + + const site = JSON.parse( description ) as SeededSite; + + mkdirSync( dirname( SITE_STATE ), { recursive: true } ); + writeFileSync( SITE_STATE, JSON.stringify( site, null, '\t' ) ); + + return site; +} + +export function seededSite(): SeededSite { + return JSON.parse( readFileSync( SITE_STATE, 'utf8' ) ) as SeededSite; +} + +export function subscriptionStatus( subscriptionId: number ): string { + const output = runWpCli( [ + 'eval', + `echo 'status:' . wcs_get_subscription( ${ subscriptionId } )->get_status();`, + ] ); + + const status = output.match( /status:(\S+)/ ); + + if ( ! status ) { + throw new Error( `WP-CLI reported no status:\n${ output }` ); + } + + return status[ 1 ]; +} diff --git a/tests/E2E/support/wp-cli.ts b/tests/E2E/support/wp-cli.ts new file mode 100644 index 0000000..5971e1e --- /dev/null +++ b/tests/E2E/support/wp-cli.ts @@ -0,0 +1,16 @@ +import { execFileSync } from 'node:child_process'; + +const WP_CLI_ON_THE_LOCAL_STACK = + 'docker exec -i -u www-data wordpress-docker-wordpress-1 wp --path=/var/www/html'; + +export function runWpCli( args: readonly string[], stdin = '' ): string { + const [ command, ...prefix ] = ( + process.env.WP_CLI ?? WP_CLI_ON_THE_LOCAL_STACK + ).split( /\s+/ ); + + return execFileSync( command, [ ...prefix, ...args ], { + input: stdin, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + } ); +} From 37f0245e960fa87bc581d58d2aa2337b3e1d12ec Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 17:47:21 -0400 Subject: [PATCH 10/16] ci: run the playwright suite on every pull request Signed-off-by: YvesCesar --- .github/workflows/e2e.yml | 40 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/e2e.yml diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..29b22e3 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,40 @@ +name: End to end + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + playwright: + name: Playwright + runs-on: ubuntu-latest + env: + WP_BASE_URL: http://localhost:8888 + WP_CLI: npx wp-env run cli wp + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + + - run: npm ci + + - run: npx playwright install --with-deps chromium + + - run: npm run env:start + + - run: npm run test:e2e + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: tests/E2E/.report + retention-days: 7 From d0e9284a8084c0f33680b97606c5886b5b47e19c Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 18:04:00 -0400 Subject: [PATCH 11/16] test: refuse to seed a site that is not disposable Signed-off-by: YvesCesar --- README.md | 14 +++++++++----- tests/E2E/support/seed.php | 13 +++++++++++++ tests/E2E/support/site.ts | 5 ++++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bf3dee4..aac0f4d 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ reach without WooCommerce, the rewrite rules and a browser — the account navigation, the account screens served from the site root and the confirmation of a subscription status change. -The suite runs against a WordPress that is already up, described by three +The suite runs against a WordPress that is already up, described by four variables: | Variable | Default | @@ -112,6 +112,7 @@ variables: | `WP_BASE_URL` | `http://localhost` | | `WP_CLI` | `docker exec -i -u www-data wordpress-docker-wordpress-1 wp --path=/var/www/html` | | `WP_E2E_CUSTOMER_PASSWORD` | `libresign-e2e` | +| `WP_E2E_ALLOW_ANY_SITE` | unset | The defaults are the local SaaS stack again, so there it takes no arguments: @@ -121,10 +122,13 @@ npx playwright install chromium npm run test:e2e ``` -`tests/E2E/support/seed.php` puts the site in the state the specs expect — My -Account as the front page, a customer with that password and an active -subscription — and runs again before each test that changes it, so a failed run -leaves nothing behind. +`tests/E2E/support/seed.php` puts the site in the state the specs expect, and +runs again before each test that changes the subscription. It is destructive and +nothing is restored afterwards: it makes My Account the front page, rebuilds the +rewrite rules, and creates `libresign_e2e_customer` with the password above, +which this repository publishes. That is why it refuses to run against anything +but `localhost` unless `WP_E2E_ALLOW_ANY_SITE=1` says so — point it at a site you +can throw away, never at production or staging. Anywhere else, point the first two at the site under test. `.wp-env.json` describes a disposable one, which is what CI runs: diff --git a/tests/E2E/support/seed.php b/tests/E2E/support/seed.php index d564d11..5d00cef 100644 --- a/tests/E2E/support/seed.php +++ b/tests/E2E/support/seed.php @@ -12,6 +12,19 @@ WP_CLI::error( 'WooCommerce Subscriptions is not active on the site under test.' ); } +$site_host = (string) wp_parse_url( (string) home_url(), PHP_URL_HOST ); +$site_is_local = in_array( $site_host, array( 'localhost', '127.0.0.1', '::1' ), true ); +$any_site_allowed = isset( $args[1] ) && 'any-site' === $args[1]; + +if ( ! $site_is_local && ! $any_site_allowed ) { + WP_CLI::error( + sprintf( + 'The seed makes My Account the front page of %s, rebuilds its rewrite rules and creates a customer with a password this repository publishes. Set WP_E2E_ALLOW_ANY_SITE=1 to run it outside localhost.', + $site_host + ) + ); +} + $customer_login = 'libresign_e2e_customer'; $customer_email = 'e2e-customer@libresign.test'; $product_sku = 'LIBRESIGN-E2E-PLAN'; diff --git a/tests/E2E/support/site.ts b/tests/E2E/support/site.ts index 0b940f2..fdb44f5 100644 --- a/tests/E2E/support/site.ts +++ b/tests/E2E/support/site.ts @@ -12,6 +12,9 @@ export const CUSTOMER_STATE = join( STATE_DIR, 'customer.json' ); export const CUSTOMER_PASSWORD = process.env.WP_E2E_CUSTOMER_PASSWORD ?? 'libresign-e2e'; +const SEED_TARGET = + process.env.WP_E2E_ALLOW_ANY_SITE === '1' ? 'any-site' : 'local-site-only'; + export interface SeededSite { customerId: number; customerLogin: string; @@ -24,7 +27,7 @@ export interface SeededSite { export function seedSite(): SeededSite { const output = runWpCli( - [ 'eval-file', '-', CUSTOMER_PASSWORD ], + [ 'eval-file', '-', CUSTOMER_PASSWORD, SEED_TARGET ], readFileSync( SEED_SCRIPT, 'utf8' ) ); From 67298d5c6d2ba2070c89be095d0500fc171e1494 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 18:04:00 -0400 Subject: [PATCH 12/16] test: locate the cancel action by its class instead of its label Signed-off-by: YvesCesar --- tests/E2E/Subscription/StatusChange.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/E2E/Subscription/StatusChange.spec.ts b/tests/E2E/Subscription/StatusChange.spec.ts index f94689f..4068269 100644 --- a/tests/E2E/Subscription/StatusChange.spec.ts +++ b/tests/E2E/Subscription/StatusChange.spec.ts @@ -5,8 +5,7 @@ import { SeededSite, seedSite, subscriptionStatus } from '../support/site'; const CANCELLATION_QUESTION = 'Are you sure you want to cancel your subscription? This action cannot be undone.'; -const cancelAction = ( page: Page ) => - page.getByRole( 'button', { name: 'Cancel', exact: true } ); +const cancelAction = ( page: Page ) => page.locator( 'a.cancel' ); test.describe( 'Confirming a subscription status change', () => { let site: SeededSite; From 33a4edfb57e89e33142d0055c6d07ae6188ac2ea Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 18:04:00 -0400 Subject: [PATCH 13/16] test: fall back when an environment variable is set to an empty string Signed-off-by: YvesCesar --- playwright.config.ts | 2 +- tests/E2E/support/site.ts | 2 +- tests/E2E/support/wp-cli.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index aa489b4..86d1370 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,7 +19,7 @@ export default defineConfig( { ] : [ [ 'list' ] ], use: { - baseURL: process.env.WP_BASE_URL ?? 'http://localhost', + baseURL: process.env.WP_BASE_URL || 'http://localhost', trace: 'retain-on-failure', screenshot: 'only-on-failure', }, diff --git a/tests/E2E/support/site.ts b/tests/E2E/support/site.ts index fdb44f5..fadbb5e 100644 --- a/tests/E2E/support/site.ts +++ b/tests/E2E/support/site.ts @@ -10,7 +10,7 @@ const SITE_STATE = join( STATE_DIR, 'site.json' ); export const CUSTOMER_STATE = join( STATE_DIR, 'customer.json' ); export const CUSTOMER_PASSWORD = - process.env.WP_E2E_CUSTOMER_PASSWORD ?? 'libresign-e2e'; + process.env.WP_E2E_CUSTOMER_PASSWORD || 'libresign-e2e'; const SEED_TARGET = process.env.WP_E2E_ALLOW_ANY_SITE === '1' ? 'any-site' : 'local-site-only'; diff --git a/tests/E2E/support/wp-cli.ts b/tests/E2E/support/wp-cli.ts index 5971e1e..94c69fc 100644 --- a/tests/E2E/support/wp-cli.ts +++ b/tests/E2E/support/wp-cli.ts @@ -5,7 +5,7 @@ const WP_CLI_ON_THE_LOCAL_STACK = export function runWpCli( args: readonly string[], stdin = '' ): string { const [ command, ...prefix ] = ( - process.env.WP_CLI ?? WP_CLI_ON_THE_LOCAL_STACK + process.env.WP_CLI || WP_CLI_ON_THE_LOCAL_STACK ).split( /\s+/ ); return execFileSync( command, [ ...prefix, ...args ], { From 22130bf597850bbc5e98522b0b8675152009e0c2 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 18:04:00 -0400 Subject: [PATCH 14/16] ci: pin woocommerce subscriptions and map the mu-plugin as a directory Signed-off-by: YvesCesar --- .wp-env.json | 4 ++-- phpstan.neon.dist | 2 +- .../{ => mu-plugins}/saved-payment-methods-gateway.php | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename tests/E2E/support/{ => mu-plugins}/saved-payment-methods-gateway.php (100%) diff --git a/.wp-env.json b/.wp-env.json index e26247e..e389c93 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -4,13 +4,13 @@ "plugins": [ ".", "https://downloads.wordpress.org/plugin/woocommerce.10.9.4.zip", - "pronamic/woocommerce-subscriptions#main" + "pronamic/woocommerce-subscriptions#v9.0.0" ], "config": { "WP_DEBUG": true, "WP_DEBUG_DISPLAY": false }, "mappings": { - "wp-content/mu-plugins/libresign-e2e-saved-payment-methods-gateway.php": "./tests/E2E/support/saved-payment-methods-gateway.php" + "wp-content/mu-plugins": "./tests/E2E/support/mu-plugins" } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index ae1be54..13a94d2 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -12,7 +12,7 @@ parameters: - tests excludePaths: analyse: - - tests/E2E/support/*.php + - tests/E2E/support analyseAndScan: # Answers the WordPress functions the plugin reaches while it loads, # so the unit suite runs without WordPress. Analysing it would take diff --git a/tests/E2E/support/saved-payment-methods-gateway.php b/tests/E2E/support/mu-plugins/saved-payment-methods-gateway.php similarity index 100% rename from tests/E2E/support/saved-payment-methods-gateway.php rename to tests/E2E/support/mu-plugins/saved-payment-methods-gateway.php From c4ad0df335ac1f7145cc5baeb2428e6ad3ab3a75 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Tue, 15 Sep 2026 18:04:00 -0400 Subject: [PATCH 15/16] chore: keep node_modules out of the php tooling Signed-off-by: YvesCesar --- composer.json | 2 +- phpcs.xml.dist | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f46c32a..338c797 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "wordpress-install-dir": "vendor/wordpress" }, "scripts": { - "lint": "parallel-lint --exclude vendor .", + "lint": "parallel-lint --exclude vendor --exclude node_modules .", "cs": "phpcs", "cs:fix": "phpcbf", "stan": "phpstan analyse", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 4292cae..640305b 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -4,6 +4,7 @@ . /vendor/* + /node_modules/* From 618bc97c28c19789e07038b6970e922fb17ef5d9 Mon Sep 17 00:00:00 2001 From: YvesCesar Date: Thu, 17 Sep 2026 17:29:26 -0400 Subject: [PATCH 16/16] ci: rename the e2e workflow to playwright Signed-off-by: YvesCesar --- .github/workflows/{e2e.yml => playwright.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{e2e.yml => playwright.yml} (97%) diff --git a/.github/workflows/e2e.yml b/.github/workflows/playwright.yml similarity index 97% rename from .github/workflows/e2e.yml rename to .github/workflows/playwright.yml index 29b22e3..4b8ff70 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/playwright.yml @@ -1,4 +1,4 @@ -name: End to end +name: Playwright on: push: