Sign in

    pm3

    A language-agnostic process manager (PM2-like) with built-in log rotation

    process-manager
    pm2
    supervisor
    daemon
    logrotate
    log-rotation
    cli
    Download zip
    Author
    Version
    0.1.2
    License
    Apache-2.0
    Last updated
    11 days ago
    Downloads
    45

    #pm3

    pm3 is a small, language-agnostic process manager with built-in log rotation, written in MoonBit. It is inspired by PM2 and by the pm2-logrotate module, but it manages any background service — a shell script, a Python worker, a Go daemon, a Node app — not just JavaScript runtimes.

    pm3 start ./worker.sh --name worker pm3 start ./worker.py --interpreter python3 --name py-worker --cwd /srv/app pm3 list pm3 logs worker --follow pm3 stop worker

    #Highlights

    • Any executable. pm3 supervises an arbitrary command line; the optional --interpreter flag covers scripts that are not directly executable.
    • Crash recovery. Automatic restarts with min_uptime / unstable-restart tracking, restart budgets, restart delays, stop_exit_codes and max_memory_restart.
    • Built-in, optimized log rotation. No external module, no logrotate cron job. Rotation is an O(1) rename + reopen performed by the daemon that owns the file descriptors — see Log rotation.
    • Background gzip. Rotated files are compressed asynchronously, so a multi-gigabyte log never blocks a worker.
    • A real daemon. The CLI talks to a detached supervisor over a loopback socket; the daemon owns child stdout/stderr pipes and keeps running after the terminal closes.
    • Single static binary. One executable provides both the client and the daemon (pm3 daemon is the internal entry point).

    #Install

    # npm, pnpm, yarn or bun. The launcher downloads the native binary for this # platform on first run; nothing is downloaded at install time. npm install -g @tomasko/pm3 # Install script, no Node.js required. curl -LsSf https://raw.githubusercontent.com/tomasky/pm3/master/install.sh | bash # Or build from source, see below.

    The Linux builds are linked statically against musl, so one binary runs on any distribution regardless of its glibc version.

    PlatformRelease asset
    Linux x86-64pm3-linux-x86_64 (static)
    Linux arm64pm3-linux-aarch64 (static)
    macOS Apple siliconpm3-darwin-aarch64

    The npm package ships a launcher rather than the binary, and deliberately declares no postinstall. Since npm 12 dependency install scripts are blocked unless the user opts in, and pnpm 12 fails a project install outright with ERR_PNPM_IGNORED_BUILDS when a dependency declares one:

    $ pnpm add @tomasko/pm3 Error: ERR_PNPM_IGNORED_BUILDS × adding a new package ╰─▶ Ignored build scripts: @tomasko/pm3@0.1.1

    Downloading lazily from the launcher works under npm, pnpm, yarn, bun, npx and --ignore-scripts, because it is an ordinary command rather than an install hook. See npmjs/README.md for the environment variables that redirect the download (PM3_DOWNLOAD_BASE, PM3_BINARY, …).

    The install script takes --version, --install-dir and --no-verify, and verifies the published SHA-256 before installing.

    #Build

    moon build --target native --release # the binary is at _build/native/release/build/cmd/main/main.exe

    Copy it somewhere on $PATH as pm3. During development the debug build works too:

    moon build --target native ./_build/native/debug/build/cmd/main/main.exe list

    #Static Linux build

    scripts/build-linux-static.sh produces a fully static binary with musl, which is what the release workflow publishes. A glibc build records the build machine's symbol versions, so a binary built on a new runner refuses to start on an older distribution (GLIBC_2.38 not found); linking against musl statically removes that entire class of problem.

    sudo apt-get install musl-tools ./scripts/build-linux-static.sh dist/pm3-linux-x86_64

    Run the unit tests with:

    moon test --target native

    #Releases

    .github/workflows/release.yml builds one binary per target on its own runner (MoonBit's native backend compiles through the system C compiler, so it is not cross-compiled), smoke-tests it, and attaches the binary plus a .sha256 to a GitHub Release — two assets per target, no archive, because both installers fetch the bare binary and set the mode themselves. It then publishes @tomasko/pm3 to npm, after the release exists, so the asset is there for the first user to pull. Pushing a tag is the whole trigger:

    git tag v0.1.1 && git push origin v0.1.1

    The npm job is skipped when NPM_TOKEN is not configured, so a fork can still cut releases.

    #Quick start

    # Start a service. The name defaults to the script's basename ("worker"). pm3 start ./worker.sh # Run a non-executable script through an interpreter. pm3 start ./jobs/digest.py --interpreter python3 --name digest # Pass arguments to the managed process after `--`. pm3 start ./server --name api -- --port 8080 # Environment variables. pm3 start ./server --name api -e PORT=8080 -e LOG_LEVEL=info # Inspect it. Tables are drawn the way PM2 draws them; the footer totals the # running processes. pm3 list # ┌────┬────────┬────────┬───────┬───────┬──────────┬────────┬────────────────────────────────────┐ # │ id │ name │ status │ cpu │ mem │ restarts │ uptime │ command │ # ├────┼────────┼────────┼───────┼───────┼──────────┼────────┼────────────────────────────────────┤ # │ 0 │ digest │ online │ 86.9% │ 11.3mb │ 0 │ 6s │ python3 ./digest.py │ # │ 1 │ worker │ online │ 0% │ 2.0mb │ 0 │ 6s │ sh -c while true; do sleep 1; done │ # └────┴────────┴────────┴───────┴────────┴──────────┴────────┴────────────────────────────────────┘ # # 2/2 online total cpu 86.9% total mem 13.3mb pm3 describe api # Describing process with id 0 - name digest # ┌────────────────────┬───────────────────────────────────┐ # │ pm_id │ 0 │ # │ name │ digest │ # │ status │ online │ # │ pid │ 21557 │ # │ command │ python3 ./digest.py │ # │ cwd │ <inherited> │ # │ out log │ /root/.pm3/logs/digest-out.log │ # │ memory │ 11.3mb │ # │ cpu │ 86.9% │ # └────────────────────┴───────────────────────────────────┘ pm3 logs digest --follow # Stop it, remove it. pm3 stop api pm3 delete api

    Or declare the services in a pm3.toml and bring them all up at once — see Application config.

    pm3 start # reads ./pm3.toml pm3 config apps # show what that would start

    #Configuration

    pm3 reads TOML and JSON only. Anything else — YAML, INI, a .js file — is refused with an error rather than ignored.

    There are two things to configure, and they live in the same file format:

    What it describesWhere pm3 looks
    ApplicationsWhich processes to run, and how$PM3_CONFIG, then ./pm3.toml, ./pm3.json, then the global file
    Log rotationThe rotation policy, shared by every processthe global file only

    The global file is $PM3_HOME/config.toml (or config.json) — by default ~/.pm3/config.toml. It is global because the daemon is: one supervisor owns every process on the machine, so one rotation policy applies to all of them. A legacy ~/.pm3/logrotate.json is still read.

    pm3 config prints the global file and its contents, pm3 config init toml (or json) creates one seeded with the current policy, and pm3 config apps prints the app list pm3 would start.

    #Application config

    This is the pm3 equivalent of PM2's ecosystem.config.js: a project checks in pm3.toml, and pm3 start brings the whole thing up.

    # pm3.toml [defaults] # optional, applied to every app below max_restarts = 16 max_memory_restart = "200M" [[app]] name = "worker" script = "./worker.py" interpreter = "python3" args = ["--verbose"] env = { PORT = "8080", LOG_LEVEL = "info" } [[app]] name = "api" script = "./bin/server" max_memory_restart = "1G" # overrides the default for this app only stop_exit_codes = [0, 2]

    The same document in JSON:

    { "defaults": { "max_restarts": 16 }, "app": [ { "name": "worker", "script": "./worker.py", "interpreter": "python3" } ] }

    Every start option has a config key of the same name:

    KeyType
    namestring (default: the script's basename without its extension)
    scriptstring, required
    argsarray of strings
    interpreterstring
    cwdstring (default: the config file's directory)
    envtable of scalars
    autorestartbool
    max_restarts, restart_delay, min_uptime, kill_timeoutinteger
    max_memory_restartsize ("200M", "1G", or bytes)
    kill_signalstring
    stop_exit_codesarray of integers
    merge_logsbool
    out_log, err_logstring
    log_date_formatstring

    An unknown key is an error, not a shrug — a misspelled max_memory_restart fails immediately instead of silently leaving the limit off.

    Paths are resolved against the config file, not the working directory. A checked-in pm3.toml therefore behaves the same whether pm3 is run from the repository root, a subdirectory, or a cron job. cwd defaults to the config file's directory for the same reason.

    A script with a slash in it, or one that names a file next to the config, is a path. A bare name that is not a file — sh, python3, go — is left alone and looked up on PATH, exactly as pm3 start sh would.

    pm3 start # ./pm3.toml, or the global file pm3 start --config prod.toml pm3 start pm3.json # a positional .toml/.json is a config file pm3 config apps # show what that would start, and validate the file

    Apps that are already online are left running, so re-running pm3 start after adding one app to the file is not disruptive; everything else is started, and a failure in one app does not abort the rest of the file.

    pm3 start <script> ... is unchanged: a single script is still started directly, and options after the script are passed to the program, so pm3 start sh -c 'echo hi' works. Use --config when the ambiguity matters.

    #Commands

    CommandDescription
    pm3 start <script> [options] [-- args...]Start and supervise a process
    pm3 start [--config <file>]Start every app a config file declares
    pm3 list (aliases ls, ps, status)Table of managed processes, with running/total counts and total CPU and memory
    pm3 describe <name>Full configuration and runtime state
    pm3 stop [name]Stop one process, or all when no name is given
    pm3 restart [name]Restart one process, or all (resets the restart counter)
    pm3 delete [name] (alias del)Stop and remove processes
    pm3 logs [name] [--lines N] [--follow] [--err]Show (and follow) logs
    pm3 flush [name]Truncate the live log(s)
    pm3 reloadLogsRotate every managed log now
    pm3 logrotateShow the rotation policy
    pm3 set logrotate:<key> <value>Change the rotation policy
    pm3 configShow the active config file and its contents
    pm3 config init [toml\|json] [--force]Create a config file
    pm3 config apps [--config <file>] [--json]Show what pm3 start would start, without starting it
    pm3 savePersist the process list to dump.json
    pm3 resurrectStart the processes that were desired online
    pm3 killStop the daemon (and its processes)
    pm3 versionPrint the version

    #start options

    OptionMeaning
    --config <file>Read the app list from a pm3.toml / pm3.json instead
    -n, --name <name>Process name (default: script basename)
    -i, --interpreter <cmd>Run <script> as an argument to <cmd>
    --cwd <dir>Working directory
    --merge-logsSend stderr into the stdout log
    --out-file <path>stdout log path
    --err-file <path>stderr log path
    --no-autorestartDo not restart the process when it exits
    --max-restarts <n>Restart budget (0 = unlimited)
    --restart-delay <ms>Delay before each restart
    --min-uptime <ms>Runs shorter than this count as "unstable"
    --max-memory-restart <size>Restart when RSS exceeds <size>
    --kill-timeout <ms>Grace period before SIGKILL
    --kill-signal <signal>Graceful signal (default SIGTERM)
    --stop-exit-codes <a,b>Exit codes that should not trigger a restart
    -e, --env KEY=VALUESet an environment variable (repeatable)

    #How it works

    pm3 <command> pm3 daemon ┌────────────────────┐ ┌──────────────────────────┐ │ client package │ loopback │ daemon package │ │ parse CLI │ ─────────► │ accept + dispatch │ │ connect to daemon │ newline- │ supervisor (per app) │ │ print results │ delimited │ log sinks + rotation │ └────────────────────┘ JSON │ gzip + prune workers │ │ resource monitor │ └────────────┬─────────────┘ │ pipes ┌─────────┴──────────┐ │ managed children │ └────────────────────┘

    • The CLI reads the daemon's ephemeral port from ~/.pm3/daemon.port. If the daemon is not running it starts one detached (spawn_orphan + setsid) and waits for the port file.
    • Each managed app gets one supervisor coroutine that runs the spawn → wait → maybe restart loop.
    • Child stdout/stderr are pipes owned by the daemon, pumped into LogSinks. This is what makes safe rotation possible.
    • State lives in ~/.pm3:

    ~/.pm3/ ├── dump.json # serialized process table ├── config.toml # configuration (or config.json) ├── daemon.port # ephemeral TCP port (while running) ├── daemon.pid # daemon pid (while running) ├── pm3.log # daemon's own stdout/stderr └── logs/ ├── <name>-out.log ├── <name>-err.log └── <name>-out__<stamp>.log[.gz]

    Set PM3_HOME to relocate everything (useful for tests).

    #Log rotation

    pm3 integrates what pm2-logrotate does as a separate module, and fixes the three problems in its rotation strategy.

    #Why the rewrite

    pm2-logrotate rotates a file by streaming it to a dated copy and then truncating the original. That design has three well-known defects:

    1. Data loss. Anything a worker writes between the copy finishing and the truncate completing is silently discarded.
    2. O(size) work. A multi-gigabyte log has to be copied in full before it can be truncated; the rotation worker stalls, and on a busy service the window above is large.
    3. Sparse holes. After truncate, the writer's file offset is still at the old position, so the next write lands far into the file and leaves a NUL-filled gap.

    It also matches rotated files with a substring test (file.indexOf(base) >= 0), which happily matches unrelated files, and it assumes every log ends in .log.

    pm3 avoids all of this because the daemon owns every log descriptor:

    • Rotation is rename(live, live__<stamp>.log) followed by opening a fresh live file. That is O(1), atomic on POSIX, loses nothing (the writer immediately switches to the new descriptor), and never produces holes.
    • Rotated files are matched by an exact prefix/suffix test (<base>__<stamp>.log[.gz]), so api-out__backup.log or a file that merely contains the base name is never mistaken for a rotated log.
    • Gzip runs on a background worker with a configurable level, instead of blocking the rotation path.
    • Retention is computed from modification times, and can additionally cap files by age.
    • An optional min_size prevents rotating near-empty logs.
    • If two rotations happen within the same timestamp tick, the second one gets a -1, -2, … suffix instead of overwriting its sibling.

    #Policy

    Show the current policy:

    pm3 logrotate

    Change a key:

    pm3 set logrotate:max_size 100M pm3 set logrotate:compress true pm3 set logrotate:retain 14 pm3 set logrotate:rotate_interval "0 3 * * *"

    KeyDefaultMeaning
    max_size10MRotate at this size (0 disables size rotation)
    min_size0Never rotate below this size
    retain30Number of rotated files to keep per log
    max_age_days0Also delete rotated files older than this (0 = off)
    compressfalseGzip rotated files in the background
    compression_level6gzip level, 1–9 (validated; encoder level is fixed)
    date_formatYYYY-MM-DD_HH-mm-ssTimestamp tokens used in rotated names
    worker_interval30Seconds between size/prune passes (min 1)
    rotate_interval0 0 * * *Cron expression that forces a rotation
    tzsystemTimezone for file names (UTC+8, +05:30, or an IANA name)
    rotate_moduletrueReserved; kept for pm2-logrotate compatibility

    The policy is shared by every sink, so changes take effect immediately without restarting the daemon.

    #Configuration file

    The policy lives in the global file, $PM3_HOME/config.toml or $PM3_HOME/config.json — by default ~/.pm3/config.toml. It is global because the daemon is: one supervisor owns every process on the machine, so one policy applies to all of them. A project-local pm3.toml is read for its applications, never for rotation, and a [logrotate] table in one is rejected rather than quietly ignored.

    Only TOML and JSON are accepted — a config file that is silently ignored is worse than a loud error. logrotate.json is still read so that an existing install keeps working.

    # ~/.pm3/config.toml [logrotate] max_size = "100M" retain = 14 compress = true compression_level = 6 rotate_interval = "0 3 * * *" tz = "UTC+8"

    { "logrotate": { "max_size": "100M", "retain": 14, "compress": true } }

    Both are parsed into the same tree, so they behave identically. pm3 config prints the file in use and its contents; pm3 config init toml (or json) creates one seeded with the current policy; pm3 set logrotate:... rewrites whichever file is active, keeping its syntax.

    Sizes may be written as 100M, 1G, 512K or a plain byte count, and a value written back is always exact: min_size = 1000 stays 1000 rather than rounding to 0.98K. Unknown keys are an error rather than being ignored, so a typo fails loudly instead of quietly doing nothing.

    rotate_interval accepts the usual cron syntax (5 or 6 fields, names, ranges, steps, lists) plus @hourly/@daily/@weekly/@monthly/@yearly. When both day-of-month and day-of-week are restricted, the schedule fires if either matches, as Vixie cron does.

    #Process lifecycle

    • Stop sends kill_signal (default SIGTERM), waits up to kill_timeout milliseconds, then sends SIGKILL.
    • Autorestart restarts the process whenever it exits, unless --no-autorestart was given, the exit code is listed in --stop-exit-codes, or the restart budget (--max-restarts) is exhausted.
    • A run shorter than --min-uptime increments the unstable restarts counter.
    • --max-memory-restart is enforced by a monitor that samples /proc/<pid>/status every two seconds.
    • A process that is no longer desired online (stopped by the user, or out of restarts) is left alone by pm3 resurrect.
    • After a daemon restart nothing can reattach to a dead child's pipes, so the loaded table starts out stopped; run pm3 resurrect to bring back the processes that were desired online. Any orphan from a hard daemon death is terminated first, so a service is never started twice.

    #Environment variables

    VariableMeaning
    PM3_HOMEState directory (default $HOME/.pm3)

    The launcher and installer add their own variables (PM3_BINARY, PM3_CACHE_DIR, PM3_VERSION, PM3_DOWNLOAD_BASE, PM3_REPO, …); they are documented in npmjs/README.md.

    #Limitations

    • Native (--target native) only: it needs POSIX process, signal and file APIs.
    • Linux/macOS: process memory and CPU are read from /proc, so resource reporting is Linux-specific. Supervision itself is POSIX.
    • kill_signal accepts SIGTERM, SIGINT, SIGHUP and SIGKILL.
    • compression_level is validated and stored, but the built-in gzip encoder uses a fixed level. The system gzip binary is preferred when available, and that one does honour the level.
    • --log-date-format is accepted and stored but line timestamping is not yet applied to log output.

    #License

    Apache-2.0.

    App

    pub(all) struct App {
    pm_id : Int
    name : String
    script : String
    args : Array[String]
    interpreter : String
    cwd : String
    env : Map[String, String]
    out_log : String
    err_log : String
    merge_logs : Bool
    log_date_format : String
    autorestart : Bool
    max_restarts : Int
    restart_delay : Int
    min_uptime : Int
    max_memory_restart : Int64
    kill_timeout : Int
    kill_signal : String
    stop_exit_codes : Array[Int]
    status : String
    desired_state : String
    pid : Int
    restarts : Int
    unstable_restarts : Int
    created_at : Int64
    started_at : Int64
    stopped_at : Int64
    exit_code : Int
    out_log_size : Int64
    err_log_size : Int64
    memory : Int64
    cpu : Double
    last_start_ms : Int64
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A process managed by pm3.

    App::command_display

    fn App::command_display(self : App) -> String

    Human-readable one-line command, for pm3 list and pm3 describe.

    App::command_line

    fn App::command_line(self : App) -> (String, Array[String])

    The argv pm3 will execute for this app: the interpreter (if any) followed by the script and its arguments.

    App::equal

    fn App::equal(App, App) -> Bool

    App::is_online

    fn App::is_online(self : App) -> Bool

    Whether the app currently has a live process.

    App::mark_exited

    fn App::mark_exited(self : App, code : Int) -> Unit

    Record that the process exited with code.

    App::mark_started

    fn App::mark_started(self : App, pid : Int) -> Unit

    Record that the process just started.

    App::new

    fn App::new(name : String, script : String, args : Array[String]) -> App

    Build a new application with conservative defaults.

    App::not_equal

    fn App::not_equal(x : App, y : App) -> Bool

    App::record_restart

    fn App::record_restart(self : App) -> Unit

    Record one restart, tracking whether the previous run was "unstable".

    App::should_restart

    fn App::should_restart(self : App, code : Int) -> Bool

    Whether the process should be restarted after exiting with code.

    App::to_json

    fn App::to_json(App) -> Json

    App::to_repr

    App::to_summary_json

    fn App::to_summary_json(self : App) -> Json

    A compact JSON description of an app, used for IPC payloads and --json.

    App::wants_stop

    fn App::wants_stop(self : App) -> Bool

    Whether a pm3 stop was requested.

    ConfigFormat

    pub(all) enum ConfigFormat {
    Toml
    Json
    } derive(Eq,
    Debug
    )

    The two configuration syntaxes pm3 understands.

    ConfigFormat::equal

    ConfigFormat::not_equal

    fn ConfigFormat::not_equal(x : ConfigFormat, y : ConfigFormat) -> Bool

    ConfigSource

    pub(all) struct ConfigSource {
    path : String
    format : ConfigFormat
    } derive(Eq,
    Debug
    )

    A configuration file pm3 found: where it is and how to read it.

    ConfigSource::equal

    ConfigSource::not_equal

    fn ConfigSource::not_equal(x : ConfigSource, y : ConfigSource) -> Bool

    CronSpec

    pub struct CronSpec {
    seconds : Array[Int]
    minutes : Array[Int]
    hours : Array[Int]
    days_of_month : Array[Int]
    months : Array[Int]
    days_of_week : Array[Int]
    seconds_restricted : Bool
    dom_restricted : Bool
    dow_restricted : Bool
    } derive(Eq,
    Debug
    )

    Parsed cron schedule. Fields are sorted ascending and de-duplicated.

    CronSpec::equal

    fn CronSpec::equal(CronSpec, CronSpec) -> Bool

    CronSpec::not_equal

    fn CronSpec::not_equal(x : CronSpec, y : CronSpec) -> Bool

    CronSpec::to_repr

    LogrotateConfig

    pub struct LogrotateConfig {
    max_size : Int64
    retain : Int
    compress : Bool
    compression_level : Int
    date_format : String
    worker_interval : Int
    rotate_interval : String
    tz : String
    rotate_module : Bool
    min_size : Int64
    max_age_days : Int
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    Rotation policy. Persisted as JSON so pm3 set logrotate:... survives daemon restarts.

    LogrotateConfig::equal

    LogrotateConfig::not_equal

    fn LogrotateConfig::not_equal(x : LogrotateConfig, y : LogrotateConfig) -> Bool

    LogrotateConfig::set_field

    fn LogrotateConfig::set_field(self : LogrotateConfig, key : String, value : String) -> Result[Unit, String]

    Update one field of the policy from a string, as used by pm3 set logrotate:<key> <value>.

    LogrotateConfig::to_display

    fn LogrotateConfig::to_display(self : LogrotateConfig) -> String

    Render the policy as aligned key = value lines for pm3 logrotate.

    LogrotateConfig::to_json

    Request

    pub(all) struct Request {
    cmd : String
    args : Array[String]
    opts : Map[String, String]
    flags : Map[String, Bool]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A request from the CLI. Kept deliberately generic so new commands do not require protocol version bumps.

    Request::equal

    fn Request::equal(Request, Request) -> Bool

    Request::new

    fn Request::new(cmd : String) -> Request

    A request with a command name and no arguments.

    Request::not_equal

    fn Request::not_equal(x : Request, y : Request) -> Bool

    Request::to_json

    fn Request::to_json(Request) -> Json

    Request::to_repr

    Request::with_args

    fn Request::with_args(cmd : String, args : Array[String]) -> Request

    A request with positional arguments.

    Response

    pub(all) struct Response {
    ok : Bool
    msg : String
    data : Json
    } derive(ToJson,
    Debug
    ,
    FromJson
    )

    A reply from the daemon. data carries an optional JSON payload.

    Response::failure

    fn Response::failure(msg : String) -> Response

    A failed response.

    Response::success

    fn Response::success(msg : String) -> Response

    A successful response with no payload.

    Response::success_data

    fn Response::success_data(msg : String, data : Json) -> Response

    A successful response carrying a JSON payload.

    Response::to_json

    fn Response::to_json(Response) -> Json

    Response::to_repr

    RotatedFile

    pub(all) struct RotatedFile {
    name : String
    mtime : Int64
    } derive(Eq,
    Debug
    )

    A rotated log file: its name and modification time.

    RotatedFile::equal

    fn RotatedFile::equal(RotatedFile, RotatedFile) -> Bool

    RotatedFile::not_equal

    fn RotatedFile::not_equal(x : RotatedFile, y : RotatedFile) -> Bool

    active_config_source

    fn active_config_source() -> ConfigSource

    The file pm3 will read, or the file it would create.

    app_config_candidates

    fn app_config_candidates() -> Array[String]

    Configuration files pm3 looks for when pm3 start is given no script.

    A project-local pm3.toml comes first, so a checked-in file wins over the user's global defaults; PM3_CONFIG overrides both. This list is about applications only — the rotation policy is read from the global file alone, whatever else exists.

    app_field_names

    fn app_field_names() -> Array[String]

    Field names an [[app]] entry understands. Anything else is rejected, so a typo is reported rather than silently ignored.

    apps_from_json

    fn apps_from_json(json : Json, base_dir : String) -> Result[Array[App], String]

    Build the application list declared in a parsed config document.

    base_dir is the directory of the config file, used to resolve the paths it contains.

    civil_from_days

    fn civil_from_days(days : Int) -> (Int, Int, Int)

    Civil date (year, month, day) for a number of days since 1970-01-01.

    cmd_delete

    let cmd_delete : String

    cmd_describe

    let cmd_describe : String

    cmd_flush

    let cmd_flush : String

    cmd_get_config

    let cmd_get_config : String

    cmd_get_logrotate

    let cmd_get_logrotate : String

    cmd_kill

    let cmd_kill : String

    cmd_list

    let cmd_list : String

    cmd_ping

    let cmd_ping : String

    Command names understood by the daemon.

    cmd_reload_logs

    let cmd_reload_logs : String

    cmd_reset

    let cmd_reset : String

    cmd_restart

    let cmd_restart : String

    cmd_resurrect

    let cmd_resurrect : String

    cmd_save

    let cmd_save : String

    cmd_set_logrotate

    let cmd_set_logrotate : String

    cmd_start

    let cmd_start : String

    cmd_start_apps

    let cmd_start_apps : String

    Start every app declared in a config file. args[0] is a JSON array of App records, already resolved by the client.

    cmd_stop

    let cmd_stop : String

    config_format_of

    fn config_format_of(path : String) -> ConfigFormat?

    Format implied by a file name, or None for an extension pm3 refuses to guess at.

    cron_matches

    fn cron_matches(spec : CronSpec, local_secs : Int64) -> Bool

    Does spec fire at local Unix second epoch_secs (already offset-shifted)?

    days_from_civil

    fn days_from_civil(year : Int, month : Int, day : Int) -> Int

    Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).

    decode_request

    fn decode_request(line : String) -> Request?

    Decode one request line.

    decode_response

    fn decode_response(line : String) -> Response?

    Decode one response line.

    default_config_source

    fn default_config_source() -> ConfigSource

    The configuration file pm3 writes to when none exists yet.

    default_log_path

    fn default_log_path(name : String, stream : String) -> String

    Default log path for a stream (out or err) of an application.

    default_logrotate_config

    fn default_logrotate_config() -> LogrotateConfig

    Default policy, matching the familiar pm2-logrotate defaults.

    derive_config_app_name

    fn derive_config_app_name(script : String) -> String

    Derive an app name from a script path: the basename without its extension.

    encode_request

    fn encode_request(request : Request) -> String

    Encode a request as a single newline-terminated line.

    encode_response

    fn encode_response(response : Response) -> String

    Encode a response as a single newline-terminated line.

    find_app_config

    fn find_app_config() -> String?

    The first application config file that exists, or None.

    find_config_source

    fn find_config_source() -> ConfigSource?

    The first configuration file that exists, or None.

    The order is TOML, JSON, then the legacy logrotate.json, so a user who migrates by writing config.toml does not have to delete the old file.

    format_cpu_percent

    fn format_cpu_percent(cpu : Double) -> String

    Render a CPU percentage the way PM2 does: rounded to one decimal, with a trailing .0 dropped — 0%, 85.4%, 100%.

    PM2 stores Math.round(stat.cpu * 10) / 10 and prints it with JavaScript's default number formatting, which is why a whole percentage has no decimal point. Matching that keeps the two tools' tables comparable line by line.

    format_duration

    fn format_duration(millis : Int64) -> String

    Render a millisecond duration as a short human string.

    format_fixed

    fn format_fixed(value : Double, precision : Int) -> String

    Format value with a fixed number of decimal places, without relying on locale-aware formatting.

    format_size

    fn format_size(bytes : Int64, precision? : Int) -> String

    Format a byte count with a binary unit and precision decimals.

    format_size_exact

    fn format_size_exact(bytes : Int64) -> String

    Render a byte count so that parse_size reads back exactly the same number.

    format_size rounds to two decimals, which is right for a status column but wrong for a config file: it would turn 1000 into 1003 on the next load. This only uses a unit when the value is an exact multiple, and otherwise falls back to a plain byte count.

    format_size_pm2

    fn format_size_pm2(bytes : Int64, precision? : Int) -> String

    Render a byte count the way PM2's bytesToSize does: one decimal place, a lowercase unit and no space — 2.0mb, 512.0kb, 1.2gb, 123b.

    The status tables use this so that pm3 list and pm2 ls agree on the number they show for the same process. PM2's own function appends a trailing space ('24.1mb '); that only matters where the value is concatenated into a sentence, and the tables pad the cell anyway.

    format_timestamp

    fn format_timestamp(epoch_secs : Int64, offset_secs : Int, fmt : String) -> String

    Format a Unix second using a subset of the moment.js-style tokens: YYYY, YY, MM, M, DD, D, HH, H, mm, m, ss, s.

    has_apps

    fn has_apps(json : Json) -> Bool

    Whether a document declares any applications.

    is_app_config_file

    fn is_app_config_file(path : String) -> Bool

    Whether a path names a config file pm3 should read rather than a program to run. Only TOML and JSON are ever config files.

    load_apps_from_file

    fn load_apps_from_file(path : String) -> Result[Array[App], String]

    Read and parse an application config file, returning the apps it declares.

    load_logrotate_config

    fn load_logrotate_config() -> (LogrotateConfig, ConfigSource)

    Load the rotation policy, together with the file it came from.

    A missing file is not an error: pm3 falls back to the defaults and reports where the settings would be written.

    logrotate_from_json

    fn logrotate_from_json(json : Json) -> Result[LogrotateConfig, String]

    Extract the rotation policy from a parsed config document.

    A [logrotate] table is the documented shape. A document that is itself a policy (the legacy logrotate.json) is also accepted, so upgrading does not require rewriting the file by hand. Unknown keys are rejected: a typo in a config file should be loud, not silently ignored.

    logrotate_to_json

    fn logrotate_to_json(config : LogrotateConfig) -> Json

    The rotation policy as a Json object nested under logrotate.

    logrotate_to_toml

    fn logrotate_to_toml(config : LogrotateConfig) -> String

    Serialise the rotation policy as a config.toml document.

    The output is regenerated from scratch, so comments in a hand-edited file are lost when pm3 set rewrites it. pm3 config prints the file path first, which is the hint to edit it by hand instead.

    next_cron_fire

    fn next_cron_fire(spec : CronSpec, from_secs : Int64, offset_secs : Int) -> Int64?

    Compute the next fire time strictly after from_secs.

    offset_secs is the local UTC offset applied before evaluating the calendar fields. Returns None when no fire time is found within roughly four years (e.g. for an impossible date such as February 30th).

    pad_left_text

    fn pad_left_text(text : String, width : Int, pad : Char) -> String

    Left-pad text with pad until it is at least width characters wide.

    pad_right

    fn pad_right(text : String, width : Int, pad : Char) -> String

    Right-pad text with pad until it is at least width characters wide.

    parse_bool_value

    fn parse_bool_value(text : StringView) -> Bool?

    Parse a true/false value, also accepting 1/0 and yes/no.

    parse_config_text

    fn parse_config_text(text : String, format : ConfigFormat) -> Result[Json, String]

    Parse configuration text in the given syntax.

    parse_cron

    fn parse_cron(expr : String) -> CronSpec?

    Parse a cron expression. Returns None for malformed input.

    parse_non_negative_int

    fn parse_non_negative_int(text : StringView) -> Int?

    Parse a base-10 non-negative integer.

    parse_rotated_stamp

    fn parse_rotated_stamp(log_path : String, file_name : String) -> String?

    If file_name is a rotated sibling of log_path, return the timestamp suffix that was embedded in its name.

    This replaces the original's indexOf(base) test, which also matched unrelated files such as api-out__backup.log or a differently named log that merely contained the base name.

    parse_size

    fn parse_size(text : StringView) -> Int64?

    Parse a size string such as 10M into bytes.

    parse_size_or

    fn parse_size_or(text : StringView, default : Int64) -> Int64

    Parse a size, falling back to default when the input is empty or invalid.

    parse_toml

    fn parse_toml(source : String) -> Result[Json, String]

    Parse a TOML document into the same Json shape that the JSON loader produces, so both config formats share one downstream path.

    The error string carries the offending line number.

    path_basename

    fn path_basename(path : String) -> String

    Return the final component of path.

    path_dirname

    fn path_dirname(path : String) -> String

    Return the directory part of path (everything before the last /). Returns "." when there is no separator.

    path_join

    fn path_join(left : String, right : String) -> String

    Join two path segments with a single /.

    pm3_config_candidates

    fn pm3_config_candidates() -> Array[String]

    Configuration files pm3 looks for, in order of precedence. Only TOML and JSON are supported; the last entry is the pre-config.* name.

    pm3_config_json_path

    fn pm3_config_json_path() -> String

    Path of the JSON configuration file.

    pm3_config_toml_path

    fn pm3_config_toml_path() -> String

    Path of the TOML configuration file.

    pm3_daemon_log_path

    fn pm3_daemon_log_path() -> String

    Path of the daemon's own stdout/stderr log.

    pm3_dump_path

    fn pm3_dump_path() -> String

    Path of the serialized process list.

    pm3_home

    fn pm3_home() -> String

    The pm3 home directory. Honours PM3_HOME, otherwise $HOME/.pm3.

    pm3_log_dir

    fn pm3_log_dir() -> String

    Directory holding application log files.

    pm3_logrotate_config_path

    fn pm3_logrotate_config_path() -> String

    Path of the persisted log-rotation configuration.

    logrotate.json is the historical name and is still read, but new installs are steered at config.toml / config.json, which can hold more than the rotation policy.

    pm3_pid_path

    fn pm3_pid_path() -> String

    Path of the file that publishes the daemon's process id.

    pm3_port_path

    fn pm3_port_path() -> String

    Path of the file that publishes the daemon's TCP port.

    pm3_state_dir

    fn pm3_state_dir() -> String

    Directory holding the daemon's own state (dump, port, config).

    read_config_file

    fn read_config_file(path : String) -> Result[Json, String]

    Read a configuration file and parse it according to its extension.

    Returns Err with a human-readable message for a missing file, an unsupported extension, or a syntax error.

    render_box

    fn render_box(header : Array[String]?, rows : Array[Array[String]]) -> String

    Render a table the way PM2 renders its tables through cli-tableau: a box-drawing border around every cell, with exactly one space of padding on each side of the content.

    header is optional. pm3 describe passes None to get the key/value shape pm2 show prints; pm3 list passes column names.

    ┌────┬────────┐ │ id │ name │ ├────┼────────┤ │ 0 │ worker │ └────┴────────┘

    render_config

    fn render_config(config : LogrotateConfig, format : ConfigFormat) -> String

    Serialise the rotation policy in the syntax the source file uses.

    rotated_path

    fn rotated_path(log_path : String, stamp : String, compress : Bool) -> String

    Compute the path a rotated log should be renamed to.

    log_path is the live log (e.g. .../logs/api-out.log); the result is .../logs/api-out__<stamp>.log (plus .gz when compression is enabled).

    rotation_due

    fn rotation_due(size : Int64, config : LogrotateConfig) -> Bool

    Whether a log of size bytes should be rotated under config.

    save_logrotate_config

    fn save_logrotate_config(config : LogrotateConfig, source : ConfigSource) -> Result[Unit, String]

    Persist the rotation policy back to its file, creating parent directories as needed. The format follows the file name.

    select_for_deletion

    fn select_for_deletion(files : Array[RotatedFile], retain : Int, max_age_days : Int, now : Int64) -> Array[String]

    Choose which rotated files to delete.

    Files are ordered newest-first by modification time; everything after the first retain is deleted. When max_age_days > 0, files older than that are deleted as well, regardless of position.

    split_timestamp

    fn split_timestamp(local_secs : Int64) -> (Int, Int, Int, Int, Int, Int, Int)

    Split a local Unix second into its calendar/clock fields.

    status_errored

    let status_errored : String

    status_launching

    let status_launching : String

    status_online

    let status_online : String

    status_stopped

    let status_stopped : String

    status_stopping

    let status_stopping : String

    strip_suffix_owned

    fn strip_suffix_owned(name : String, suffix : String) -> String

    Remove a trailing suffix from name, if present.

    truncate_text

    fn truncate_text(text : String, width : Int) -> String

    Shorten text to at most width characters, appending an ellipsis.

    weekday_from_days

    fn weekday_from_days(days : Int) -> Int

    Day of week for a number of days since 1970-01-01 (0 = Sunday).

    write_default_config

    fn write_default_config(format : ConfigFormat, force : Bool) -> Result[String, String]

    Create a configuration file seeded with the current effective policy.

    Refuses to overwrite an existing file unless force is set, so a stray invocation cannot silently discard a hand-written config. Returns the path that was written.