# Cron jobs and CLI commands

> Run scheduled work from a FeatherPanel plugin with Cron classes and the Cron helper, and add your own commands to the panel CLI with CommandBuilder.

Source: https://featherpanel.com/docs/plugins/cron-and-commands/

## Cron jobs

### How the runner calls your class

The PHP cron runner, `backend/storage/cron/runner.php`, starts once a minute (see
[Cron and workers](https://featherpanel.com/docs/panel/cron-and-workers/) for how it is scheduled). On each run it:

1. runs the core jobs in `storage/cron/php/`,
2. for every folder in `storage/addons/`, loads each `Cron/*.php` file and instantiates the class
   `App\Addons\<folder>\Cron\<file name>`,
3. calls `run()` on it.

Things to know:

- The runner does not check whether the plugin is valid or loaded. Any folder with a `Cron/` directory is run.
- If the class name does not match, the output shows `Class App\Addons\... not found` and the file is skipped.
- The runner catches `Exception` only. A PHP `Error`, such as a `TypeError` or a call to an undefined method, stops
  the whole run, so jobs after yours do not run that minute.
- Output goes to the cron log, `/var/log/cron.log` inside the Docker backend container.
- `php cli cron` runs the core jobs only. To test a plugin job, run the runner itself from the backend folder:
  `php storage/cron/runner.php`.

### A cron class

Implement `App\Cron\TimeTask` and use `App\Cron\Cron` to decide whether the work is due:

```php
<?php

namespace App\Addons\myplugin\Cron;

use App\App;
use App\Chat\TimedTask;
use App\Cron\Cron;
use App\Cron\TimeTask;

class Cleanup implements TimeTask
{
    public function run()
    {
        $cron = new Cron('myplugin-cleanup', '1H');

        try {
            $cron->runIfDue(function (): void {
                // Delete old rows, call an API, send a report...
                TimedTask::markRun('myplugin-cleanup', true, 'cleanup finished');
            });
        } catch (\Throwable $e) {
            App::getInstance(false, true)->getLogger()->error('myplugin cleanup failed: ' . $e->getMessage());
            TimedTask::markRun('myplugin-cleanup', false, $e->getMessage());
        }
    }
}
```

The file is `Cron/Cleanup.php`. The namespace must be `App\Addons\<identifier>\Cron` and the class name must equal
the file name.

### The Cron helper

`new Cron(string $identifier, string $interval)`:

| Part | Notes |
| --- | --- |
| `$identifier` | Names the state file, `storage/caches/cron/<identifier>.fptj`, which holds the last run time. Prefix it with your plugin identifier so it cannot clash with core jobs or other plugins. |
| `$interval` | A whole number followed by an upper-case unit: `S` seconds, `M` minutes, `H` hours, `D` days, `W` weeks. Examples: `5M`, `1H`, `1D`. Anything else, such as `1m` or `1.5H`, throws an exception when the `Cron` object is created. See the note below for `S`, `MO` and `Y`. |

| Method | Does |
| --- | --- |
| `runIfDue(callable $callback, bool $force = false): bool` | Runs the callback when the interval has passed since the last successful run, or when `$force` is true. If the callback throws an `Exception`, the run is logged as `Cron job <id> failed` and not marked, so it is tried again next minute. |
| `shouldRun(): bool` | Whether the interval has passed. |
| `markAsRun(): void` | Stores the current time as the last run. |
| `getLastRunTime(): ?DateTime`, `getNextRunTime(): DateTime` | Read the schedule. |

Delete the `.fptj` file to make the job run on the next minute.

> [!NOTE]
> On v1.5.2 and earlier, `S` is converted to whole minutes and rounded down, so `30S` becomes 0 (the job runs on every
> runner pass) and `90S` becomes 1 minute. `MO` and `Y` are in the unit table but the parser rejects them. Exact
> seconds and the units `MO` (30 days) and `Y` (365 days) are fixed in v1.5.3 ([PR #365](https://github.com/MythicalLTD/FeatherPanel/pull/365)). The runner starts once a minute either
> way, so an interval under `60S` means every runner pass.

`App\Chat\TimedTask::markRun(string $name, bool $success, ?string $message)` records the result in the panel's timed
task table, the same way the core jobs do. It is optional.

> [!WARNING]
> On v1.5.2 and earlier, the cron example in the plugin manager's Starter template declares `namespace App\Cron;`.
> The runner looks for `App\Addons\<identifier>\Cron\<file name>`, so change the namespace before you rely on it.
> The template is fixed in v1.5.3 ([PR #365](https://github.com/MythicalLTD/FeatherPanel/pull/365)), and plugins created from the old template keep the wrong namespace until you edit
> `Cron/<ClassName>CronExample.php`. The **Create file**
> tool in **Admin > Dev > Plugins** generates the correct namespace, but the hint on its **Schedule** field reads
> `1m=minute`. Type upper-case units (`1M`); the value is copied into the class as is, and `1m` throws when the job runs.

## CLI commands

### How commands are found

The panel CLI is the file `backend/cli`, run as `php cli <command>` from the backend folder. The installer also adds a
`featherpanel` command that runs `php cli` inside the Docker backend container. See [Panel CLI](https://featherpanel.com/docs/cli/panel/).

When you run a command, the CLI first looks for a plugin command:

1. On v1.5.2 and earlier it scans `<current directory>/backend/storage/addons/*/Commands/*.php`. From v1.5.3
   ([PR #365](https://github.com/MythicalLTD/FeatherPanel/pull/365)) it scans the panel's addons directory (`APP_ADDONS_DIR`, `backend/storage/addons`) wherever you run it
   from, and still checks the old location as a fallback.
2. For each file it loads the class `App\Addons\<folder>\Commands\<file name>`.
3. The first class whose name matches the command, ignoring case, runs. Plugin commands are checked before built-in
   ones, so a plugin command named `migrate` would replace the core command. Pick a name that cannot clash.

`help` lists plugin commands under **Plugins:** using the same lookup.

The CLI only runs from `/var/www/featherpanel`, `/var/www/featherpanel/backend` or `/var/www/html` unless you pass
`--skip-path-check`. On v1.5.2 and earlier, because of the `<current directory>/backend/storage/addons` lookup,
plugin commands are only found when the current directory is the repository root:

```bash
cd /var/www/featherpanel
php backend/cli myplugincleanup
```

In the Docker image the working directory is `/var/www/html`, which is the backend folder itself, so on v1.5.2 and
earlier `php cli` and the `featherpanel` wrapper do not find plugin commands there. Use a cron job or an admin route
for work that has to run in every setup. Discovery from any directory, including Docker, is fixed in v1.5.3 ([PR #365](https://github.com/MythicalLTD/FeatherPanel/pull/365)).

### A command class

```php
<?php

namespace App\Addons\myplugin\Commands;

use App\Cli\App;
use App\Cli\CommandBuilder;

class MyPluginCleanup implements CommandBuilder
{
    public static function execute(array $args): void
    {
        $cli = App::getInstance();
        $cli->send('&aCleanup started');

        // $args[0] is the command name itself; your arguments start at $args[1].
        $days = (int) ($args[1] ?? 30);
        $cli->send('&7Removing entries older than &f' . $days . ' &7days');
    }

    public static function getDescription(): string
    {
        return 'Remove old MyPlugin entries';
    }

    public static function getSubCommands(): array
    {
        return [];
    }
}
```

The file is `Commands/MyPluginCleanup.php` and the command is `myplugincleanup`. The **Create file** tool names the
class `<Name>Command`, which makes the command `<name>command`.

Notes:

- `send()` understands `&` colour codes such as `&a` (green), `&c` (red), `&7` (grey) and `&f` (white). The global
  flags `--no-colors`, `--clean-output` and `--no-prefix` are removed from `$args` before your command runs.
- The CLI does not boot the HTTP app or connect to the database for you. The built-in `settings` command loads the
  environment with `\App\App::getInstance(true)->loadEnv()` and opens a connection with `new \App\Chat\Database(...)`
  from the `DATABASE_*` values; do the same if your command needs the database.

## Related

- [Cron and workers](https://featherpanel.com/docs/panel/cron-and-workers/)
- [Panel CLI](https://featherpanel.com/docs/cli/panel/)
- [Migrations and storage](https://featherpanel.com/docs/plugins/database/)
- [Debugging plugins](https://featherpanel.com/docs/plugins/debugging/)

## Frequently asked questions

### How often does my cron class run?

The cron runner starts once a minute and calls run() on every plugin cron class each time. Use the App\Cron\Cron helper inside run() to do the real work only every N minutes, hours, days or weeks.

### Why does php cli help not list my plugin command?

The CLI looks for plugin commands in backend/storage/addons below the current directory, so it only finds them when you run it from the repository root, /var/www/featherpanel, as php backend/cli. In the Docker image the working directory is the backend folder itself, where that path does not exist.
