diff --git a/.crontab b/.crontab
index 80d263ed..4a2155fc 100644
--- a/.crontab
+++ b/.crontab
@@ -38,7 +38,3 @@ INDEX_PATH='/home/bmxfeed/aggro/current/public/index.php'
## aggro youtube
## Look for new YouTube videos every 5 minutes
*/5 * * * * $PHP_PATH $INDEX_PATH aggro youtube
-
-## aggro watch
-## Show next video in the watch queue every week (Monday at 00:00)
-0 0 * * 1 $PHP_PATH $INDEX_PATH aggro watch
diff --git a/.docksal/commands/admin b/.docksal/commands/admin
deleted file mode 100755
index f924178b..00000000
--- a/.docksal/commands/admin
+++ /dev/null
@@ -1,36 +0,0 @@
-#!/usr/bin/env bash
-
-## Run application tasks
-## Usage: fin admin
-
-cd "$PROJECT_ROOT" || exit
-
-echo -e "\\n\\033[1;33mClearing aggro logs...\\033[0m"
-fin fire aggro log-clean
-
-echo -e "\\n\\033[1;33mClearing error logs...\\033[0m"
-fin fire aggro log-error-clean
-
-echo -e "\\n\\033[1;33mClearing news cache...\\033[0m"
-fin fire aggro news-cache
-
-echo -e "\\n\\033[1;33mClearing news stories...\\033[0m"
-fin fire aggro news-clean
-
-echo -e "\\n\\033[1;33mUpdating news...\\033[0m"
-fin fire aggro news
-
-echo -e "\\n\\033[1;33mUpdating Vimeo...\\033[0m"
-fin fire aggro vimeo
-
-echo -e "\\n\\033[1;33mUpdating YouTube...\\033[0m"
-fin fire aggro youtube
-
-echo -e "\\n\\033[1;33mCleaning up...\\033[0m"
-fin fire aggro sweep
-
-echo -e "\\n\\033[1;33mShow aggro log...\\033[0m"
-fin fire aggro log
-
-echo -e "\\n\\033[1;33mShow error log...\\033[0m"
-fin fire aggro log-error
diff --git a/.docksal/commands/clicheck b/.docksal/commands/clicheck
new file mode 100755
index 00000000..50a910a4
--- /dev/null
+++ b/.docksal/commands/clicheck
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+
+## Validate CLI routes are accessible (and run them in the process)
+## Usage: fin clicheck
+
+#: exec_target = cli
+
+# Abort if anything fails
+set -e
+
+echo -e "\n\033[1;33mValidating CLI routes...\033[0m"
+
+# CLI routes from crontab that should be accessible
+ROUTES=(
+ "aggro log"
+ "aggro log-error"
+ "aggro news"
+ "aggro news-clean"
+ "aggro news-cache"
+ "aggro sweep"
+ "aggro duration"
+ "aggro vimeo"
+ "aggro youtube"
+ "aggro log-clean"
+ "aggro log-error-clean"
+)
+
+FAILED=0
+
+for route in "${ROUTES[@]}"; do
+ echo -n " Testing $route... "
+
+ # Run the CLI route and capture output
+ # shellcheck disable=SC2086 # Intentional word splitting for route args
+ OUTPUT=$(/usr/local/bin/php /var/www/public/index.php $route 2>&1) || true
+
+ # Check for 404 response (CI4 error page contains
404
)
+ if echo "$OUTPUT" | grep -q "404
"; then
+ echo -e "\033[1;31mFAILED (404)\033[0m"
+ echo -e "\033[0;33mOutput:\033[0m"
+ echo "$OUTPUT"
+ FAILED=1
+ elif echo "$OUTPUT" | grep -q "Unable to locate"; then
+ echo -e "\033[1;31mFAILED (Route not found)\033[0m"
+ echo -e "\033[0;33mOutput:\033[0m"
+ echo "$OUTPUT"
+ FAILED=1
+ else
+ echo -e "\033[1;32mOK\033[0m"
+ fi
+done
+
+if [ $FAILED -eq 1 ]; then
+ echo -e "\n\033[1;31mCLI route validation failed!\033[0m"
+ echo "Ensure all routes have corresponding \$routes->cli() definitions in app/Config/Routes.php"
+ exit 1
+fi
+
+echo -e "\n\033[1;32mAll CLI routes accessible.\033[0m"
diff --git a/.docksal/commands/maintain b/.docksal/commands/maintain
index 4cdcd865..25e282f5 100755
--- a/.docksal/commands/maintain
+++ b/.docksal/commands/maintain
@@ -6,8 +6,4 @@
cd "$PROJECT_ROOT" || exit
fin upgrade
-fin init
-fin check
-fin sniff
-fin shellcheck
-fin admin
+fin test
diff --git a/.docksal/commands/sniff b/.docksal/commands/sniff
deleted file mode 100755
index c2eca876..00000000
--- a/.docksal/commands/sniff
+++ /dev/null
@@ -1,8 +0,0 @@
-#!/usr/bin/env bash
-
-## Run static PHP analysis
-## Usage: fin sniff
-
-
-cd "$PROJECT_ROOT" || exit
-fin composer lint
diff --git a/.docksal/commands/test b/.docksal/commands/test
index aaa546db..e9b096da 100755
--- a/.docksal/commands/test
+++ b/.docksal/commands/test
@@ -6,7 +6,7 @@
cd "$PROJECT_ROOT" || exit
fin init
-fin sniff
-fin shellcheck
+fin composer lint
fin composer test
-fin admin
+fin shellcheck
+fin clicheck
diff --git a/README.md b/README.md
index ebcd09ff..462e3301 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@ Performance note — Xdebug may slow down the application. If you experience per
Aggro includes several custom Docksal commands to help with development:
-- `fin admin` — Run application maintenance tasks
+- `fin clicheck` — Run application maintenance tasks
- `fin deploy [env]` — Deploy to specified environment
- `fin frontend` — Run front-end build process
- `fin maintain` — Run upgrades and tests
@@ -197,11 +197,9 @@ Note — Coverage reporting requires Xdebug to be enabled. Use `XDEBUG_MODE=cove
### Code Quality Checks
```bash
-# Run all linting and static analysis
-fin lint
-
# Run specific checks
-fin sniff # PHP CodeSniffer
+fin composer lint # PHP linting, static analysis
+fin composer test # PHP unit tests
fin shellcheck # Shell script linting
# Individual quality tools
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 268f67f8..2e425e24 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -33,11 +33,19 @@
$routes->post('aggro/news-cache', 'Aggro::getNewsCache');
$routes->post('aggro/news-clean', 'Aggro::getNewsClean');
$routes->post('aggro/sweep', 'Aggro::getSweep');
+$routes->cli('aggro/log', 'Aggro::getLog');
+$routes->cli('aggro/log-error', 'Aggro::getLogError');
$routes->cli('aggro/log-clean', 'Aggro::getLogClean');
$routes->cli('aggro/log-error-clean', 'Aggro::getLogErrorClean');
$routes->cli('aggro/news-cache', 'Aggro::getNewsCache');
$routes->cli('aggro/news-clean', 'Aggro::getNewsClean');
$routes->cli('aggro/sweep', 'Aggro::getSweep');
+$routes->cli('aggro/news', 'Aggro::getNews');
+$routes->cli('aggro/vimeo', 'Aggro::getVimeo');
+$routes->cli('aggro/vimeo/(:segment)', 'Aggro::getVimeo/$1');
+$routes->cli('aggro/youtube', 'Aggro::getYoutube');
+$routes->cli('aggro/youtube/(:segment)', 'Aggro::getYoutube/$1');
+$routes->cli('aggro/duration', 'Aggro::getYouTubeDuration');
// Set 404 override
$routes->set404Override('App\Controllers\Front::getError404');
diff --git a/composer.json b/composer.json
index fa2c86c6..16693859 100644
--- a/composer.json
+++ b/composer.json
@@ -43,10 +43,10 @@
}
},
"scripts": {
- "test": "phpunit --no-coverage",
- "test:unit": "phpunit tests/unit",
- "test:feature": "phpunit tests/feature",
- "test:coverage": "phpunit --coverage-html build/logs/html",
+ "test": "phpunit --testdox",
+ "test:unit": "phpunit tests/unit --testdox",
+ "test:feature": "phpunit tests/feature --testdox",
+ "test:coverage": "XDEBUG_MODE=coverage phpunit --coverage-html build/logs/html --coverage-clover build/logs/clover.xml --testdox",
"phpcs": "phpcs --standard=phpcs.xml -v --colors",
"phpcbf": "phpcbf --standard=phpcs.xml -v --colors --runtime-set installed_paths $($COMPOSER_BINARY config vendor-dir)/drupal/coder/coder_sniffer,$($COMPOSER_BINARY config vendor-dir)/sirbrillig/phpcs-variable-analysis,$($COMPOSER_BINARY config vendor-dir)/slevomat/coding-standard",
"phpmd": [
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 9948d6cd..519093e0 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -18,14 +18,6 @@
./app/Config/Routes.php
-
-
-
-
-
-
-
-
./tests
diff --git a/tests/unit/CliRouteRegressionTest.php b/tests/unit/CliRouteRegressionTest.php
new file mode 100644
index 00000000..417c4938
--- /dev/null
+++ b/tests/unit/CliRouteRegressionTest.php
@@ -0,0 +1,161 @@
+add() to
+ * $routes->get()), CLI routes need explicit $routes->cli() definitions.
+ * This test ensures all cron jobs have corresponding CLI route definitions.
+ *
+ * @internal
+ */
+final class CliRouteRegressionTest extends CIUnitTestCase
+{
+ private string $crontabPath;
+ private string $routesPath;
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+ $this->crontabPath = ROOTPATH . '.crontab';
+ $this->routesPath = APPPATH . 'Config/Routes.php';
+ }
+
+ /**
+ * Extract route paths from crontab entries.
+ *
+ * @return list Array of route paths (e.g., ['aggro/news', 'aggro/sweep'])
+ */
+ private function getCrontabRoutes(): array
+ {
+ $this->assertFileExists($this->crontabPath, 'Crontab file not found');
+
+ $content = file_get_contents($this->crontabPath);
+ $lines = explode("\n", $content);
+ $routes = [];
+
+ foreach ($lines as $line) {
+ $line = trim($line);
+
+ // Skip empty lines and comments
+ if ($line === '' || str_starts_with($line, '#')) {
+ continue;
+ }
+
+ // Skip variable definitions and DreamHost block
+ if (str_contains($line, '=') && ! str_contains($line, '$')) {
+ continue;
+ }
+
+ // Match cron entries that invoke index.php with a route
+ // Pattern: $PHP_PATH $INDEX_PATH controller method [optional args]
+ // Routes in crontab are space-separated: "aggro news" becomes "aggro/news"
+ if (preg_match('/\$INDEX_PATH\s+(\S+)\s+(\S+)/', $line, $matches)) {
+ $routes[] = $matches[1] . '/' . $matches[2];
+ }
+ }
+
+ return array_unique($routes);
+ }
+
+ /**
+ * Extract CLI route definitions from Routes.php.
+ *
+ * @return list Array of CLI route paths
+ */
+ private function getCliRouteDefinitions(): array
+ {
+ $this->assertFileExists($this->routesPath, 'Routes.php file not found');
+
+ $content = file_get_contents($this->routesPath);
+ $routes = [];
+
+ // Match $routes->cli('path', ...) patterns
+ if (preg_match_all('/\$routes->cli\([\'"]([^\'"]+)[\'"]/', $content, $matches)) {
+ $routes = $matches[1];
+ }
+
+ return $routes;
+ }
+
+ /**
+ * Test that all crontab routes have corresponding CLI route definitions.
+ */
+ public function testAllCrontabRoutesHaveCliDefinitions(): void
+ {
+ $crontabRoutes = $this->getCrontabRoutes();
+ $cliRoutes = $this->getCliRouteDefinitions();
+
+ $this->assertNotEmpty($crontabRoutes, 'No routes found in crontab');
+ $this->assertNotEmpty($cliRoutes, 'No CLI routes found in Routes.php');
+
+ $missingRoutes = [];
+
+ foreach ($crontabRoutes as $crontabRoute) {
+ // Check if this route or a parameterized version exists
+ $routeFound = false;
+
+ foreach ($cliRoutes as $cliRoute) {
+ // Direct match
+ if ($cliRoute === $crontabRoute) {
+ $routeFound = true;
+
+ break;
+ }
+
+ // Check for parameterized route (e.g., aggro/vimeo matches aggro/vimeo/(:segment))
+ $baseRoute = preg_replace('/\/\([^)]+\)$/', '', $cliRoute);
+ if ($baseRoute === $crontabRoute) {
+ $routeFound = true;
+
+ break;
+ }
+ }
+
+ if (! $routeFound) {
+ $missingRoutes[] = $crontabRoute;
+ }
+ }
+
+ $this->assertEmpty(
+ $missingRoutes,
+ sprintf(
+ "Missing CLI route definitions in Routes.php for crontab entries:\n- %s\n\n"
+ . 'Add $routes->cli() definitions for these routes to prevent CLI access failures.',
+ implode("\n- ", $missingRoutes),
+ ),
+ );
+ }
+
+ /**
+ * Test that Routes.php contains CLI route definitions.
+ */
+ public function testRoutesFileHasCliDefinitions(): void
+ {
+ $this->assertFileExists($this->routesPath);
+
+ $content = file_get_contents($this->routesPath);
+
+ $this->assertStringContainsString(
+ '$routes->cli(',
+ $content,
+ 'Routes.php should contain CLI route definitions',
+ );
+ }
+
+ /**
+ * Test crontab file is readable and has expected structure.
+ */
+ public function testCrontabFileStructure(): void
+ {
+ $this->assertFileExists($this->crontabPath);
+
+ $content = file_get_contents($this->crontabPath);
+
+ // Check for required variable definitions
+ $this->assertStringContainsString('PHP_PATH=', $content, 'Crontab should define PHP_PATH');
+ $this->assertStringContainsString('INDEX_PATH=', $content, 'Crontab should define INDEX_PATH');
+ }
+}
diff --git a/tests/unit/PhpVersionConsistencyTest.php b/tests/unit/PhpVersionConsistencyTest.php
new file mode 100644
index 00000000..01155565
--- /dev/null
+++ b/tests/unit/PhpVersionConsistencyTest.php
@@ -0,0 +1,183 @@
+=8.4, 8.4.*
+ if (preg_match('/(\d+\.\d+)/', $constraint, $matches)) {
+ return $matches[1];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Test that PHP version is consistent across all configuration files.
+ */
+ public function testPhpVersionConsistency(): void
+ {
+ $versions = [
+ '.crontab' => $this->getCrontabPhpVersion(),
+ '.github/workflows/deploy.yml' => $this->getDeployPhpVersion(),
+ '.docksal/docksal.env' => $this->getDocksalPhpVersion(),
+ 'composer.json' => $this->getComposerPhpVersion(),
+ ];
+
+ // Filter out null values (missing files)
+ $foundVersions = array_filter($versions, static fn ($v) => $v !== null);
+
+ $this->assertNotEmpty($foundVersions, 'No PHP version found in any configuration file');
+
+ $uniqueVersions = array_unique($foundVersions);
+
+ if (count($uniqueVersions) > 1) {
+ $message = "PHP version mismatch detected:\n";
+
+ foreach ($foundVersions as $file => $version) {
+ $message .= sprintf(" - %s: %s\n", $file, $version);
+ }
+ $message .= "\nAll configuration files should specify the same PHP version.";
+
+ $this->fail($message);
+ }
+
+ $this->assertCount(1, $uniqueVersions, 'PHP versions should be consistent');
+ }
+
+ /**
+ * Test that required configuration files exist.
+ */
+ public function testRequiredConfigFilesExist(): void
+ {
+ $requiredFiles = [
+ '.crontab' => ROOTPATH . '.crontab',
+ '.github/workflows/deploy.yml' => ROOTPATH . '.github/workflows/deploy.yml',
+ '.docksal/docksal.env' => ROOTPATH . '.docksal/docksal.env',
+ 'composer.json' => ROOTPATH . 'composer.json',
+ ];
+
+ foreach ($requiredFiles as $name => $path) {
+ $this->assertFileExists($path, sprintf('%s should exist', $name));
+ }
+ }
+
+ /**
+ * Test that PHP version can be extracted from each file.
+ */
+ public function testPhpVersionExtractable(): void
+ {
+ $this->assertNotNull(
+ $this->getCrontabPhpVersion(),
+ 'Could not extract PHP version from .crontab',
+ );
+
+ $this->assertNotNull(
+ $this->getDeployPhpVersion(),
+ 'Could not extract PHP version from deploy.yml',
+ );
+
+ $this->assertNotNull(
+ $this->getDocksalPhpVersion(),
+ 'Could not extract PHP version from docksal.env',
+ );
+
+ $this->assertNotNull(
+ $this->getComposerPhpVersion(),
+ 'Could not extract PHP version from composer.json',
+ );
+ }
+}