diff --git a/.github/workflows/cli-coverage.yml b/.github/workflows/cli-coverage.yml new file mode 100644 index 000000000..8953427c6 --- /dev/null +++ b/.github/workflows/cli-coverage.yml @@ -0,0 +1,78 @@ +name: CLI Coverage + +on: + push: + branches: [develop] + paths: + - '.github/workflows/cli-coverage.yml' + - '.github/actions/setup-php-composer/**' + - 'composer.json' + - 'composer.lock' + - 'bin/cacti' + - 'bin/.htaccess' + - 'include/cli_check.php' + - 'include/cli_only.php' + - 'lib/Console/**' + - 'phpunit-cli-coverage.xml' + - 'tests/fixtures/Console/**' + - 'tests/.htaccess' + - 'tests/index.php' + - 'tests/Unit/Core/Cli/CactiApplicationTest.php' + - 'tests/Unit/Core/Cli/CactiEntrypointSecurityTest.php' + pull_request: + branches: [develop] + paths: + - '.github/workflows/cli-coverage.yml' + - '.github/actions/setup-php-composer/**' + - 'composer.json' + - 'composer.lock' + - 'bin/cacti' + - 'bin/.htaccess' + - 'include/cli_check.php' + - 'include/cli_only.php' + - 'lib/Console/**' + - 'phpunit-cli-coverage.xml' + - 'tests/fixtures/Console/**' + - 'tests/.htaccess' + - 'tests/index.php' + - 'tests/Unit/Core/Cli/CactiApplicationTest.php' + - 'tests/Unit/Core/Cli/CactiEntrypointSecurityTest.php' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: cli-coverage-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + COMPOSER_ALLOW_SUPERUSER: 1 + +jobs: + coverage: + name: CLI 100% coverage + runs-on: ubuntu-24.04 + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - name: Setup locked PHP dependencies + uses: ./.github/actions/setup-php-composer + with: + php-version: '8.2' + coverage: xdebug + extensions: intl, mysql, gd, ldap, gmp, xml, curl, json, mbstring + dependency-mode: locked-cli-coverage + install-args: --prefer-dist --no-progress --no-interaction + + - name: Enforce line coverage + env: + XDEBUG_MODE: coverage + run: | + set -euo pipefail + composer test:cli-coverage diff --git a/bin/.htaccess b/bin/.htaccess new file mode 100644 index 000000000..18532a25d --- /dev/null +++ b/bin/.htaccess @@ -0,0 +1,10 @@ +# Apache 2.4 + + Require all denied + + +# Apache 2.2 + + Order Allow,Deny + Deny from all + diff --git a/bin/cacti b/bin/cacti new file mode 100755 index 000000000..7d9f835d5 --- /dev/null +++ b/bin/cacti @@ -0,0 +1,36 @@ +#!/usr/bin/env php +run(new RawArgvInput())); diff --git a/changelog.d/7902.feature b/changelog.d/7902.feature new file mode 100644 index 000000000..509f65445 --- /dev/null +++ b/changelog.d/7902.feature @@ -0,0 +1 @@ +Add a unified bin/cacti command line entry point over the legacy cli scripts and activate the shared CLI-only constants for CLI entrypoints diff --git a/cli/add_site.php b/cli/add_site.php index af045aa46..3e8b625a2 100755 --- a/cli/add_site.php +++ b/cli/add_site.php @@ -32,7 +32,7 @@ include_once(CACTI_PATH_LIBRARY . '/api_tree.php'); $parms = $_SERVER['argv']; array_shift($parms); -if (sizeof($parms)) { +if (cacti_sizeof($parms)) { // setup defaults $siteName = ''; // Site Name $siteAddr1 = ''; // Site Address 1 @@ -380,7 +380,7 @@ function mapDevices(int $siteId, bool $doMap) : void { } } - $numMatched = sizeof($matchedDevices); + $numMatched = cacti_sizeof($matchedDevices); if ($numMatched) { echoQuiet(PHP_EOL); diff --git a/cli/genkey.php b/cli/genkey.php index de2cf01cf..a01b2dac1 100755 --- a/cli/genkey.php +++ b/cli/genkey.php @@ -231,13 +231,13 @@ if ((file_exists($keypair_path . '/package.pem') || file_exists($keypair_path . if ($privKey === false) { print "FATAL: Unable to open your private key $privkey" . PHP_EOL; - exit(0); + exit(1); } if ($pubKey === false) { print "FATAL: Unable to open your public key $pubkey" . PHP_EOL; - exit(0); + exit(1); } } else { print 'NOTE: Generating custom public/private key pair.' . PHP_EOL; diff --git a/cli/poller_graphs_reapply_names.php b/cli/poller_graphs_reapply_names.php index 2f5c56608..08614d166 100755 --- a/cli/poller_graphs_reapply_names.php +++ b/cli/poller_graphs_reapply_names.php @@ -52,6 +52,7 @@ if (cacti_sizeof($parms)) { switch ($arg) { case '-id': + case '--id': case '--host-id': $host_id = $value; diff --git a/cli/remove_graphs.php b/cli/remove_graphs.php index f6e4133c7..b254293c0 100755 --- a/cli/remove_graphs.php +++ b/cli/remove_graphs.php @@ -184,6 +184,10 @@ if (cacti_sizeof($parms)) { default: print "ERROR: Invalid Argument: ($arg)" . PHP_EOL . PHP_EOL; + + display_help(); + + exit(1); } } } else { diff --git a/cli/removespikes.php b/cli/removespikes.php index 12b65db6f..def7bf804 100755 --- a/cli/removespikes.php +++ b/cli/removespikes.php @@ -225,7 +225,7 @@ if (!$result) { print 'ERROR: Remove Spikes experienced errors' . PHP_EOL; print $spiker->get_errors(); - exit(-1); + exit(1); } else { print $spiker->get_output(); diff --git a/cli/rrdresize.php b/cli/rrdresize.php index 906ba89e2..a2c6ab570 100755 --- a/cli/rrdresize.php +++ b/cli/rrdresize.php @@ -36,7 +36,7 @@ require_once('./include/cli_check.php'); $parms = $_SERVER['argv']; array_shift($parms); -if (sizeof($parms)) { +if (cacti_sizeof($parms)) { $data_template_id = false; $displayDataTemplates = false; $debug_mode = false; @@ -164,7 +164,7 @@ if ($displayDataTemplates) { print 'Known Data Templates: (id, name)' . PHP_EOL; - if (sizeof($data_templates)) { + if (cacti_sizeof($data_templates)) { foreach ($data_templates as $data_template) { print $data_template['id'] . "\t" . $data_template['name'] . PHP_EOL; } @@ -179,13 +179,13 @@ if ($displayDataTemplates) { if (!$data_template_id) { print 'ERROR: You must supply a valid data template id to run this script!' . PHP_EOL; - exit(0); + exit(1); } if (!$backup_folder) { print 'ERROR: You must define a backup folder!' . PHP_EOL; - exit(0); + exit(1); } else { $tmp_xml_file = $tmp_backup_folder . 'tmp_rrdresize.xml'; $tmp_rrd_file = $tmp_backup_folder . 'tmp_rrdfile.rrd'; @@ -240,7 +240,7 @@ if ($scanned_directory['folders']) { if (!mkdir($tmp_backup_folder)) { print 'ERROR: Unable to create a backup folder!' . PHP_EOL; - exit(0); + exit(1); } // create log file @@ -261,7 +261,7 @@ if ($scanned_directory['folders']) { $dt_hosts = []; - if (sizeof($db_hosts) > 0) { + if (cacti_sizeof($db_hosts) > 0) { foreach ($db_hosts as $key => $value) { $dt_hosts[] = $value['host_id']; } @@ -280,7 +280,7 @@ if ($scanned_directory['folders']) { $dt_data_sources = []; - if (sizeof($db_data_sources) > 0) { + if (cacti_sizeof($db_data_sources) > 0) { foreach ($db_data_sources as $key => $value) { $dt_data_sources[] = $value['local_data_id']; } @@ -361,7 +361,7 @@ if ($scanned_directory['folders']) { [$local_data['data_template_id']] ); - $data_template_ds_counter = sizeof($data_template_ds); + $data_template_ds_counter = cacti_sizeof($data_template_ds); $data_template_data_settings = db_fetch_assoc_prepared('SELECT * FROM data_template_data_rra LEFT JOIN rra diff --git a/cli/splice_rrd.php b/cli/splice_rrd.php index 591ffd4c7..25d0ca739 100755 --- a/cli/splice_rrd.php +++ b/cli/splice_rrd.php @@ -278,7 +278,7 @@ if (strlen($response)) { } else { print 'FATAL: RRDTool not found in configuration or path.' . PHP_EOL . 'Please insure RRDTool can be found using one of these methods!' . PHP_EOL; - exit(-1); + exit(1); } /* The dump files and the backups were previously named from the RRD basename diff --git a/composer.json b/composer.json index f2b47704c..868b86918 100644 --- a/composer.json +++ b/composer.json @@ -56,12 +56,14 @@ "slim/slim": "^4.14", "stevenmaguire/oauth2-keycloak": "^6.1.0", "stevenmaguire/oauth2-microsoft": "^2.2", + "symfony/console": "^7.4", "symfony/filesystem": "^7.4", "symfony/finder": "^6.4 || ^7.4", "symfony/http-foundation": "^7.4", "symfony/lock": "^7.4", "symfony/mailer": "^7.4", "symfony/notifier": "^7.4", + "symfony/process": "^7.4", "symfony/validator": "^7.4" }, "require-dev": { @@ -81,6 +83,7 @@ "hardening-patterns": "php tests/tools/check_hardening_patterns.php", "vendor-policy": "php tests/tools/check_vendor_policy.php", "php-cs-fixit": "php-cs-fixer fix ", + "test:cli-coverage": "@php include/vendor/bin/pest --configuration=phpunit-cli-coverage.xml --coverage --coverage-text --min=100", "install-hooks": "bash -c 'install -m 755 tests/tools/pre-commit \"$(git rev-parse --git-path hooks/pre-commit)\"'", "test": "include/vendor/bin/pest --display-warnings", "sink-guard": "bash tests/security/verify_sink_inventory.sh", @@ -114,6 +117,11 @@ ], "minimum-stability": "stable", "prefer-stable": true, + "autoload": { + "psr-4": { + "Cacti\\Console\\": "lib/Console/" + } + }, "config": { "sort-packages": true, "vendor-dir": "./include/vendor", diff --git a/composer.lock b/composer.lock index e49225a4f..877cfeb64 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "22cda0241ac22aff9c2fe23ef7abb3c6", + "content-hash": "33ddeb7d701bf817126ad36feb9a92e0", "packages": [ { "name": "doctrine/lexer", @@ -1935,6 +1935,104 @@ }, "time": "2017-06-07T13:42:47+00:00" }, + { + "name": "symfony/console", + "version": "v7.4.18", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:18:37+00:00" + }, { "name": "symfony/deprecation-contracts", "version": "v3.7.1", @@ -2814,6 +2912,88 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.42.0", @@ -3316,17 +3496,82 @@ "time": "2026-07-01T12:47:55+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.7.1", + "name": "symfony/process", + "version": "v7.4.18", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + "url": "https://github.com/symfony/process.git", + "reference": "058d17fc284cce14efb2385783b55014a461b176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", - "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", + "reference": "058d17fc284cce14efb2385783b55014a461b176", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.18" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:40:08+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", "shasum": "" }, "require": { @@ -3380,7 +3625,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" }, "funding": [ { @@ -3400,7 +3645,98 @@ "type": "tidelift" } ], - "time": "2026-06-16T09:55:08+00:00" + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" }, { "name": "symfony/translation-contracts", @@ -7734,104 +8070,6 @@ ], "time": "2026-06-05T06:23:12+00:00" }, - { - "name": "symfony/console", - "version": "v7.4.17", - "source": { - "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/962e18f09ebe68a49039b4c82fc0ea4871824fca", - "reference": "962e18f09ebe68a49039b4c82fc0ea4871824fca", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2|^8.0" - }, - "conflict": { - "symfony/dependency-injection": "<6.4", - "symfony/dotenv": "<6.4", - "symfony/event-dispatcher": "<6.4", - "symfony/lock": "<6.4", - "symfony/process": "<6.4" - }, - "provide": { - "psr/log-implementation": "1.0|2.0|3.0" - }, - "require-dev": { - "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/event-dispatcher": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/lock": "^6.4|^7.0|^8.0", - "symfony/messenger": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/stopwatch": "^6.4|^7.0|^8.0", - "symfony/var-dumper": "^6.4|^7.0|^8.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", - "keywords": [ - "cli", - "command-line", - "console", - "terminal" - ], - "support": { - "source": "https://github.com/symfony/console/tree/v7.4.17" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-08-21T12:09:28+00:00" - }, { "name": "symfony/options-resolver", "version": "v7.4.8", @@ -7903,88 +8141,6 @@ ], "time": "2026-03-24T13:12:05+00:00" }, - { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.41.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "suggest": { - "ext-intl": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for intl's grapheme_* functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-07-28T08:25:59+00:00" - }, { "name": "symfony/polyfill-php81", "version": "v1.38.1", @@ -8145,71 +8301,6 @@ ], "time": "2026-05-26T12:51:13+00:00" }, - { - "name": "symfony/process", - "version": "v7.4.17", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "058d17fc284cce14efb2385783b55014a461b176" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", - "reference": "058d17fc284cce14efb2385783b55014a461b176", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Process\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Executes commands in sub-processes", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/process/tree/v7.4.17" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-08-21T17:40:08+00:00" - }, { "name": "symfony/stopwatch", "version": "v7.4.8", @@ -8276,97 +8367,6 @@ ], "time": "2026-03-24T13:12:05+00:00" }, - { - "name": "symfony/string", - "version": "v7.4.15", - "source": { - "type": "git", - "url": "https://github.com/symfony/string.git", - "reference": "e394af32256bf9e7bf80849d95e589167c10097b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", - "reference": "e394af32256bf9e7bf80849d95e589167c10097b", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.33", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0" - }, - "conflict": { - "symfony/translation-contracts": "<2.5" - }, - "require-dev": { - "symfony/emoji": "^7.1|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", - "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" - }, - "type": "library", - "autoload": { - "files": [ - "Resources/functions.php" - ], - "psr-4": { - "Symfony\\Component\\String\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", - "homepage": "https://symfony.com", - "keywords": [ - "grapheme", - "i18n", - "string", - "unicode", - "utf-8", - "utf8" - ], - "support": { - "source": "https://github.com/symfony/string/tree/v7.4.15" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-07-28T07:33:02+00:00" - }, { "name": "symfony/var-exporter", "version": "v6.4.42", diff --git a/include/cli_check.php b/include/cli_check.php index f39a29b95..aa107c4c3 100644 --- a/include/cli_check.php +++ b/include/cli_check.php @@ -31,10 +31,11 @@ * * Any source wishing to check whether the system is in * CLI or WEB mode should use the CACTI_WEB and - * CACTI_CLI constants -define('CACTI_CLI_ONLY', true); + * CACTI_CLI constants. + */ +require_once(__DIR__ . '/cli_only.php'); -/* We are not talking to the browser */ +// We are not talking to the browser $no_http_headers = true; // Make sure CLI's are have minimum settings diff --git a/include/cli_only.php b/include/cli_only.php new file mode 100644 index 000000000..6e8b3d1ae --- /dev/null +++ b/include/cli_only.php @@ -0,0 +1,27 @@ +readVersion()); + + foreach (LegacyCommandMap::commands() as $script => $command) { + $this->add(new LegacyScriptCommand($command, $script, $this->root)); + } + } + + /** + * Dispatch a legacy command before Symfony can claim its flags. + * + * Application::doRun() answers --version/-V itself and rewrites --help/-h + * into the help command, and it reads both straight off the token vector, + * so dropping them from the input definition changes nothing. Forty-five + * scripts under cli/ carry their own --version and --help handling, so a + * legacy command owns its whole flag namespace and Symfony must not read + * any of it. + */ + #[\Override] + public function doRun(InputInterface $input, OutputInterface $output): int { + $name = $this->getCommandName($input); + + if ($name !== null && $name !== '') { + try { + $command = $this->find($name); + } catch (\Throwable) { + // Unknown or ambiguous; let Symfony report it as it always has. + $command = null; + } + + if ($command instanceof LegacyScriptCommand) { + /* -q belongs to the legacy script too. Symfony has already applied + * it to this output, which would swallow the child's stream while + * the flag is also forwarded to the child. */ + $output->setVerbosity(OutputInterface::VERBOSITY_NORMAL); + + return $command->run($input, $output); + } + } + + return parent::doRun($input, $output); + } + + private function readVersion(): string { + $version = @file_get_contents($this->root . '/include/cacti_version'); + + return $version === false ? 'unknown' : trim($version); + } +} diff --git a/lib/Console/Command/LegacyScriptCommand.php b/lib/Console/Command/LegacyScriptCommand.php new file mode 100644 index 000000000..2ba750b84 --- /dev/null +++ b/lib/Console/Command/LegacyScriptCommand.php @@ -0,0 +1,149 @@ +): Process */ + private readonly \Closure $process_factory; + /** @var \Closure(): bool */ + private readonly \Closure $tty_probe; + /** @var (\Closure(int, callable): void)|null */ + private readonly ?\Closure $signal_registrar; + + public function __construct( + string $name, + private readonly string $script, + private readonly string $root, + ?callable $process_factory = null, + ?callable $tty_probe = null, + callable|false|null $signal_registrar = null, + ) { + parent::__construct($name); + $this->process_factory = $process_factory === null + ? static fn (array $command): Process => new Process($command, null, null, STDIN, null) + : \Closure::fromCallable($process_factory); + $this->tty_probe = $tty_probe === null + ? static fn (): bool => defined('STDIN') && defined('STDOUT') && defined('STDERR') && Process::isTtySupported() && stream_isatty(STDIN) && stream_isatty(STDOUT) && stream_isatty(STDERR) + : \Closure::fromCallable($tty_probe); + $signals_available = function_exists('pcntl_async_signals') && function_exists('pcntl_signal'); + + if ($signal_registrar === false || ($signal_registrar === null && !$signals_available)) { + $this->signal_registrar = null; + } elseif ($signal_registrar !== null) { + $this->signal_registrar = \Closure::fromCallable($signal_registrar); + } else { + $this->signal_registrar = static function (int $signal, callable $handler): void { + pcntl_async_signals(true); + pcntl_signal($signal, $handler); + }; + } + $this->setAliases([$script]); + $this->setDescription(sprintf('Runs the legacy cli/%s.php command.', $script)); + $this->setHelp(sprintf( + 'This compatibility command forwards arguments, input, output, and the exit status to cli/%s.php.', + $script + )); + $this->ignoreValidationErrors(); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int { + if (!$input instanceof RawArgvInput) { + throw new \LogicException('Legacy commands require RawArgvInput.'); + } + + $arguments = $input->argumentsAfterCommand(array_values(array_merge([$this->getName()], $this->getAliases()))); + /* Inherit the caller's directory. The script path is absolute, and the + * legacy scripts resolve relative path arguments against the cwd with no + * normalization. Anchoring here would send a relative output into the + * web-served root instead of the directory the operator ran it from. */ + $process = ($this->process_factory)( + array_merge([PHP_BINARY, $this->root . '/cli/' . $this->script . '.php'], $arguments) + ); + $process->setTimeout(null); + + $this->forwardSignals($process); + + try { + /* Several scripts under cli/ prompt with fgets(STDIN). Behind a pipe + * the prompt sits in a buffer while the child blocks on the read, so + * hand the terminal over whenever there is one. */ + if ($this->canUseTty()) { + $process->setTty(true); + + return $process->run(); + } + + /* Process::buildCallback() appends every byte to a php://temp stream + * as well as calling this callback, and nothing ever reads it back. + * With no timeout, a long poller run spooled its whole output to disk + * for no reader. */ + $process->disableOutput(); + + return $process->run(function (string $type, string $buffer) use ($output): void { + $target = $type === Process::ERR && $output instanceof ConsoleOutputInterface + ? $output->getErrorOutput() + : $output; + $target->write($buffer, false, OutputInterface::OUTPUT_RAW); + }); + } catch (ProcessSignaledException $exception) { + /* A child the kernel killed has no exit code of its own, and Process + * throws rather than returning one. Report it the way a shell does so + * cron and systemd see 137 or 143, not a rendered stack trace. */ + return 128 + $exception->getSignal(); + } + } + + /** + * Whether this invocation owns a terminal it can hand to the child. + */ + private function canUseTty(): bool { + return ($this->tty_probe)(); + } + + /** + * Pass a termination signal on to the child. + * + * Without this a SIGTERM to bin/cacti leaves the legacy script running, + * possibly mid-write to the database, with nothing left to reap it. + */ + private function forwardSignals(Process $process): void { + if ($this->signal_registrar === null) { + return; + } + + foreach ([SIGTERM, SIGINT, SIGHUP] as $signal) { + ($this->signal_registrar)($signal, static function (int $received) use ($process): void { + try { + $process->signal($received); + } catch (ProcessLogicException) { + /* The child can exit between signal delivery and Process::signal(). + * Its real exit status remains authoritative in that race. */ + } + }); + } + } +} diff --git a/lib/Console/Input/RawArgvInput.php b/lib/Console/Input/RawArgvInput.php new file mode 100644 index 000000000..1b9befd7d --- /dev/null +++ b/lib/Console/Input/RawArgvInput.php @@ -0,0 +1,61 @@ + */ + private readonly array $rawTokens; + + /** @param list|null $argv */ + public function __construct(?array $argv = null, ?InputDefinition $definition = null) { + $argv ??= $_SERVER['argv'] ?? []; + $this->rawTokens = array_values(array_slice($argv, 1)); + + parent::__construct($argv, $definition); + } + + /** + * @param list $names + * @return list + */ + public function argumentsAfterCommand(array $names): array { + /* Symfony resolves abbreviated command names, so the typed token is + * often neither the registered name nor an alias. Without the token it + * actually consumed, an abbreviation matches nothing and the legacy + * script is spawned with an empty argv, silently taking its + * no-argument path. */ + $names[] = $this->getFirstArgument(); + + foreach ($this->rawTokens as $offset => $token) { + if (in_array($token, $names, true)) { + /* Drop the command token, keep everything else. getFirstArgument() + * skips leading options, so 'bin/cacti --help poller:rebuild-cache' + * dispatches with the flag ahead of the name; slicing only forward + * handed the script an empty argv and it rebuilt every cache. */ + return array_merge( + array_slice($this->rawTokens, 0, $offset), + array_slice($this->rawTokens, $offset + 1) + ); + } + } + + throw new \LogicException('The resolved command token is missing from the raw argument vector.'); + } +} diff --git a/lib/Console/LegacyCommandMap.php b/lib/Console/LegacyCommandMap.php new file mode 100644 index 000000000..7d558427e --- /dev/null +++ b/lib/Console/LegacyCommandMap.php @@ -0,0 +1,84 @@ + Legacy script basename to command name. */ + private const COMMANDS = [ + 'add_data_query' => 'data-query:add', + 'add_datasource' => 'data-source:add', + 'add_device' => 'device:add', + 'add_graph_template' => 'graph-template:add', + 'add_graphs' => 'graph:add', + 'add_group' => 'group:add', + 'add_group_perms' => 'group:permissions:add', + 'add_perms' => 'permissions:add', + 'add_site' => 'site:add', + 'add_tree' => 'tree:add', + 'analyze_database' => 'database:analyze', + 'apply_automation_rules' => 'automation:apply-rules', + 'audit_database' => 'database:audit', + 'audit_graph_template_inputs' => 'graph-template:audit-inputs', + 'batchgapfix' => 'rrd:batch-gap-fix', + 'change_device' => 'device:change', + 'clone_device_template' => 'device-template:clone', + 'convert_tables' => 'database:convert-tables', + 'copy_user' => 'user:copy', + 'fetch_plugins' => 'plugin:fetch', + 'fix_mediumint' => 'database:fix-mediumint', + 'float_rrdfiles' => 'rrd:float-files', + 'genkey' => 'security:generate-key', + 'genmanifest' => 'package:generate-manifest', + 'host_update_template' => 'device:update-template', + 'import_package' => 'package:import', + 'import_template' => 'template:import', + 'input_whitelist' => 'security:input-whitelist', + 'install_cacti' => 'system:install', + 'md5sum' => 'integrity:md5', + 'migrate_poller' => 'poller:migrate', + 'plugin_manage' => 'plugin:manage', + 'poller_data_sources_reapply_names' => 'poller:data-sources:reapply-names', + 'poller_graphs_reapply_names' => 'poller:graphs:reapply-names', + 'poller_output_empty' => 'poller:clear-empty-output', + 'poller_reindex_hosts' => 'poller:reindex', + 'poller_replicate' => 'poller:replicate', + 'push_out_hosts' => 'poller:push-hosts', + 'rebuild_poller_cache' => 'poller:rebuild-cache', + 'refresh_csrf' => 'security:refresh-csrf', + 'remove_broken_graphs' => 'graph:remove-broken', + 'remove_device' => 'device:remove', + 'remove_graphs' => 'graph:remove', + 'removespikes' => 'rrd:remove-spikes', + 'reorder_data_query' => 'data-query:reorder', + 'repair_database' => 'database:repair', + 'repair_graphs' => 'graph:repair', + 'repair_templates' => 'template:repair', + 'rrdresize' => 'rrd:resize', + 'show_perms' => 'permissions:show', + 'splice_rrd' => 'rrd:splice', + 'sqltable_to_php' => 'developer:sql-table-to-php', + 'structure_rra_paths' => 'rrd:structure-paths', + 'update_heartbeat' => 'rrd:update-heartbeat', + 'upgrade_database' => 'database:upgrade', + 'version' => 'system:version', + ]; + + /** @return array Legacy script basename to command name. */ + public static function commands(): array { + return self::COMMANDS; + } +} diff --git a/phpunit-cli-coverage.xml b/phpunit-cli-coverage.xml new file mode 100644 index 000000000..de88a1094 --- /dev/null +++ b/phpunit-cli-coverage.xml @@ -0,0 +1,17 @@ + + + + + + + + lib/Console + + + + + tests/Unit/Core/Cli/CactiApplicationTest.php + tests/Unit/Core/Cli/CactiEntrypointSecurityTest.php + + + diff --git a/tests/.htaccess b/tests/.htaccess new file mode 100644 index 000000000..18532a25d --- /dev/null +++ b/tests/.htaccess @@ -0,0 +1,10 @@ +# Apache 2.4 + + Require all denied + + +# Apache 2.2 + + Order Allow,Deny + Deny from all + diff --git a/tests/Unit/Core/Cli/CactiApplicationTest.php b/tests/Unit/Core/Cli/CactiApplicationTest.php new file mode 100644 index 000000000..ee0f60cc0 --- /dev/null +++ b/tests/Unit/Core/Cli/CactiApplicationTest.php @@ -0,0 +1,438 @@ +tty_enabled = $tty; + + return $this; + } + + public function disableOutput(): static { + $this->output_disabled = true; + + return $this; + } + + public function run(callable|null $callback = null, array $env = []): int { + if ($this->term_signal !== null) { + throw new ProcessSignaledException($this); + } + + return $this->result; + } + + public function getTermSignal(): int { + return $this->term_signal ?? 0; + } + + public function isRunning(): bool { + return $this->running; + } + + public function signal(int $signal): static { + if (!$this->running) { + throw new ProcessLogicException('Cannot send signal on a non running process.'); + } + $this->received_signal = $signal; + + return $this; + } +} + +it('registers every executable legacy CLI script exactly once', function (): void { + $root = dirname(__DIR__, 4); + $non_commands = ['cacti', 'index']; + $scripts = array_map( + static fn (string $path): string => basename($path, '.php'), + glob($root . '/cli/*.php') ?: [] + ); + + $mapped = array_keys(LegacyCommandMap::commands()); + $missing = array_values(array_diff($mapped, $scripts)); + $unexpected = array_values(array_diff($scripts, $mapped, $non_commands)); + + expect($missing)->toBe([], 'Every mapped command must name an existing cli/*.php script.') + ->and($unexpected)->toBe([], 'Classify new non-command files in $non_commands, or add commands to LegacyCommandMap.') + ->and(array_unique(array_values(LegacyCommandMap::commands()))) + ->toHaveCount(count($mapped)); +}); + +it('lists commands without bootstrapping the database', function (): void { + $root = dirname(__DIR__, 4); + $application = new CactiApplication($root); + $application->setAutoExit(false); + $tester = new ApplicationTester($application); + + $status = $tester->run(['command' => 'list', '--format' => 'json']); + $commands = json_decode($tester->getDisplay(), true, flags: JSON_THROW_ON_ERROR)['commands']; + $names = array_column($commands, 'name'); + + expect($status)->toBe(0) + ->and($names)->toContain('device:add', 'database:audit', 'poller:reindex', 'rrd:resize'); +}); + +it('reports an unknown version when the version file is unavailable', function (): void { + $application = new CactiApplication('/path/that/does/not/exist'); + + expect($application->getVersion())->toBe('unknown'); +}); + +it('leaves unknown commands to Symfony after legacy lookup fails', function (): void { + $application = new CactiApplication(dirname(__DIR__, 4)); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + expect(fn (): int => $application->run(new RawArgvInput(['bin/cacti', 'not:a:command']), new BufferedOutput())) + ->toThrow(Symfony\Component\Console\Exception\CommandNotFoundException::class); +}); + +it('hands a terminal directly to a legacy process', function (): void { + $process = new CactiControllableLegacyProcess(17); + $command = new LegacyScriptCommand( + 'probe:run', + 'probe', + dirname(__DIR__, 3) . '/fixtures/Console', + static fn (array $command): Process => $process, + static fn (): bool => true, + false + ); + + expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(17) + ->and($process->tty_enabled)->toBeTrue() + ->and($process->output_disabled)->toBeFalse(); +}); + +it('maps a signaled legacy process to the shell exit status', function (): void { + $process = new CactiControllableLegacyProcess(term_signal: SIGKILL); + $command = new LegacyScriptCommand( + 'probe:run', + 'probe', + dirname(__DIR__, 3) . '/fixtures/Console', + static fn (array $command): Process => $process, + static fn (): bool => false, + false + ); + + expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(128 + SIGKILL); +}); + +it('relays registered termination signals only while the child is running', function (): void { + $process = new CactiControllableLegacyProcess(); + $handlers = []; + $command = new LegacyScriptCommand( + 'probe:run', + 'probe', + dirname(__DIR__, 3) . '/fixtures/Console', + static fn (array $command): Process => $process, + static fn (): bool => false, + static function (int $signal, callable $handler) use (&$handlers): void { + $handlers[$signal] = $handler; + } + ); + + $command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()); + $process->running = true; + $handlers[SIGTERM](SIGTERM); + + expect($process->received_signal)->toBe(SIGTERM) + ->and($handlers)->toHaveKeys([SIGTERM, SIGINT, SIGHUP]); + + $process->running = false; + $process->received_signal = null; + $handlers[SIGINT](SIGINT); + + expect($process->received_signal)->toBeNull(); +}); + +it('preserves the child status when a signal arrives before process startup', function (): void { + $process = new CactiControllableLegacyProcess(17); + $command = new LegacyScriptCommand( + 'probe:run', + 'probe', + dirname(__DIR__, 3) . '/fixtures/Console', + static fn (array $command): Process => $process, + static fn (): bool => false, + static function (int $signal, callable $handler): void { + if ($signal === SIGTERM) { + $handler($signal); + } + } + ); + + expect($command->run(new RawArgvInput(['bin/cacti', 'probe:run']), new BufferedOutput()))->toBe(17) + ->and($process->received_signal)->toBeNull(); +}); + +it('forwards raw legacy arguments and preserves the exit status', function (): void { + $root = dirname(__DIR__, 3) . '/fixtures/Console'; + $application = new Application('test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('probe:run', 'probe', $root)); + $input = new RawArgvInput(['bin/cacti', 'probe:run', '--arbitrary=value', '-x', 'plain']); + $output = new BufferedOutput(); + + $status = $application->run($input, $output); + + expect($status)->toBe(23) + ->and($output->fetch())->toBe('["--arbitrary=value","-x","plain"]probe-error'); +}); + +it('routes child stderr separately for console outputs and accepts aliases', function (): void { + $root = dirname(__DIR__, 3) . '/fixtures/Console'; + $application = new Application('test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('probe:run', 'probe', $root)); + $output = new ConsoleOutput(decorated: false); + $errorOutput = new BufferedOutput(); + $output->setErrorOutput($errorOutput); + + $status = $application->run( + new RawArgvInput(['bin/cacti', 'probe', 'alias-value']), + $output + ); + + expect($status)->toBe(23) + ->and($errorOutput->fetch())->toBe('probe-error'); +}); + +it('forwards arguments when the command name is abbreviated', function (): void { + $root = dirname(__DIR__, 3) . '/fixtures/Console'; + $application = new Application('test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('probe:run', 'probe', $root)); + $output = new BufferedOutput(); + + $status = $application->run( + new RawArgvInput(['bin/cacti', 'pro:r', '--arbitrary=value', 'plain']), + $output + ); + + expect($status)->toBe(23) + ->and($output->fetch())->toBe('["--arbitrary=value","plain"]probe-error'); +}); + +it('leaves --help, -h, --version and -V to the legacy script', function (string $flag): void { + $application = new CactiApplication(dirname(__DIR__, 4)); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console')); + $output = new BufferedOutput(); + + $status = $application->run(new RawArgvInput(['bin/cacti', 'probe:run', $flag]), $output); + + expect($status)->toBe(23) + ->and($output->fetch())->toBe(sprintf('["%s"]probe-error', $flag)); +})->with(['--help', '-h', '--version', '-V']); + +it('forwards a flag written before the command name', function (string $flag): void { + $application = new CactiApplication(dirname(__DIR__, 4)); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console')); + $output = new BufferedOutput(); + + // getFirstArgument() skips leading options, so the command dispatches with + // the flag ahead of its name. Slicing only forward handed the script an + // empty argv, and a script whose flags are all optional then ran its + // default path. + $status = $application->run(new RawArgvInput(['bin/cacti', $flag, 'probe:run']), $output); + + expect($status)->toBe(23) + ->and($output->fetch())->toBe(sprintf('["%s"]probe-error', $flag)); +})->with(['--help', '-h', '--version', '-V']); + +it('preserves the order of flags on both sides of the command name', function (): void { + $application = new CactiApplication(dirname(__DIR__, 4)); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console')); + $output = new BufferedOutput(); + + $status = $application->run( + new RawArgvInput(['bin/cacti', '-v', 'probe:run', '--arbitrary=value', 'plain']), + $output + ); + + expect($status)->toBe(23) + ->and($output->fetch())->toBe('["-v","--arbitrary=value","plain"]probe-error'); +}); + +it('still answers --version and --help for the application itself', function (): void { + $application = new CactiApplication(dirname(__DIR__, 4)); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $output = new BufferedOutput(); + + expect($application->run(new RawArgvInput(['bin/cacti', '--version']), $output))->toBe(0) + ->and($output->fetch())->toStartWith('Cacti'); +}); + +it('does not swallow legacy output when -q is passed through', function (): void { + $application = new CactiApplication(dirname(__DIR__, 4)); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('probe:run', 'probe', dirname(__DIR__, 3) . '/fixtures/Console')); + $output = new BufferedOutput(); + + $status = $application->run(new RawArgvInput(['bin/cacti', 'probe:run', '-q', 'plain']), $output); + + expect($status)->toBe(23) + ->and($output->fetch())->toBe('["-q","plain"]probe-error'); +}); + +it('streams large legacy output without retaining it in the parent', function (): void { + $root = dirname(__DIR__, 3) . '/fixtures/Console'; + $application = new Application('test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('bulk:run', 'bulk', $root)); + $output = new BufferedOutput(); + + $status = $application->run(new RawArgvInput(['bin/cacti', 'bulk:run', '20000']), $output); + + // 20000 lines is past the 1MB php://temp threshold Process spools at. + expect($status)->toBe(0) + ->and(strlen($output->fetch()))->toBe(20000 * 100); +}); + +it('reports a killed legacy script as the shell does', function (): void { + if (!function_exists('pcntl_fork') || !function_exists('posix_kill') || !defined('SIGKILL')) { + $this->markTestSkipped('pcntl and posix signal support are required.'); + } + + $root = dirname(__DIR__, 3) . '/fixtures/Console'; + $runner = dirname(__DIR__, 3) . '/fixtures/Console/kill_runner.php'; + + $descriptor = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; + $process = proc_open([PHP_BINARY, $runner, $root], $descriptor, $pipes); + + expect($process)->toBeResource(); + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + proc_close($process); + + // 128 + SIGKILL, the status a shell reports for the same death. + expect(trim($stdout))->toBe('137') + ->and($stderr)->not->toContain('ProcessSignaledException'); +}); + +it('runs the legacy script in the directory the caller was in', function (): void { + $root = dirname(__DIR__, 3) . '/fixtures/Console'; + $original = getcwd(); + $elsewhere = sys_get_temp_dir(); + + try { + chdir($elsewhere); + + $application = new Application('test'); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new LegacyScriptCommand('pwd:run', 'pwd', $root)); + $output = new BufferedOutput(); + + $application->run(new RawArgvInput(['bin/cacti', 'pwd:run']), $output); + + // A relative path argument has to mean what it would have meant to + // `php cli/