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/