pitchfork_cli/settings.rs
1//! User-configurable settings for pitchfork.
2//!
3//! Settings can be configured in multiple ways (in order of precedence):
4//! 1. Environment variables (highest priority)
5//! 2. Project-level `pitchfork.toml` or `pitchfork.local.toml` (in `[settings]` section)
6//! 3. User-level `~/.config/pitchfork/config.toml` (in `[settings]` section)
7//! 4. System-level `/etc/pitchfork/config.toml` (in `[settings]` section)
8//! 5. Built-in defaults (lowest priority)
9//!
10//! Example pitchfork.toml with settings:
11//! ```toml
12//! [daemons.myapp]
13//! run = "node server.js"
14//!
15//! [settings.general]
16//! autostop_delay = "5m"
17//! log_level = "debug"
18//!
19//! [settings.web]
20//! auto_start = true
21//! ```
22//!
23//! The structs below are the single declaration of every setting:
24//! `#[derive(usage_rs::Config)]` generates the usage-config registry, the
25//! reader that fills the structs from a resolution, and the spec `config`
26//! block that documents them (carried into `pitchfork.usage.kdl` through
27//! `#[usage(config = ...)]` on the CLI root). There is no `settings.toml`
28//! and no build-script generator left to keep in step with this file.
29//!
30//! Resolution is usage-config's single origin-tracked merge, so explicitly
31//! setting a value equal to the default in a higher-precedence file still
32//! overrides a lower one, and `pitchfork settings` renders provenance from
33//! the same merge that produced the values.
34
35use std::path::{Path, PathBuf};
36use std::sync::Arc;
37use usage_rs::config::{
38 Const, EnvLayer, FileLayer, FileScope, Layers, Resolved, SourceKind, Ty, Value, resolve,
39};
40
41/// The `api.*` settings: the standalone API server (JSON REST endpoints for
42/// the Vue SPA). When `api.auto_start` is enabled and `api.bind_port` is set, the API server binds
43/// independently of the web UI.
44#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
45#[usage(prefix = "api")]
46pub struct SettingsApi {
47 /// Automatically start the standalone API server
48 ///
49 /// When true, pitchfork starts a standalone API server on api.bind_address +
50 /// api.bind_port when the supervisor launches. The web UI does not need to
51 /// be enabled for this; you can run the API alone and serve the Vue SPA from
52 /// a separate static host (e.g. nginx, Vite dev server).
53 ///
54 /// Default is false so the API is only available through the bundled web UI.
55 #[usage(env = "PITCHFORK_API_AUTO_START", default = false)]
56 pub auto_start: bool,
57
58 /// IP address the API server binds to
59 ///
60 /// Use "0.0.0.0" to make the API reachable from other devices on your network.
61 /// Keep "127.0.0.1" (default) to restrict it to localhost.
62 #[usage(env = "PITCHFORK_API_BIND_ADDRESS", default = "127.0.0.1")]
63 pub bind_address: String,
64
65 /// Port the standalone API server listens on
66 ///
67 /// Set to 0 (default) to disable the standalone API server.
68 /// Set to a valid port (e.g. 8081) and enable `api.auto_start` to start the API on its own port.
69 #[usage(env = "PITCHFORK_API_BIND_PORT", default = 0)]
70 pub bind_port: i64,
71
72 /// Number of consecutive ports to try if api.bind_port is in use
73 #[usage(env = "PITCHFORK_API_PORT_ATTEMPTS", default = 10)]
74 pub port_attempts: i64,
75
76 /// Authentication token for API access when bound to non-loopback addresses
77 ///
78 /// When the API server or web UI is bound to a non-loopback address (e.g.,
79 /// "0.0.0.0"), this token is required on every API request via the
80 /// X-Pitchfork-Token header.
81 ///
82 /// If left empty and the bind address is non-loopback, a random 32-byte
83 /// hex token is auto-generated on startup and printed to the supervisor log.
84 /// This token is injected into the served index.html, so the bundled Vue
85 /// SPA works without additional configuration.
86 ///
87 /// For external API consumers (e.g., curl, mobile apps), set this to a
88 /// fixed value or pass it via the PITCHFORK_API_TOKEN environment variable.
89 #[usage(env = "PITCHFORK_API_TOKEN", default = "")]
90 pub token: String,
91}
92
93/// The `general.*` settings.
94#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
95#[usage(prefix = "general")]
96pub struct SettingsGeneral {
97 /// Delay before auto-stopping daemons when leaving a directory
98 ///
99 /// When using shell hooks with `auto = ["stop"]`, this controls how long pitchfork waits
100 /// before stopping a daemon after you leave its directory.
101 ///
102 /// This delay prevents unnecessary stop/start cycles when briefly switching directories.
103 ///
104 /// **Examples:**
105 /// - `"0s"` - Stop immediately (no delay)
106 /// - `"30s"` - Wait 30 seconds
107 /// - `"1m"` - Wait 1 minute (default)
108 /// - `"5m"` - Wait 5 minutes
109 ///
110 /// Set to `"0s"` to disable the delay and stop daemons immediately.
111 #[usage(env = "PITCHFORK_AUTOSTOP_DELAY", default = "1m", ty = "duration")]
112 pub autostop_delay: String,
113
114 /// Supervisor background task refresh interval
115 ///
116 /// Controls how often the supervisor refreshes its internal state and checks for:
117 /// - Daemon health status changes
118 /// - Configuration file updates
119 /// - Process state synchronization
120 ///
121 /// Lower values provide more responsive status updates but use more resources.
122 ///
123 /// **Recommended values:**
124 /// - `"5s"` - For development/testing
125 /// - `"10s"` - Default, balanced
126 /// - `"30s"` - For production with many daemons
127 #[usage(
128 env = "PITCHFORK_INTERVAL",
129 deprecated_env("PITCHFORK_INTERVAL_SECS"),
130 default = "10s",
131 ty = "duration"
132 )]
133 pub interval: String,
134
135 /// File log level (trace, debug, info, warn, error)
136 ///
137 /// Controls the verbosity of log output written to log files.
138 /// Can be set independently from `log_level` to have more verbose file logs.
139 ///
140 /// For example, set console to `"info"` but file to `"debug"` to keep
141 /// detailed logs for troubleshooting without cluttering the console.
142 #[usage(env = "PITCHFORK_LOG_FILE_LEVEL", default = "info")]
143 pub log_file_level: String,
144
145 /// Console log level (trace, debug, info, warn, error)
146 ///
147 /// Controls the verbosity of log output to the console.
148 ///
149 /// **Available levels:**
150 /// - `"trace"` - Most verbose, includes all internal details
151 /// - `"debug"` - Detailed information for debugging
152 /// - `"info"` - Normal operation messages (default)
153 /// - `"warn"` - Warnings and potential issues
154 /// - `"error"` - Only errors
155 #[usage(env = "PITCHFORK_LOG", default = "info")]
156 pub log_level: String,
157
158 /// Wrap daemon commands with mise x -- globally
159 ///
160 /// When enabled, pitchfork wraps every daemon command with `mise x --` so that
161 /// [mise](https://mise.jdx.dev) sets up the correct tool versions, PATH,
162 /// and environment variables before the daemon runs.
163 ///
164 /// This is especially useful when pitchfork is started as a login item or boot
165 /// daemon, where the shell profile (`.zshrc`, `.bashrc`) is not sourced and
166 /// tools installed via Homebrew or mise are not on PATH.
167 ///
168 /// Individual daemons can override this with `mise = true` or `mise = false`
169 /// in their configuration.
170 #[usage(env = "PITCHFORK_MISE", default = false)]
171 pub mise: bool,
172
173 /// Explicit path to the mise binary
174 ///
175 /// By default, pitchfork searches well-known locations for the mise binary:
176 /// - `~/.local/bin/mise`
177 /// - `~/.cargo/bin/mise`
178 /// - `/usr/local/bin/mise`
179 /// - `/opt/homebrew/bin/mise`
180 /// - on Windows, `mise.exe` on `PATH`
181 ///
182 /// Set this to an absolute path if mise is installed elsewhere.
183 #[usage(env = "PITCHFORK_MISE_BIN", default = "")]
184 pub mise_bin: String,
185
186 /// Shell command used to execute daemon run scripts
187 ///
188 /// Controls the shell used to execute daemon `run` commands, as well as
189 /// `ready_cmd` / `health_cmd` probes, lifecycle hooks and the log archive
190 /// hook.
191 ///
192 /// The value is split with `shell_words::split` into a program and arguments,
193 /// then the daemon's `run` string is appended verbatim as the final argument
194 /// (passed to the shell's command flag, e.g. `sh -c "<run>"`).
195 ///
196 /// This means the `run` string is interpreted directly by the shell, so
197 /// variable expansion (`$VAR`), globs (`*.txt`), pipes (`|`), and command
198 /// chaining (`&&`) all work as expected.
199 ///
200 /// **Common configurations:**
201 /// - `"sh -c"` — Default, POSIX shell
202 /// - `"sh -o errexit -c"` — Exit on the first failing command
203 /// - `"bash -c"` — Use bash instead of sh
204 /// - `"bash -o errexit -o pipefail -c"` — `pipefail` is not a POSIX option,
205 /// so name a shell that has it rather than relying on `sh`
206 ///
207 /// On Windows this setting applies only when it is set explicitly (in a
208 /// config file or via `PITCHFORK_SHELL`); otherwise
209 /// `general.windows_shell` is used. Setting it is the way to use one shell
210 /// on every platform, e.g. `"sh -c"` with Git for Windows' `sh.exe` on
211 /// `PATH`.
212 ///
213 /// When `mise = true` is enabled for a daemon, the shell wraps inside
214 /// `mise x --`, e.g. `mise x -- sh -c "<run>"`.
215 #[usage(env = "PITCHFORK_SHELL", default = "sh -c")]
216 pub shell: String,
217
218 /// Shell command used to execute daemon run scripts on Windows
219 ///
220 /// Used in place of `general.shell` on Windows, unless `general.shell` is
221 /// set explicitly. Split and used the same way: the program and arguments
222 /// come from this value, and the `run` string is appended as the final
223 /// argument, e.g. `cmd /C "<run>"`.
224 ///
225 /// The default is `cmd /C`, which is always available, so `run` strings are
226 /// read by cmd.exe: use `%VAR%` rather than `$VAR`, and double quotes
227 /// rather than single quotes.
228 ///
229 /// The split follows POSIX rules, so a path with spaces or backslashes has
230 /// to be quoted: `"'C:\Program Files\Git\bin\sh.exe' -c"`.
231 ///
232 /// When the shell is cmd.exe with `/C` last, the `run` string is handed to
233 /// it as `/S /C "<run>"`, so double quotes in it reach cmd as written, e.g.
234 /// `run = '"C:\Program Files\app\app.exe" --name "a b"'`. A daemon with
235 /// `mise = true` is the exception: mise starts cmd itself and quotes the
236 /// string again, so a `run` containing `"` may not reach cmd intact there.
237 ///
238 /// **Common configurations:**
239 /// - `"cmd /C"` — Default
240 /// - `"powershell -Command"` / `"pwsh -Command"` — PowerShell
241 /// - `"sh -c"` — Git for Windows' sh, when `Git\bin` is on `PATH`
242 #[usage(env = "PITCHFORK_WINDOWS_SHELL", default = "cmd /C")]
243 pub windows_shell: String,
244
245 /// Show timestamps in startup log output
246 ///
247 /// When enabled, pitchfork prefixes each startup log line and result line
248 /// with a timestamp (e.g. `19:03:15`), making it easier to see how long
249 /// each daemon took to start.
250 ///
251 /// When disabled (default), a dim bullet (`•`) is used instead to keep
252 /// the output compact and aligned with the spinner / status icons.
253 #[usage(env = "PITCHFORK_STARTUP_LOG_TIMESTAMPS", default = false)]
254 pub startup_log_timestamps: bool,
255
256 /// Default readiness delay in seconds when a daemon has no ready check configured
257 ///
258 /// When a daemon has no other readiness check (output, HTTP, port, command),
259 /// pitchfork waits this long before considering it ready. Daemons can
260 /// override this with their own `ready_delay` setting.
261 #[usage(env = "PITCHFORK_READY_DELAY", default = "3s", ty = "duration")]
262 pub ready_delay: String,
263
264 /// Enable git worktree / jj workspace auto-discovery
265 ///
266 /// When enabled (default), pitchfork discovers git worktrees and jj workspaces for proxy
267 /// hostname routing and supervisor config discovery. Each worktree gets its own namespace,
268 /// and its daemons answer at `<daemon>.<worktree>.<project>.<tld>`.
269 ///
270 /// Set to `false` to disable all worktree/workspace discovery. The deprecated
271 /// `PITCHFORK_PROXY_WORKTREE` environment variable remains an alias during migration.
272 #[usage(
273 env = "PITCHFORK_WORKTREE",
274 deprecated_env("PITCHFORK_PROXY_WORKTREE"),
275 alias("proxy.worktree"),
276 default = true
277 )]
278 pub worktree: bool,
279}
280
281/// The `ipc.*` (inter-process communication) settings.
282#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
283#[usage(prefix = "ipc")]
284pub struct SettingsIpc {
285 /// Number of connection retry attempts
286 ///
287 /// How many times to retry connecting to the supervisor before giving up.
288 /// Each attempt uses exponential backoff between `connect_min_delay` and `connect_max_delay`.
289 #[usage(env = "PITCHFORK_IPC_CONNECT_ATTEMPTS", default = 5)]
290 pub connect_attempts: i64,
291
292 /// Maximum delay between connection retries
293 ///
294 /// The maximum delay between connection retry attempts.
295 /// Exponential backoff will not exceed this value.
296 #[usage(
297 env = "PITCHFORK_IPC_CONNECT_MAX_DELAY",
298 default = "1s",
299 ty = "duration"
300 )]
301 pub connect_max_delay: String,
302
303 /// Minimum delay between connection retries
304 ///
305 /// The initial delay between connection retry attempts.
306 /// The actual delay increases exponentially up to `connect_max_delay`.
307 #[usage(
308 env = "PITCHFORK_IPC_CONNECT_MIN_DELAY",
309 default = "100ms",
310 ty = "duration"
311 )]
312 pub connect_min_delay: String,
313
314 /// Maximum IPC requests per second per connection
315 ///
316 /// Rate limiting for IPC connections to prevent local DoS attacks.
317 /// Uses a sliding window algorithm.
318 ///
319 /// Most users won't need to change this. Increase if you have automated
320 /// tools making many rapid requests to the supervisor.
321 #[usage(env = "PITCHFORK_IPC_RATE_LIMIT", default = 100)]
322 pub rate_limit: i64,
323
324 /// Rate limit sliding window duration
325 ///
326 /// The time window for rate limiting calculations.
327 /// `rate_limit` requests are allowed within each window.
328 #[usage(
329 env = "PITCHFORK_IPC_RATE_LIMIT_WINDOW",
330 default = "1s",
331 ty = "duration"
332 )]
333 pub rate_limit_window: String,
334
335 /// Default timeout for IPC requests
336 ///
337 /// Maximum time to wait for a response from the supervisor for most operations.
338 ///
339 /// Note: Daemon start operations may use a longer timeout calculated from
340 /// the daemon's `ready_delay` setting plus a buffer.
341 #[usage(env = "PITCHFORK_IPC_REQUEST_TIMEOUT", default = "5s", ty = "duration")]
342 pub request_timeout: String,
343}
344
345/// The `logs.archive_hook.*` settings.
346#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
347#[usage(prefix = "logs.archive_hook")]
348pub struct SettingsLogsArchiveHook {
349 /// Maximum log entries per archive hook invocation
350 ///
351 /// Log entries selected for pruning are passed to the archive hook in batches of
352 /// this size. Smaller batches use less memory and are easier to retry, while larger
353 /// batches reduce hook invocation overhead.
354 #[usage(env = "PITCHFORK_LOG_ARCHIVE_HOOK_BATCH_SIZE", default = 1000)]
355 pub batch_size: i64,
356
357 /// Command to run before retention deletes old log entries
358 ///
359 /// When set, the given command is invoked before log entries are permanently
360 /// pruned by retention. The command receives the entries about to be deleted as
361 /// JSON Lines (JSONL) on stdin. Each line is an object with the fields:
362 ///
363 /// - `id`: the SQLite row id
364 /// - `daemon_id`: the qualified daemon id (e.g. `"myproject/api"`)
365 /// - `timestamp`: the log timestamp in RFC 3339 format
366 /// - `message`: the raw log message text
367 ///
368 /// The following environment variables are also available:
369 ///
370 /// - `PITCHFORK_DAEMON_ID`: the qualified daemon id
371 /// - `PITCHFORK_ARCHIVE_REASON`: either `"age"` or `"count"`
372 ///
373 /// If the command exits with a non-zero status, the pruning is skipped for that
374 /// batch. This prevents data loss when the archive destination is unavailable.
375 ///
376 /// **Examples:**
377 ///
378 /// ```toml
379 /// [settings.logs.archive_hook]
380 /// command = "gzip -c >> /var/log/pitchfork/archive.jsonl.gz"
381 /// ```
382 ///
383 /// ```toml
384 /// [settings.logs.archive_hook]
385 /// command = "aws s3 cp - s3://my-bucket/pitchfork-logs/"
386 /// ```
387 #[usage(env = "PITCHFORK_LOG_ARCHIVE_HOOK_COMMAND", default = "")]
388 pub command: String,
389}
390
391/// The `logs.*` settings.
392#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
393#[usage(prefix = "logs")]
394pub struct SettingsLogs {
395 #[usage(flatten)]
396 pub archive_hook: SettingsLogsArchiveHook,
397
398 /// Count-based log retention (e.g. 10000)
399 ///
400 /// Maximum number of log entries to keep per daemon in the SQLite log store.
401 ///
402 /// When set, only the most recent N log entries are retained. Older entries are
403 /// automatically pruned.
404 ///
405 /// When set to `0` (default), no count-based pruning is performed. You can combine
406 /// this with `time_retention` to enforce both a time limit and a line count limit.
407 ///
408 /// Logs are pruned automatically by the supervisor during its interval watcher
409 /// cycle (no more than once per hour). No manual rotation command is needed.
410 #[usage(env = "PITCHFORK_LOG_LINE_RETENTION", default = 0)]
411 pub line_retention: i64,
412
413 /// Default log format for daemons (json | logfmt | text)
414 #[usage(env = "PITCHFORK_LOG_FORMAT", default = "text")]
415 pub log_format: String,
416
417 /// Time-based log retention duration (e.g. '7d', '30d')
418 ///
419 /// Maximum age of log entries to keep in the SQLite log store.
420 ///
421 /// When set, log entries older than this duration are automatically pruned.
422 /// Examples: `"7d"` for 7 days, `"30d"` for 30 days, `"1h"` for 1 hour.
423 ///
424 /// When empty (default), no time-based pruning is performed. You can combine
425 /// this with `line_retention` to enforce both a time limit and a line count limit.
426 ///
427 /// Logs are pruned automatically by the supervisor during its interval watcher
428 /// cycle (no more than once per hour). No manual rotation command is needed.
429 #[usage(env = "PITCHFORK_LOG_TIME_RETENTION", default = "", ty = "duration")]
430 pub time_retention: String,
431
432 /// Show timestamps in log output
433 ///
434 /// When enabled (default), pitchfork prefixes each log line with a timestamp
435 /// (e.g. `07-10 10:30:00`).
436 ///
437 /// When disabled, timestamps are omitted from `pitchfork logs` output. This is
438 /// useful when piping logs to tools like `lnav` that expect raw JSON or when
439 /// you want to process log output without the timestamp prefix.
440 ///
441 /// Can be overridden per-invocation with `pitchfork logs --no-timestamp`.
442 #[usage(env = "PITCHFORK_LOG_TIMESTAMP", default = true)]
443 pub timestamp: bool,
444
445 /// strftime format for log timestamps in `pitchfork logs` output
446 ///
447 /// Controls the date/time format used when displaying timestamps in `pitchfork logs`
448 /// output. Uses chrono strftime syntax.
449 ///
450 /// Common format specifiers:
451 /// - `%Y` — full year (2024)
452 /// - `%m` — month (01-12)
453 /// - `%d` — day (01-31)
454 /// - `%H` — hour (00-23)
455 /// - `%M` — minute (00-59)
456 /// - `%S` — second (00-59)
457 ///
458 /// Examples:
459 /// - `%m-%d %H:%M:%S` — `07-10 10:30:00` (default)
460 /// - `%Y-%m-%d %H:%M:%S` — `2024-07-10 10:30:00`
461 /// - `%H:%M:%S` — `10:30:00` (time only)
462 /// - `%Y/%m/%d %H:%M` — `2024/07/10 10:30`
463 ///
464 /// Only affects the text display output, not `--json` or `--raw` modes.
465 #[usage(env = "PITCHFORK_LOG_TIMESTAMP_FORMAT", default = "%m-%d %H:%M:%S")]
466 pub timestamp_format: String,
467}
468
469/// The `proxy.*` settings.
470#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
471#[usage(prefix = "proxy")]
472pub struct SettingsProxy {
473 /// Automatically start daemons when accessed via proxy URL
474 ///
475 /// Enabled by default. Opening a stopped daemon's proxy URL starts its
476 /// `depends` dependencies first, using the same startup order and readiness
477 /// checks as `pitchfork start`. Oneshot dependencies must complete successfully.
478 ///
479 /// The first request waits for startup. Additional requests to the same daemon
480 /// receive a "Starting…" page that refreshes every two seconds until it is ready.
481 /// Opening a project or stack page does not start any daemons.
482 ///
483 /// Set to `false` to return a 502 error for stopped daemons and require manual startup.
484 #[usage(env = "PITCHFORK_PROXY_AUTO_START", default = true)]
485 pub auto_start: bool,
486
487 /// Maximum time to wait for an auto-started daemon to become ready
488 ///
489 /// Limits how long a proxy request waits for dependency startup, readiness
490 /// checks, and detection of the daemon's bound port. Defaults to 30 seconds.
491 ///
492 /// On timeout, the request receives an error page, but startup continues in
493 /// the background. Reload to check again, or increase this setting for a
494 /// longer startup sequence, for example `"60s"`.
495 #[usage(
496 env = "PITCHFORK_PROXY_AUTO_START_TIMEOUT",
497 default = "30s",
498 ty = "duration"
499 )]
500 pub auto_start_timeout: String,
501
502 /// Automatically trust the generated proxy CA certificate
503 ///
504 /// When enabled (default), pitchfork attempts to install its generated CA
505 /// certificate into the system trust store during HTTPS proxy startup.
506 ///
507 /// On macOS, this triggers a system authorization dialog (Touch ID or password).
508 /// On Linux, use `pitchfork proxy setup` to install the CA with sudo while
509 /// keeping the supervisor unprivileged.
510 ///
511 /// If auto-trust fails, pitchfork logs a warning and continues starting the
512 /// proxy. Use `pitchfork proxy doctor` to check trust, or
513 /// `pitchfork proxy trust` to install the CA manually (with sudo on Linux).
514 ///
515 /// Set to `false` to disable auto-trust entirely.
516 #[usage(env = "PITCHFORK_PROXY_AUTO_TRUST", default = true)]
517 pub auto_trust: bool,
518
519 /// Run a loopback DNS resolver for the proxy TLD
520 ///
521 /// While the proxy is running, answer UDP and TCP DNS queries on
522 /// `127.0.0.1:<proxy.dns_port>` for names under `proxy.tld`, including nested
523 /// project and worktree hostnames. Answers follow `proxy.host`, or the LAN
524 /// IPv4 address in LAN mode. Names outside the TLD receive REFUSED; queries
525 /// are never forwarded.
526 ///
527 /// Run `pitchfork proxy setup` to configure system resolution separately.
528 /// Set to `false` when using another resolver or when DNS is not needed.
529 #[usage(env = "PITCHFORK_PROXY_DNS", default = true)]
530 pub dns: bool,
531
532 /// Port the loopback DNS resolver listens on
533 ///
534 /// The resolver binds `127.0.0.1:<dns_port>` on both UDP and TCP.
535 ///
536 /// The default avoids privileged port 53 and the mDNS port, 5353.
537 #[usage(env = "PITCHFORK_PROXY_DNS_PORT", default = 15353)]
538 pub dns_port: i64,
539
540 /// Enable the reverse proxy server for daemons
541 ///
542 /// When enabled, pitchfork starts a reverse proxy that routes a stable
543 /// hostname to the daemon's actual listening port.
544 ///
545 /// Every daemon with a `port` gets a hostname built from its name, its
546 /// worktree when it lives in one, and its project, unless it opts out with
547 /// `proxy = false`. A daemon without a port is not routed.
548 ///
549 /// Example: `api.myproject.localhost:7777` -> `localhost:3000`
550 #[usage(env = "PITCHFORK_PROXY_ENABLE", default = false)]
551 pub enable: bool,
552
553 /// Bind address for the reverse proxy server
554 ///
555 /// IP address the reverse proxy listens on.
556 ///
557 /// **Security Warning:** The default `127.0.0.1` only allows local connections.
558 /// Setting this to `0.0.0.0` will expose the proxy on every network interface,
559 /// including externally routable ones -- anyone on the same LAN can then reach
560 /// your local daemons.
561 ///
562 /// **Examples:**
563 /// - `"127.0.0.1"` - Local only (default, recommended)
564 /// - `"0.0.0.0"` - All interfaces (use with caution)
565 /// - `"::1"` - IPv6 loopback
566 #[usage(env = "PITCHFORK_PROXY_HOST", default = "127.0.0.1")]
567 pub host: String,
568
569 /// Enable HTTPS for the reverse proxy
570 ///
571 /// When enabled (default), the proxy serves HTTPS instead of HTTP.
572 ///
573 /// You must also configure `proxy.tls_cert` and `proxy.tls_key`, or pitchfork
574 /// will auto-generate a self-signed certificate stored in the state directory.
575 ///
576 /// Set to `false` to use plain HTTP (e.g. for simple local development).
577 #[usage(env = "PITCHFORK_PROXY_HTTPS", default = true)]
578 pub https: bool,
579
580 /// Stop proxy-started daemons after this long without proxy activity
581 ///
582 /// Disabled by default: an empty string or `"0"` disables the default timeout.
583 /// Set a duration such as `"15m"` or `"1h"` to enable idle shutdown for daemons
584 /// started through their proxy URL. A daemon's `proxy_idle_timeout` overrides
585 /// this setting; dependencies without an override inherit the requested
586 /// daemon's timeout.
587 ///
588 /// HTTP requests count until their response ends. Streaming responses,
589 /// WebSockets, and TLS passthrough connections keep a daemon active while
590 /// open. DNS lookups, idle keep-alive connections, and traffic sent directly
591 /// to the daemon's port do not count.
592 ///
593 /// Only proxy-started daemons are eligible. Explicitly starting a daemon
594 /// exempts it and its dependencies from idle shutdown. Live dependents and
595 /// tracked shell sessions also prevent shutdown.
596 ///
597 /// Eligibility is checked every `general.interval` (10 seconds by default).
598 /// Dependencies stop after their dependents; shutdown may take longer than
599 /// the idle timeout. The timeout is recorded at startup, and activity
600 /// tracking resets after a supervisor restart.
601 #[usage(env = "PITCHFORK_PROXY_IDLE_TIMEOUT", default = "", ty = "duration")]
602 pub idle_timeout: String,
603
604 /// Enable LAN mode for the reverse proxy
605 ///
606 /// When enabled, the proxy switches to the `.local` TLD and publishes slug
607 /// hostnames via mDNS so that other devices on the same network can reach
608 /// your daemons (e.g. `myapp.local` from a phone or another computer).
609 ///
610 /// LAN mode:
611 /// - Forces `proxy.tld` to `local` (mDNS requirement)
612 /// - Publishes each slug as an mDNS address record (`<slug>.local → <LAN-IP>`)
613 /// - Binds the proxy to `0.0.0.0` instead of `127.0.0.1` (overridable via `proxy.host`)
614 /// - Auto-detects your LAN IP and re-publishes mDNS records if it changes
615 ///
616 /// Other devices must trust the pitchfork CA certificate to use HTTPS.
617 /// Run `pitchfork proxy trust` on each device, or use `proxy.https = false`.
618 #[usage(env = "PITCHFORK_PROXY_LAN", default = false)]
619 pub lan: bool,
620
621 /// Pin a specific LAN IP address instead of auto-detecting
622 ///
623 /// When set, skips auto-detection and uses this IP for mDNS publishing.
624 /// Implies `proxy.lan = true` if a non-empty value is provided.
625 #[usage(env = "PITCHFORK_PROXY_LAN_IP", default = "")]
626 pub lan_ip: String,
627
628 /// Port the reverse proxy server listens on
629 ///
630 /// The port pitchfork's reverse proxy binds to. Must be in the range 1-65535.
631 ///
632 /// Default is 443 (standard HTTPS port) since the proxy defaults to HTTPS.
633 /// Users can override this to any port (e.g. 7777) to avoid requiring
634 /// elevated privileges.
635 ///
636 /// To use standard ports without running the supervisor as root, choose
637 /// an unprivileged listener such as 8443 and run `pitchfork proxy setup`
638 /// to redirect local traffic on macOS or Linux. On Linux, setup can also
639 /// grant permission to bind ports below 1024 directly.
640 #[usage(env = "PITCHFORK_PROXY_PORT", default = 443)]
641 pub port: i64,
642
643 /// Automatically sync slug hostnames to /etc/hosts (deprecated)
644 ///
645 /// Deprecated and scheduled for removal after one release. Run
646 /// `pitchfork proxy setup`, check resolution with `pitchfork proxy doctor`,
647 /// then set `sync_hosts = false`. The loopback DNS resolver supports nested
648 /// hostnames without per-host entries.
649 ///
650 /// When enabled (default), pitchfork adds entries to `/etc/hosts` for
651 /// registered slugs (e.g. `127.0.0.1 myapp.localhost`) so that browsers can
652 /// resolve them. Entries are managed in a marked block and cleaned up when
653 /// the proxy shuts down. Writing to `/etc/hosts` may require `sudo`, and it
654 /// only ever covers registered slugs, never wildcard names.
655 #[usage(env = "PITCHFORK_PROXY_SYNC_HOSTS", default = true)]
656 pub sync_hosts: bool,
657
658 /// Top-level domain used for proxy URLs
659 ///
660 /// The TLD appended to daemon hostnames in proxy URLs.
661 ///
662 /// With the default `localhost`, daemon URLs look like:
663 /// `api.myproject.localhost:7777` (daemon `api` of project `myproject`)
664 ///
665 /// Run `pitchfork proxy setup` to configure system resolution, including
666 /// for custom TLDs such as `test`, or use `--pac` for applications that honor
667 /// automatic proxy settings. Restart the supervisor after changing the TLD.
668 #[usage(env = "PITCHFORK_PROXY_TLD", default = "localhost")]
669 pub tld: String,
670
671 /// Path to TLS certificate file (PEM format) for HTTPS proxy
672 ///
673 /// Path to a PEM-encoded TLS certificate file used when `proxy.https = true`.
674 /// It is served as-is for every hostname, and must match `proxy.tls_key`.
675 ///
676 /// If left empty and `proxy.https = true`, pitchfork generates a local
677 /// certificate authority at `$PITCHFORK_STATE_DIR/proxy/ca.pem` and signs a
678 /// certificate per hostname from it on the first TLS handshake, caching
679 /// them in `$PITCHFORK_STATE_DIR/proxy/host-certs/`. Trusting the CA once
680 /// with `pitchfork proxy trust` covers every proxy hostname.
681 ///
682 /// With a custom certificate, pitchfork serves that certificate without
683 /// signing per-hostname certificates. It must cover every hostname you
684 /// use, and clients must trust its issuer. Setup skips CA installation;
685 /// set `proxy.auto_trust = false` to also disable startup CA trust.
686 #[usage(env = "PITCHFORK_PROXY_TLS_CERT", default = "")]
687 pub tls_cert: String,
688
689 /// Path to TLS private key file (PEM format) for HTTPS proxy
690 ///
691 /// Path to a PEM-encoded private key file matching `proxy.tls_cert`. The
692 /// pair is checked at startup, so a mismatch is reported rather than
693 /// failing every handshake.
694 ///
695 /// If left empty and `proxy.https = true`, pitchfork generates a CA key at
696 /// `$PITCHFORK_STATE_DIR/proxy/ca-key.pem` instead. See `proxy.tls_cert`.
697 #[usage(env = "PITCHFORK_PROXY_TLS_KEY", default = "")]
698 pub tls_key: String,
699
700 /// Enable wildcard subdomain matching for proxy routes
701 ///
702 /// When enabled (default), extra labels to the left of a daemon's hostname
703 /// route to that same daemon.
704 ///
705 /// For example, with a daemon reachable at `api.myproject.localhost`:
706 ///
707 /// - `api.myproject.localhost` → exact match (always works)
708 ///
709 /// - `tenant.api.myproject.localhost` → wildcard fallback to the same daemon
710 ///
711 /// The same holds for a legacy slug, where `tenant.myapp.localhost` falls
712 /// back to the slug `myapp`.
713 ///
714 /// This is useful for multi-tenant apps where each tenant gets a unique
715 /// subdomain (e.g. `acme.myapp.localhost`, `globex.myapp.localhost`) but
716 /// all share the same backend server.
717 ///
718 /// Set to `false` to require exact hostname matches only.
719 #[usage(env = "PITCHFORK_PROXY_WILDCARD", default = true)]
720 pub wildcard: bool,
721}
722
723/// The `supervisor.*` settings.
724#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
725#[usage(prefix = "supervisor")]
726pub struct SettingsSupervisor {
727 /// Automatically start the supervisor when a client command needs it
728 ///
729 /// When enabled (default), commands such as `pitchfork start`, `pitchfork list`,
730 /// the TUI, and shell activation start a background supervisor automatically when
731 /// one is not already running.
732 ///
733 /// Disable this when the supervisor is managed by systemd, launchd, or another
734 /// service manager:
735 ///
736 /// ```toml
737 /// [settings.supervisor]
738 /// auto_start = false
739 /// ```
740 ///
741 /// With auto-start disabled, client commands wait for the configured IPC
742 /// connection attempts and then fail with an actionable error instead of spawning
743 /// an unmanaged supervisor. Explicit `pitchfork supervisor start` and
744 /// `pitchfork supervisor run` commands are unaffected.
745 #[usage(env = "PITCHFORK_SUPERVISOR_AUTO_START", default = true)]
746 pub auto_start: bool,
747
748 /// Reconcile orphaned daemon processes when supervisor starts
749 ///
750 /// When enabled, the supervisor scans the state file on startup for daemon
751 /// processes left behind by a previous supervisor instance that was killed
752 /// unexpectedly (for example, with `kill -9`) and reconciles them according
753 /// to `supervisor.orphan_policy` (re-adopt by default, or terminate).
754 ///
755 /// Before acting, the recorded process identity (PID plus kernel start
756 /// time) is verified so that a PID recycled by the OS to an unrelated process
757 /// is never adopted or killed — in that case only the stale state entry is
758 /// cleared. When terminating, Linux and Windows also pin that identity with a
759 /// pidfd or open process handle, so a recycled PID cannot be signaled. If the
760 /// identity cannot be verified or pinned, reconciliation fails closed: the
761 /// live process and its running state are retained rather than risk acting on
762 /// the wrong process or allowing a duplicate instance to start.
763 ///
764 /// Disabling this leaves orphaned processes and their state entries
765 /// completely untouched. This is a legacy escape hatch; prefer
766 /// `orphan_policy = "adopt"` (the default), which keeps daemons running
767 /// across a supervisor crash while resuming supervision.
768 #[usage(env = "PITCHFORK_CLEANUP_ORPHANS", default = true)]
769 pub cleanup_orphans: bool,
770
771 /// Enable container/PID1 mode for running inside Docker containers
772 ///
773 /// When enabled, pitchfork operates as a proper PID 1 process inside a container:
774 /// - Installs a SIGCHLD handler to reap all orphaned/zombie child processes
775 /// - Routes SIGTERM/SIGINT through the graceful shutdown sequence
776 ///
777 /// This is essential when running pitchfork as the entrypoint of a Docker container,
778 /// where PID 1 must reap zombie processes to prevent process table exhaustion.
779 ///
780 /// Can also be enabled via the `--container` CLI flag on `pitchfork supervisor run`.
781 #[usage(env = "PITCHFORK_CONTAINER", default = false)]
782 pub container: bool,
783
784 /// Consecutive CPU-over-limit samples before killing a daemon
785 ///
786 /// When a daemon has `cpu_limit` configured, the supervisor checks CPU usage at
787 /// each interval tick. To avoid killing daemons during transient spikes (e.g. JIT
788 /// warm-up, burst responses), the process is only killed after this many
789 /// **consecutive** samples exceed the limit. A single sample below the limit
790 /// resets the counter.
791 ///
792 /// **Examples:**
793 /// - `1` - Kill immediately on first over-limit sample (no grace period)
794 /// - `3` - Require 3 consecutive over-limit samples (default)
795 /// - `5` - More tolerant of short bursts
796 ///
797 /// With the default interval of `10s`, a threshold of `3` means a daemon must
798 /// exceed its CPU limit for ~30 seconds before being killed.
799 #[usage(env = "PITCHFORK_CPU_VIOLATION_THRESHOLD", default = 3)]
800 pub cpu_violation_threshold: i64,
801
802 /// Interval for checking cron schedules
803 ///
804 /// How often to check if any cron-scheduled daemons should be triggered.
805 ///
806 /// The default of 10 seconds supports sub-minute cron schedules.
807 /// Increase for lower resource usage if you don't need fine-grained scheduling.
808 #[usage(
809 env = "PITCHFORK_CRON_CHECK_INTERVAL",
810 default = "10s",
811 ty = "duration"
812 )]
813 pub cron_check_interval: String,
814
815 /// File watch debounce duration
816 ///
817 /// When using `watch` patterns to auto-restart daemons on file changes,
818 /// this controls how long to wait after the last change before triggering
819 /// a restart.
820 ///
821 /// This prevents rapid restart cycles when many files change at once
822 /// (e.g., during a build or git checkout).
823 #[usage(env = "PITCHFORK_FILE_WATCH_DEBOUNCE", default = "1s", ty = "duration")]
824 pub file_watch_debounce: String,
825
826 /// Default time between health probes
827 ///
828 /// When a daemon has `health_cmd`, `health_http` or `health_port`
829 /// configured but does not set its own `interval`, the supervisor probes
830 /// it this often.
831 #[usage(
832 env = "PITCHFORK_HEALTH_CHECK_INTERVAL",
833 default = "10s",
834 ty = "duration"
835 )]
836 pub health_check_interval: String,
837
838 /// Default consecutive health-check failures before killing a daemon
839 ///
840 /// When a daemon's `health_cmd`, `health_http` or `health_port` fails this
841 /// many times in a row, the daemon is killed as a crash so the retry logic
842 /// restarts it. Individual daemons can override this with `retries` in
843 /// their health check configuration.
844 #[usage(env = "PITCHFORK_HEALTH_CHECK_RETRIES", default = 3)]
845 pub health_check_retries: i64,
846
847 /// Default per-probe timeout for `health_cmd`
848 ///
849 /// Maximum time to wait for a `health_cmd` shell command to finish before
850 /// counting the probe as failed and cancelling it.
851 #[usage(env = "PITCHFORK_HEALTH_CMD_TIMEOUT", default = "10s", ty = "duration")]
852 pub health_cmd_timeout: String,
853
854 /// Default per-request timeout for `health_http`
855 ///
856 /// Maximum time to wait for a response from a `health_http` endpoint before
857 /// counting the probe as failed.
858 #[usage(env = "PITCHFORK_HEALTH_HTTP_TIMEOUT", default = "5s", ty = "duration")]
859 pub health_http_timeout: String,
860
861 /// Default per-connect timeout for `health_port`
862 ///
863 /// Maximum time to wait for a TCP connection to a `health_port` to
864 /// establish before counting the probe as failed.
865 #[usage(env = "PITCHFORK_HEALTH_PORT_TIMEOUT", default = "5s", ty = "duration")]
866 pub health_port_timeout: String,
867
868 /// Timeout for HTTP ready checks
869 ///
870 /// Maximum time to wait for a response when checking `ready_http` endpoints.
871 ///
872 /// Increase if your services take a while to respond during startup.
873 #[usage(env = "PITCHFORK_HTTP_CLIENT_TIMEOUT", default = "5s", ty = "duration")]
874 pub http_client_timeout: String,
875
876 /// Daemon log buffer flush interval
877 ///
878 /// How often daemon log output is flushed to disk.
879 /// Lower values mean logs appear faster in the UI but may impact performance.
880 #[usage(
881 env = "PITCHFORK_LOG_FLUSH_INTERVAL",
882 default = "500ms",
883 ty = "duration"
884 )]
885 pub log_flush_interval: String,
886
887 /// What to do with live orphaned daemons on supervisor startup: adopt or kill
888 ///
889 /// When the supervisor starts and finds daemons in the state file whose
890 /// processes are still alive from a previous supervisor instance that died
891 /// uncleanly, this policy decides what happens (after the process identity
892 /// is verified via PID plus kernel start time):
893 ///
894 /// - `adopt` (default): keep the process running and resume supervision.
895 /// The daemon keeps its state (status, ports, proxy routing) and is
896 /// monitored by polling. Log capture is unaffected, because a daemon's
897 /// output is read by a sibling sink process rather than by the supervisor,
898 /// so it continues uninterrupted across the crash. Exit codes of adopted
899 /// daemons cannot be observed, though;
900 /// an adopted daemon that dies unexpectedly is marked `errored` with an
901 /// unknown exit code, which makes it eligible for its configured retries.
902 /// - `kill`: terminate the orphaned process group so the new supervisor
903 /// starts with a clean slate, matching pre-adoption behavior.
904 ///
905 /// Daemons whose recorded PID is dead, or whose PID now belongs to a
906 /// different process, have their state reset under either policy. If the
907 /// process identity cannot be verified, reconciliation fails closed and
908 /// retains the running state without adopting or killing.
909 ///
910 /// The same policy applies when the interval watcher finds a running daemon
911 /// that has lost its monitor at runtime.
912 ///
913 /// This setting has no effect when `cleanup_orphans` is disabled.
914 #[usage(env = "PITCHFORK_ORPHAN_POLICY", default = "adopt")]
915 pub orphan_policy: String,
916
917 /// Maximum port increment attempts when auto_bump_port is enabled
918 ///
919 /// When `auto_bump_port = true` is set on a daemon, pitchfork will try incrementing
920 /// all of the daemon's ports by the same offset to find a free range. This setting
921 /// controls how many offsets are tried before giving up with an error.
922 ///
923 /// For example, with `port = [3000]` and `port_bump_attempts = 10`, pitchfork will
924 /// try ports 3000, 3001, 3002, ... up to 3009 before reporting failure.
925 ///
926 /// This is a global default; individual daemons can override it with
927 /// `port_bump_attempts` in their daemon configuration.
928 #[usage(env = "PITCHFORK_PORT_BUMP_ATTEMPTS", default = 10)]
929 pub port_bump_attempts: i64,
930
931 /// Interval between ready checks (HTTP, TCP, command)
932 ///
933 /// How often to poll when checking if a daemon is ready using:
934 /// - `ready_http` - HTTP health endpoint
935 /// - `ready_port` - TCP port listening
936 /// - `ready_cmd` - Shell command exit code
937 ///
938 /// Lower values detect readiness faster but use more resources.
939 #[usage(
940 env = "PITCHFORK_READY_CHECK_INTERVAL",
941 default = "500ms",
942 ty = "duration"
943 )]
944 pub ready_check_interval: String,
945
946 /// Delay between stop and start during restart
947 ///
948 /// Brief pause after stopping a daemon before starting it again.
949 /// Helps ensure resources (like ports) are fully released.
950 #[usage(env = "PITCHFORK_RESTART_DELAY", default = "100ms", ty = "duration")]
951 pub restart_delay: String,
952
953 /// Maximum time to wait for a oneshot daemon to finish
954 ///
955 /// A `oneshot = true` daemon is ready when its process exits `0`, so there is no
956 /// readiness check to bound the wait. `pitchfork start` gives up after this long
957 /// and reports a timeout; the task itself keeps running and is still recorded as
958 /// `completed` if it later exits successfully. A nonzero exit remains a failure.
959 ///
960 /// Set to `0` for no limit. Raise it for long migrations, backfills, or seeds.
961 #[usage(env = "PITCHFORK_ONESHOT_TIMEOUT", default = "1h", ty = "duration")]
962 pub oneshot_timeout: String,
963
964 /// Maximum time to wait for daemon to stop gracefully
965 ///
966 /// When stopping a daemon, pitchfork sends its configured signal (SIGTERM by default) and waits this long
967 /// for the process to exit gracefully before sending SIGKILL.
968 ///
969 /// Increase for daemons that need time to clean up (e.g., flush data).
970 #[usage(env = "PITCHFORK_STOP_TIMEOUT", default = "5s", ty = "duration")]
971 pub stop_timeout: String,
972
973 /// Default user to run daemon processes as
974 ///
975 /// Default Unix user for daemon processes spawned by the supervisor.
976 ///
977 /// When set, all daemons run as this user unless an individual daemon sets
978 /// `user = "..."`. The value may be a username (for example `"postgres"`) or
979 /// a numeric UID (for example `"501"`).
980 ///
981 /// If unset and the supervisor is running as root via `sudo`, daemons default to
982 /// the sudo-calling user from `SUDO_UID`/`SUDO_GID` instead of running as root.
983 /// A system boot service registered with `sudo pitchfork boot enable` records
984 /// that user as `--invoking-user` and defaults to them in the same way.
985 #[usage(env = "PITCHFORK_USER", default = "")]
986 pub user: String,
987
988 /// File watcher config refresh interval
989 ///
990 /// How often the supervisor refreshes file watch configuration when using `watch` patterns.
991 ///
992 /// This controls how quickly newly started/stopped daemons with watch patterns are reflected
993 /// in the active watcher set.
994 ///
995 /// For polling watcher cadence, use `supervisor.watch_poll_interval`.
996 ///
997 /// Lower values react faster to configuration/runtime changes but use more CPU.
998 /// The default `"10s"` is appropriate for most environments.
999 #[usage(
1000 env = "PITCHFORK_WATCH_INTERVAL",
1001 deprecated_env("PITCHFORK_WATCH_INTERVAL_MS"),
1002 default = "10s",
1003 ty = "duration"
1004 )]
1005 pub watch_interval: String,
1006
1007 /// Polling watcher filesystem scan interval
1008 ///
1009 /// How often polling-based file watchers scan for changes.
1010 ///
1011 /// This applies when daemon `watch_mode` is `poll`, or when `watch_mode = "auto"`
1012 /// falls back to polling because native watchers are unavailable.
1013 ///
1014 /// Lower values detect changes faster but use more CPU and I/O.
1015 /// `"100ms"` is useful for highly interactive workflows;
1016 /// `"500ms"` is a practical default for remote/networked filesystems.
1017 #[usage(
1018 env = "PITCHFORK_WATCH_POLL_INTERVAL",
1019 default = "500ms",
1020 ty = "duration"
1021 )]
1022 pub watch_poll_interval: String,
1023}
1024
1025/// The `tui.*` (terminal UI) settings.
1026#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
1027#[usage(prefix = "tui")]
1028pub struct SettingsTui {
1029 /// Status message display duration
1030 ///
1031 /// How long status messages (like "Daemon started") remain visible
1032 /// in the TUI before automatically clearing.
1033 #[usage(
1034 env = "PITCHFORK_TUI_MESSAGE_DURATION",
1035 default = "3s",
1036 ty = "duration"
1037 )]
1038 pub message_duration: String,
1039
1040 /// Daemon list refresh interval
1041 ///
1042 /// How often the TUI refreshes the daemon list and status information.
1043 ///
1044 /// Lower values provide more responsive updates but may increase CPU usage,
1045 /// especially with many daemons.
1046 ///
1047 /// **Recommended values:**
1048 /// - `"1s"` - More responsive
1049 /// - `"2s"` - Default, balanced
1050 /// - `"5s"` - Lower resource usage
1051 #[usage(env = "PITCHFORK_TUI_REFRESH_RATE", default = "2s", ty = "duration")]
1052 pub refresh_rate: String,
1053
1054 /// Number of stat samples to keep for graphs
1055 ///
1056 /// How many CPU/memory stat samples to keep for each daemon's graph.
1057 /// With the default refresh rate of 2s, 60 samples = ~2 minutes of history.
1058 ///
1059 /// Increase for longer history in graphs, at the cost of more memory.
1060 #[usage(env = "PITCHFORK_TUI_STAT_HISTORY", default = 60)]
1061 pub stat_history: i64,
1062
1063 /// Event loop tick rate
1064 ///
1065 /// How often the TUI checks for keyboard input and other events.
1066 /// This affects input responsiveness.
1067 ///
1068 /// Most users won't need to change this. Lower values make the UI more
1069 /// responsive but use more CPU.
1070 #[usage(env = "PITCHFORK_TUI_TICK_RATE", default = "100ms", ty = "duration")]
1071 pub tick_rate: String,
1072}
1073
1074/// The `web.*` (web UI) settings.
1075#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
1076#[usage(prefix = "web")]
1077pub struct SettingsWeb {
1078 /// Automatically start web UI when supervisor starts
1079 ///
1080 /// When enabled, the web UI server will automatically start alongside the supervisor.
1081 ///
1082 /// By default, this is disabled. You can also start the web UI manually with:
1083 /// ```text
1084 /// pitchfork supervisor start --web-port=3120
1085 /// ```
1086 #[usage(env = "PITCHFORK_WEB_AUTO_START", default = false)]
1087 pub auto_start: bool,
1088
1089 /// URL path prefix for the web UI (e.g. "ps" serves at /ps/)
1090 ///
1091 /// Serves the web UI under a sub-path prefix, useful when running behind a reverse
1092 /// proxy that routes a path prefix to pitchfork.
1093 ///
1094 /// **Examples:**
1095 /// - `""` - Serve at root `/` (default)
1096 /// - `"ps"` - Serve at `/ps/`
1097 /// - `"tools/pitchfork"` - Serve at `/tools/pitchfork/`
1098 ///
1099 /// Equivalent to the `--web-path` CLI flag. The CLI flag takes priority over this setting.
1100 #[usage(env = "PITCHFORK_WEB_PATH", default = "")]
1101 pub base_path: String,
1102
1103 /// Web server bind address
1104 ///
1105 /// IP address for the web UI to listen on.
1106 ///
1107 /// **Security Warning:** The default `127.0.0.1` only allows local connections.
1108 /// Setting this to `0.0.0.0` will expose the web UI to your network.
1109 ///
1110 /// **Examples:**
1111 /// - `"127.0.0.1"` - Local only (default, recommended)
1112 /// - `"0.0.0.0"` - All interfaces (use with caution)
1113 /// - `"192.168.1.100"` - Specific interface
1114 #[usage(env = "PITCHFORK_WEB_BIND_ADDRESS", default = "127.0.0.1")]
1115 pub bind_address: String,
1116
1117 /// Default web server port
1118 ///
1119 /// The port number for the web UI. If this port is in use, pitchfork will
1120 /// try subsequent ports up to `port_attempts` times.
1121 #[usage(env = "PITCHFORK_WEB_BIND_PORT", default = 3120)]
1122 pub bind_port: i64,
1123
1124 /// Initial number of log lines to display
1125 ///
1126 /// How many lines of logs to show initially when viewing daemon logs in the web UI.
1127 /// More lines means slower initial load but more history visible.
1128 #[usage(env = "PITCHFORK_WEB_LOG_LINES", default = 100)]
1129 pub log_lines: i64,
1130
1131 /// Number of ports to try if default is in use
1132 ///
1133 /// If the default port is occupied, pitchfork will try this many consecutive
1134 /// ports before giving up.
1135 ///
1136 /// For example, with `bind_port = 3120` and `port_attempts = 10`,
1137 /// it will try ports 3120, 3121, 3122, ... up to 3129.
1138 #[usage(env = "PITCHFORK_WEB_PORT_ATTEMPTS", default = 10)]
1139 pub port_attempts: i64,
1140
1141 /// Server-Sent Events poll interval for log streaming
1142 ///
1143 /// How often the web UI checks for new log lines when streaming logs.
1144 /// Lower values provide more real-time updates but use more resources.
1145 #[usage(
1146 env = "PITCHFORK_WEB_SSE_POLL_INTERVAL",
1147 default = "500ms",
1148 ty = "duration"
1149 )]
1150 pub sse_poll_interval: String,
1151}
1152
1153/// Every setting pitchfork has.
1154///
1155/// `SETTINGS_PROPS`, `SETTINGS_REGISTRY`, `read`, and `spec_kdl` are generated
1156/// from these fields; the CLI's emitted spec carries the `config` block
1157/// through `#[usage(config = ...)]` on the root.
1158#[derive(usage_rs::Config, Debug, Clone, PartialEq)]
1159// `file(...)` is intentionally repeatable in usage's configuration DSL.
1160#[allow(clippy::duplicated_attributes)]
1161#[usage(
1162 file(path = "/etc/pitchfork/config.toml", scope = "system", format = "toml"),
1163 file(
1164 path = "~/.config/pitchfork/config.toml",
1165 scope = "global",
1166 format = "toml"
1167 ),
1168 file(
1169 path = ".config/pitchfork.toml",
1170 findup,
1171 scope = "project",
1172 format = "toml"
1173 ),
1174 file(
1175 path = ".config/pitchfork.local.toml",
1176 findup,
1177 scope = "project",
1178 format = "toml"
1179 ),
1180 file(path = "pitchfork.toml", findup, scope = "project", format = "toml"),
1181 file(
1182 path = "pitchfork.local.toml",
1183 findup,
1184 scope = "project",
1185 format = "toml"
1186 )
1187)]
1188pub struct Settings {
1189 #[usage(flatten)]
1190 pub api: SettingsApi,
1191 #[usage(flatten)]
1192 pub general: SettingsGeneral,
1193 #[usage(flatten)]
1194 pub ipc: SettingsIpc,
1195 #[usage(flatten)]
1196 pub logs: SettingsLogs,
1197 #[usage(flatten)]
1198 pub proxy: SettingsProxy,
1199 #[usage(flatten)]
1200 pub supervisor: SettingsSupervisor,
1201 #[usage(flatten)]
1202 pub tui: SettingsTui,
1203 #[usage(flatten)]
1204 pub web: SettingsWeb,
1205}
1206
1207impl Default for Settings {
1208 /// The declared defaults, read the same way any other resolution is.
1209 fn default() -> Self {
1210 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new())
1211 .expect("no layers were given, so there is nothing to fail");
1212 Settings::read(&resolved).expect("every pitchfork setting has a declared default")
1213 }
1214}
1215
1216/// Generate the `Duration` convenience getters the rest of the codebase uses
1217/// (e.g. `settings().general_autostop_delay()`), each parsing its humantime
1218/// string field and silently falling back to the schema default. Invalid
1219/// values were already warned about once at load time.
1220macro_rules! duration_getters {
1221 ($($name:ident => $group:ident . $field:ident, $default:literal;)+) => {
1222 impl Settings {
1223 $(
1224 #[doc = concat!("Get `", stringify!($group), ".", stringify!($field), "` as Duration")]
1225 #[allow(dead_code)]
1226 pub fn $name(&self) -> std::time::Duration {
1227 Self::parse_duration(&self.$group.$field).unwrap_or_else(|| {
1228 humantime::parse_duration($default)
1229 .unwrap_or(std::time::Duration::from_secs(1))
1230 })
1231 }
1232 )+
1233 }
1234 };
1235}
1236
1237duration_getters! {
1238 general_autostop_delay => general.autostop_delay, "1m";
1239 general_interval => general.interval, "10s";
1240 general_ready_delay => general.ready_delay, "3s";
1241 ipc_connect_max_delay => ipc.connect_max_delay, "1s";
1242 ipc_connect_min_delay => ipc.connect_min_delay, "100ms";
1243 ipc_rate_limit_window => ipc.rate_limit_window, "1s";
1244 ipc_request_timeout => ipc.request_timeout, "5s";
1245 logs_time_retention => logs.time_retention, "";
1246 proxy_auto_start_timeout => proxy.auto_start_timeout, "30s";
1247 supervisor_cron_check_interval => supervisor.cron_check_interval, "10s";
1248 supervisor_file_watch_debounce => supervisor.file_watch_debounce, "1s";
1249 supervisor_health_check_interval => supervisor.health_check_interval, "10s";
1250 supervisor_health_cmd_timeout => supervisor.health_cmd_timeout, "10s";
1251 supervisor_health_http_timeout => supervisor.health_http_timeout, "5s";
1252 supervisor_health_port_timeout => supervisor.health_port_timeout, "5s";
1253 supervisor_http_client_timeout => supervisor.http_client_timeout, "5s";
1254 supervisor_log_flush_interval => supervisor.log_flush_interval, "500ms";
1255 supervisor_oneshot_timeout => supervisor.oneshot_timeout, "1h";
1256 supervisor_ready_check_interval => supervisor.ready_check_interval, "500ms";
1257 supervisor_restart_delay => supervisor.restart_delay, "100ms";
1258 supervisor_stop_timeout => supervisor.stop_timeout, "5s";
1259 supervisor_watch_interval => supervisor.watch_interval, "10s";
1260 supervisor_watch_poll_interval => supervisor.watch_poll_interval, "500ms";
1261 tui_message_duration => tui.message_duration, "3s";
1262 tui_refresh_rate => tui.refresh_rate, "2s";
1263 tui_tick_rate => tui.tick_rate, "100ms";
1264 web_sse_poll_interval => web.sse_poll_interval, "500ms";
1265}
1266
1267impl Settings {
1268 /// How long to wait for a `oneshot` daemon to finish.
1269 ///
1270 /// `supervisor.oneshot_timeout = "0"` means no limit: a task's natural end
1271 /// is its exit, and a long migration should not be called failed merely for
1272 /// outlasting a default. That is an absent deadline, not a very large one,
1273 /// so no task can outlive the substitute and be reported as timed out
1274 /// despite the setting promising otherwise.
1275 pub fn supervisor_oneshot_wait(&self) -> crate::config_types::OneshotWait {
1276 let configured = self.supervisor_oneshot_timeout();
1277 if configured.is_zero() {
1278 crate::config_types::OneshotWait::Unlimited
1279 } else {
1280 crate::config_types::OneshotWait::For(configured)
1281 }
1282 }
1283
1284 /// `proxy.idle_timeout` as a grace period, or `None` when idle shutdown
1285 /// is off (empty, zero, or invalid — invalid values were already warned
1286 /// about at load time).
1287 pub fn proxy_idle_timeout(&self) -> Option<std::time::Duration> {
1288 Self::parse_duration(self.proxy.idle_timeout.trim()).filter(|d| !d.is_zero())
1289 }
1290
1291 /// Resolve `general.ready_delay` as whole seconds.
1292 ///
1293 /// The ready-check pipeline (RunOptions/IPC/supervisor) models the delay
1294 /// in whole seconds. Reject subsecond values that would otherwise be
1295 /// silently truncated to a zero delay by `as_secs()`.
1296 pub fn general_ready_delay_secs(&self) -> Result<u64, String> {
1297 let delay = self.general_ready_delay();
1298 if delay.subsec_nanos() != 0 {
1299 return Err(format!(
1300 "settings.general.ready_delay must be a whole number of seconds, got \"{}\"",
1301 self.general.ready_delay
1302 ));
1303 }
1304 Ok(delay.as_secs())
1305 }
1306}
1307
1308impl Settings {
1309 /// Load settings from pitchfork.toml files, then overlay environment variables.
1310 /// Settings are loaded from all pitchfork.toml files in precedence order:
1311 /// 1. System-level: /etc/pitchfork/config.toml
1312 /// 2. User-level: ~/.config/pitchfork/config.toml
1313 /// 3. Project-level: pitchfork.toml files from root to current directory
1314 ///
1315 /// Environment variables override all file-based settings.
1316 ///
1317 /// The global [`settings()`] accessor resolves through
1318 /// [`Settings::resolve_from_dir`] so it can keep the provenance; this
1319 /// stays as the public one-shot entry point.
1320 #[allow(dead_code)]
1321 pub fn load() -> Self {
1322 Self::load_from_dir(&crate::env::CWD)
1323 }
1324
1325 /// Load settings for a specific project directory, then overlay environment variables.
1326 ///
1327 /// This is used when operating on daemons from registered namespaces whose project
1328 /// settings may differ from those of the invoking process's current directory.
1329 pub fn load_from_dir(start_dir: &Path) -> Self {
1330 Self::resolve_from_dir(start_dir).0
1331 }
1332
1333 /// Resolve settings for `start_dir`, returning both the typed struct and
1334 /// the origin-tracked resolution it was read from (for `pitchfork settings`).
1335 pub(crate) fn resolve_from_dir(start_dir: &Path) -> (Self, Resolved) {
1336 let env_layer = EnvLayer::from_process();
1337 // Highest precedence first, below the environment.
1338 let file_layers = Self::file_layers_from(start_dir);
1339 let mut layers = Layers::new().then(&env_layer);
1340 for layer in &file_layers {
1341 layers = layers.then(layer);
1342 }
1343 let mut resolved = match resolve(Settings::SETTINGS_REGISTRY, layers) {
1344 Ok(resolved) => resolved,
1345 Err(e) => {
1346 // Should not happen: every file was pre-validated above. Fall
1347 // back to env + defaults rather than failing to start.
1348 eprintln!("pitchfork: warning: failed to resolve settings from config files: {e}");
1349 resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env_layer)).unwrap_or_else(
1350 |_| {
1351 resolve(Settings::SETTINGS_REGISTRY, Layers::new())
1352 .expect("resolving only defaults cannot fail")
1353 },
1354 )
1355 }
1356 };
1357 sanitize_durations(&mut resolved);
1358 for warning in usage_rs::config::explain::warnings(&resolved) {
1359 warn!("{warning}");
1360 }
1361 // Lossy, because the alternative is worse than the problem: `read` is all or nothing,
1362 // so one field the merge could not hand to its type used to cost every *other* setting
1363 // too — `Settings::default()` throws away the environment and every config file over a
1364 // single bad value. Here the offending field falls back to its own declared default and
1365 // nothing else is touched. Warn and keep going is pitchfork's call to make: a
1366 // supervisor that refuses to start because one duration is misspelled is a supervisor
1367 // that took the whole machine's daemons down with it.
1368 let (settings, errors) = Settings::read_lossy(&resolved);
1369 for error in &errors.0 {
1370 warn!("{error}");
1371 }
1372 // Every setting declares a default, so the only way this is `None` is a setting added
1373 // without one — a hole in the declaration rather than anything a user did.
1374 let settings = settings.unwrap_or_else(|| {
1375 warn!("a setting has no value and no default; falling back to built-in defaults");
1376 Settings::default()
1377 });
1378 (settings, resolved)
1379 }
1380
1381 /// The config file layers for `start_dir`, highest precedence first.
1382 ///
1383 /// Project files are found by walking up from `start_dir`; within one
1384 /// directory `pitchfork.local.toml` outranks `pitchfork.toml`, which
1385 /// outranks the same pair under `.config/`, and any file in a nearer
1386 /// directory outranks every file in a farther one. Below the project
1387 /// files come the user-level and system-level configs.
1388 ///
1389 /// Files that exist but cannot be parsed are warned about and skipped,
1390 /// matching the previous loader: a broken file must not stop pitchfork
1391 /// from starting.
1392 fn file_layers_from(start_dir: &Path) -> Vec<FileLayer> {
1393 let mut candidates: Vec<(PathBuf, FileScope)> = xx::file::find_up_all(
1394 start_dir,
1395 &[
1396 "pitchfork.local.toml",
1397 "pitchfork.toml",
1398 ".config/pitchfork.local.toml",
1399 ".config/pitchfork.toml",
1400 ],
1401 )
1402 .into_iter()
1403 .map(|path| (path, FileScope::Project))
1404 .collect();
1405 let extras = crate::extra_configs::paths_for(start_dir)
1406 .into_iter()
1407 .rev()
1408 .map(|p| (p, FileScope::Project));
1409 candidates.splice(0..0, extras);
1410 candidates.push((
1411 crate::env::PITCHFORK_GLOBAL_CONFIG_USER.clone(),
1412 FileScope::Global,
1413 ));
1414 candidates.push((
1415 crate::env::PITCHFORK_GLOBAL_CONFIG_SYSTEM.clone(),
1416 FileScope::System,
1417 ));
1418 candidates
1419 .into_iter()
1420 .filter(|(path, _)| readable_settings_file(path))
1421 .map(|(path, scope)| FileLayer::at(path, scope).under("settings"))
1422 .collect()
1423 }
1424
1425 /// Parse a duration string (humantime format) to Duration
1426 pub fn parse_duration(s: &str) -> Option<std::time::Duration> {
1427 humantime::parse_duration(s).ok()
1428 }
1429
1430 /// Resolve the mise binary path.
1431 ///
1432 /// If `general.mise_bin` is explicitly set, returns that path.
1433 /// Otherwise, searches well-known install locations:
1434 /// - `~/.local/bin/mise`
1435 /// - `~/.cargo/bin/mise`
1436 /// - `/usr/local/bin/mise`
1437 /// - `/opt/homebrew/bin/mise`
1438 /// - on Windows, `mise.exe` on `PATH`
1439 ///
1440 /// Returns `None` if mise cannot be found.
1441 pub fn resolve_mise_bin(&self) -> Option<std::path::PathBuf> {
1442 self.resolve_mise_bin_with_path(std::env::var_os("PATH").as_deref())
1443 }
1444
1445 /// [`Self::resolve_mise_bin`] with `path` in place of the `PATH` variable.
1446 fn resolve_mise_bin_with_path(
1447 &self,
1448 path: Option<&std::ffi::OsStr>,
1449 ) -> Option<std::path::PathBuf> {
1450 // Explicit configuration takes priority
1451 if !self.general.mise_bin.is_empty() {
1452 let p = PathBuf::from(&self.general.mise_bin);
1453 if p.is_file() {
1454 return Some(p);
1455 }
1456 warn!(
1457 "mise_bin is set to {:?} but the file does not exist",
1458 self.general.mise_bin
1459 );
1460 return None;
1461 }
1462
1463 // Search well-known install paths
1464 let home = crate::env::HOME_DIR.as_path();
1465 let candidates = [
1466 home.join(".local/bin/mise"),
1467 home.join(".cargo/bin/mise"),
1468 PathBuf::from("/usr/local/bin/mise"),
1469 PathBuf::from("/opt/homebrew/bin/mise"),
1470 ];
1471
1472 candidates.into_iter().find(|p| p.is_file()).or_else(|| {
1473 // Windows package managers install mise to locations of their
1474 // own, none of them above, so look for it on PATH instead.
1475 if cfg!(windows) {
1476 find_in_path("mise.exe", path)
1477 } else {
1478 None
1479 }
1480 })
1481 }
1482
1483 /// Resolve the shell used for daemon `run` scripts, `ready_cmd` /
1484 /// `health_cmd` probes, lifecycle hooks and the log archive hook, split
1485 /// into a program and its arguments.
1486 ///
1487 /// `general.shell` applies everywhere. On Windows it applies only when it
1488 /// was set explicitly (`shell_is_explicit`); a Windows machine that leaves
1489 /// it at its default uses `general.windows_shell` instead, because the
1490 /// `sh` that `general.shell` defaults to is not on a stock Windows `PATH`.
1491 /// [`crate::settings::resolve_shell`] supplies that flag from the origin
1492 /// of the merged value; it is a parameter here so the precedence can be
1493 /// tested without a resolution.
1494 ///
1495 /// `Err` names the key whose value is empty or could not be split.
1496 pub fn resolve_shell(&self, shell_is_explicit: bool) -> Result<Vec<String>, String> {
1497 let (key, configured) = if cfg!(windows) && !shell_is_explicit {
1498 ("general.windows_shell", &self.general.windows_shell)
1499 } else {
1500 ("general.shell", &self.general.shell)
1501 };
1502 match shell_words::split(configured) {
1503 Ok(parts) if !parts.is_empty() => Ok(parts),
1504 Ok(_) => Err(format!("{key} setting is empty")),
1505 Err(e) => Err(format!("failed to parse {key} setting {configured:?}: {e}")),
1506 }
1507 }
1508
1509 /// Return `supervisor.port_bump_attempts` as `u32`, clamping out-of-range
1510 /// values to the schema default (10) and zero to 1.
1511 ///
1512 /// This is the single source of truth for the fallback so that call-sites
1513 /// don't each duplicate the hardcoded `10`.
1514 pub fn default_port_bump_attempts(&self) -> u32 {
1515 let v = u32::try_from(self.supervisor.port_bump_attempts).unwrap_or_else(|_| {
1516 warn!(
1517 "supervisor.port_bump_attempts value {} is out of range (0-{}), clamping to 10",
1518 self.supervisor.port_bump_attempts,
1519 u32::MAX
1520 );
1521 10
1522 });
1523 if v == 0 {
1524 warn!("supervisor.port_bump_attempts is 0; defaulting to 1");
1525 1
1526 } else {
1527 v
1528 }
1529 }
1530}
1531
1532/// Whether `path` holds a settings file this loader can hand to a `FileLayer`.
1533///
1534/// Mirrors the previous loader's per-file error handling: a missing file is
1535/// the ordinary case, and a file that exists but cannot be read or parsed —
1536/// or whose `settings` key is not a table — is warned about and skipped
1537/// rather than turned into a startup failure.
1538fn readable_settings_file(path: &Path) -> bool {
1539 if !path.exists() {
1540 return false;
1541 }
1542 let content = match std::fs::read_to_string(path) {
1543 Ok(content) => content,
1544 Err(e) => {
1545 eprintln!(
1546 "pitchfork: warning: failed to read {}: {}",
1547 path.display(),
1548 e
1549 );
1550 return false;
1551 }
1552 };
1553 let table: toml::Table = match content.parse() {
1554 Ok(table) => table,
1555 Err(e) => {
1556 eprintln!(
1557 "pitchfork: warning: failed to parse {}: {}",
1558 path.display(),
1559 e
1560 );
1561 return false;
1562 }
1563 };
1564 match table.get("settings") {
1565 None => true,
1566 Some(toml::Value::Table(_)) => true,
1567 Some(_) => {
1568 eprintln!(
1569 "pitchfork: warning: invalid [settings] in {}: not a table",
1570 path.display()
1571 );
1572 false
1573 }
1574 }
1575}
1576
1577/// Validate every duration-typed setting in a resolution.
1578///
1579/// The registry's `duration` type stores the humantime text as-is, so an
1580/// invalid value would otherwise ride into the struct. This warns once and
1581/// rewrites the value to the schema default through [`Resolved::coerced`],
1582/// so `pitchfork settings get/list` and the typed getters all agree —
1583/// matching the previous loader, which rejected invalid durations at load
1584/// time. A value equal to the declared default is left alone (the
1585/// `logs.time_retention` default is the empty string, meaning "disabled").
1586/// Duration settings where `0s` is never meaningful: the value is passed
1587/// straight into `tokio::time::timeout` for a health probe, so a zero
1588/// timeout fails every probe immediately and would crash-kill a healthy
1589/// daemon after the retry threshold. Same defect family as the per-daemon
1590/// health timeout rejection in `config_types.rs` (`parse_positive_timeout`).
1591const NON_ZERO_DURATION_KEYS: &[&str] = &[
1592 "supervisor.health_cmd_timeout",
1593 "supervisor.health_http_timeout",
1594 "supervisor.health_port_timeout",
1595];
1596
1597fn sanitize_durations(resolved: &mut Resolved) {
1598 let registry = Settings::SETTINGS_REGISTRY;
1599 for id in registry.ids() {
1600 let meta = registry.get(id);
1601 if !matches!(meta.ty.inner(), Ty::Duration) {
1602 continue;
1603 }
1604 let Some(Value::String(text)) = resolved.get(id) else {
1605 continue;
1606 };
1607 let default_text = match meta.default {
1608 Some(Const::Str(s)) => s,
1609 _ => "",
1610 };
1611 if text == default_text {
1612 continue;
1613 }
1614 let reason = match humantime::parse_duration(text) {
1615 Err(_) => Some(format!("invalid duration {text:?}")),
1616 Ok(d) if d.is_zero() && NON_ZERO_DURATION_KEYS.contains(&meta.key) => Some(format!(
1617 "supervisor health probe timeout must be greater than 0, got {text:?}"
1618 )),
1619 Ok(_) => None,
1620 };
1621 let Some(reason) = reason else {
1622 continue;
1623 };
1624 let origin = resolved
1625 .origin(id)
1626 .map(|o| o.describe().to_string())
1627 .unwrap_or_default();
1628 warn!("{reason} (set by {origin}), using default");
1629 resolved.coerced(
1630 id,
1631 Value::String(default_text.to_string()),
1632 format!("{reason}; the default stands"),
1633 );
1634 }
1635}
1636
1637/// Global settings instance, kept beside the resolution it was read from
1638/// (RwLock<Arc> to support runtime reload).
1639type SettingsState = (Arc<Settings>, Arc<Resolved>);
1640static SETTINGS: std::sync::RwLock<Option<SettingsState>> = std::sync::RwLock::new(None);
1641
1642fn settings_state() -> SettingsState {
1643 // Fast path: if already initialized, clone the Arc pointers.
1644 {
1645 let lock = SETTINGS.read().unwrap();
1646 if let Some(state) = lock.as_ref() {
1647 return state.clone();
1648 }
1649 }
1650 // Slow path: initialize on first access
1651 let mut lock = SETTINGS.write().unwrap();
1652 if let Some(state) = lock.as_ref() {
1653 return state.clone();
1654 }
1655 let (settings, resolved) = Settings::resolve_from_dir(&crate::env::CWD);
1656 let state = (Arc::new(settings), Arc::new(resolved));
1657 *lock = Some(state.clone());
1658 state
1659}
1660
1661/// Get the global settings instance
1662pub fn settings() -> Arc<Settings> {
1663 settings_state().0
1664}
1665
1666/// The origin-tracked resolution behind [`settings()`], for the settings CLI.
1667pub(crate) fn settings_resolved() -> Arc<Resolved> {
1668 settings_state().1
1669}
1670
1671/// Whether `general.shell` was set by the user rather than left at its default.
1672///
1673/// Asked of the merge, not of the text, the same way `pitchfork settings`
1674/// decides what to mark as default: `shell = "sh -c"` written in a config file
1675/// is the same string as the default, and only its origin tells them apart.
1676fn shell_is_explicit(resolved: &Resolved) -> bool {
1677 resolved
1678 .origin_key("general.shell")
1679 .is_some_and(|origin| origin.kind != SourceKind::DEFAULTS)
1680}
1681
1682/// The file called `name` in the first directory of `path` (a `PATH`-style
1683/// list) that holds one, as an absolute path.
1684///
1685/// A relative entry is looked up from this process's working directory, but
1686/// the result is used from a daemon's, so it is made absolute here.
1687fn find_in_path(name: &str, path: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
1688 std::env::split_paths(path?)
1689 .map(|dir| dir.join(name))
1690 .find(|candidate| candidate.is_file())
1691 .and_then(|found| std::path::absolute(found).ok())
1692}
1693
1694/// Resolve the shell for daemon `run` scripts, command probes and hooks from
1695/// the global settings. See [`Settings::resolve_shell`].
1696pub fn resolve_shell() -> Result<Vec<String>, String> {
1697 let (settings, resolved) = settings_state();
1698 settings.resolve_shell(shell_is_explicit(&resolved))
1699}
1700
1701/// Reload settings from config files.
1702///
1703/// Called when the supervisor receives a ReloadConfig IPC request,
1704/// typically after `pitchfork settings set` modifies a config file.
1705///
1706/// Old Arc references remain valid until all holders drop them,
1707/// so no use-after-free can occur.
1708pub fn reload_settings() {
1709 let (settings, resolved) = Settings::resolve_from_dir(&crate::env::CWD);
1710 let mut lock = SETTINGS.write().unwrap();
1711 *lock = Some((Arc::new(settings), Arc::new(resolved)));
1712}
1713
1714// ============================================================================
1715// Partial (all-Option) mirror structs
1716//
1717// These exist for `pitchfork.toml` round-trips: `PitchforkToml` deserializes
1718// its `[settings]` table into `SettingsPartial`, merges per-file overrides,
1719// and serializes it back out (`pitchfork settings set`, `proxy add`, ...).
1720// Settings *resolution* no longer goes through these — usage-config's
1721// origin-tracked merge replaced the apply/merge chain.
1722// ============================================================================
1723
1724macro_rules! settings_partial {
1725 (
1726 $(#[$meta:meta])*
1727 $name:ident {
1728 $(@group $group_field:ident: $group_ty:ty,)*
1729 $($(#[$fdoc:meta])* $field:ident: $ty:ty,)*
1730 }
1731 ) => {
1732 $(#[$meta])*
1733 #[derive(
1734 Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
1735 )]
1736 #[serde(default)]
1737 pub struct $name {
1738 $(
1739 #[serde(default, skip_serializing_if = "is_empty_partial")]
1740 pub $group_field: $group_ty,
1741 )*
1742 $(
1743 $(#[$fdoc])*
1744 #[serde(skip_serializing_if = "Option::is_none", default)]
1745 pub $field: Option<$ty>,
1746 )*
1747 }
1748
1749 #[allow(dead_code)]
1750 impl $name {
1751 /// Whether any field (or nested group field) is set.
1752 pub fn has_any_set(&self) -> bool {
1753 false
1754 $(|| self.$group_field.has_any_set())*
1755 $(|| self.$field.is_some())*
1756 }
1757
1758 pub fn is_empty(&self) -> bool {
1759 !self.has_any_set()
1760 }
1761
1762 /// Merge another partial onto this one.
1763 /// All `Some` values in `other` override the corresponding values in `self`.
1764 pub fn merge_from(&mut self, other: &Self) {
1765 $(self.$group_field.merge_from(&other.$group_field);)*
1766 $(
1767 if other.$field.is_some() {
1768 self.$field = other.$field.clone();
1769 }
1770 )*
1771 }
1772 }
1773 };
1774}
1775
1776/// serde `skip_serializing_if` helper for nested partial groups.
1777fn is_empty_partial<T: HasAnySet>(v: &T) -> bool {
1778 !v.has_any_set()
1779}
1780
1781/// Internal trait so one serde skip helper serves every partial group.
1782trait HasAnySet {
1783 fn has_any_set(&self) -> bool;
1784}
1785
1786macro_rules! impl_has_any_set {
1787 ($($name:ident),+ $(,)?) => {
1788 $(impl HasAnySet for $name {
1789 fn has_any_set(&self) -> bool {
1790 $name::has_any_set(self)
1791 }
1792 })+
1793 };
1794}
1795
1796settings_partial! {
1797 /// Partial mirror of [`SettingsApi`].
1798 SettingsApiPartial {
1799 /// Automatically start the standalone API server
1800 auto_start: bool,
1801 /// IP address the API server binds to
1802 bind_address: String,
1803 /// Port the standalone API server listens on
1804 bind_port: i64,
1805 /// Number of consecutive ports to try if api.bind_port is in use
1806 port_attempts: i64,
1807 /// Authentication token for API access when bound to non-loopback addresses
1808 token: String,
1809 }
1810}
1811
1812settings_partial! {
1813 /// Partial mirror of [`SettingsGeneral`].
1814 SettingsGeneralPartial {
1815 /// Delay before auto-stopping daemons when leaving a directory
1816 autostop_delay: String,
1817 /// Supervisor background task refresh interval
1818 interval: String,
1819 /// File log level (trace, debug, info, warn, error)
1820 log_file_level: String,
1821 /// Console log level (trace, debug, info, warn, error)
1822 log_level: String,
1823 /// Wrap daemon commands with mise x -- globally
1824 mise: bool,
1825 /// Explicit path to the mise binary
1826 mise_bin: String,
1827 /// Shell command used to execute daemon run scripts
1828 shell: String,
1829 /// Shell command used to execute daemon run scripts on Windows
1830 windows_shell: String,
1831 /// Show timestamps in startup log output
1832 startup_log_timestamps: bool,
1833 /// Default readiness delay in seconds when a daemon has no ready check configured
1834 ready_delay: String,
1835 /// Enable git worktree / jj workspace auto-discovery
1836 worktree: bool,
1837 }
1838}
1839
1840settings_partial! {
1841 /// Partial mirror of [`SettingsIpc`].
1842 SettingsIpcPartial {
1843 /// Number of connection retry attempts
1844 connect_attempts: i64,
1845 /// Maximum delay between connection retries
1846 connect_max_delay: String,
1847 /// Minimum delay between connection retries
1848 connect_min_delay: String,
1849 /// Maximum IPC requests per second per connection
1850 rate_limit: i64,
1851 /// Rate limit sliding window duration
1852 rate_limit_window: String,
1853 /// Default timeout for IPC requests
1854 request_timeout: String,
1855 }
1856}
1857
1858settings_partial! {
1859 /// Partial mirror of [`SettingsLogsArchiveHook`].
1860 SettingsLogsArchiveHookPartial {
1861 /// Maximum log entries per archive hook invocation
1862 batch_size: i64,
1863 /// Command to run before retention deletes old log entries
1864 command: String,
1865 }
1866}
1867
1868settings_partial! {
1869 /// Partial mirror of [`SettingsLogs`].
1870 SettingsLogsPartial {
1871 @group archive_hook: SettingsLogsArchiveHookPartial,
1872 /// Count-based log retention (e.g. 10000)
1873 line_retention: i64,
1874 /// Default log format for daemons (json | logfmt | text)
1875 log_format: String,
1876 /// Time-based log retention duration (e.g. '7d', '30d')
1877 time_retention: String,
1878 /// Show timestamps in log output
1879 timestamp: bool,
1880 /// strftime format for log timestamps in `pitchfork logs` output
1881 timestamp_format: String,
1882 }
1883}
1884
1885settings_partial! {
1886 /// Partial mirror of [`SettingsProxy`].
1887 SettingsProxyPartial {
1888 /// Automatically start daemons when accessed via proxy URL
1889 auto_start: bool,
1890 /// Maximum time to wait for an auto-started daemon to become ready
1891 auto_start_timeout: String,
1892 /// Automatically install the proxy TLS certificate into the system trust store
1893 auto_trust: bool,
1894 /// Run a loopback DNS resolver for the proxy TLD
1895 dns: bool,
1896 /// Port the loopback DNS resolver listens on
1897 dns_port: i64,
1898 /// Enable the reverse proxy server for daemons
1899 enable: bool,
1900 /// Bind address for the reverse proxy server
1901 host: String,
1902 /// Enable HTTPS for the reverse proxy
1903 https: bool,
1904 /// Stop proxy-started daemons after this long without proxy activity
1905 idle_timeout: String,
1906 /// Enable LAN mode for the reverse proxy
1907 lan: bool,
1908 /// Pin a specific LAN IP address instead of auto-detecting
1909 lan_ip: String,
1910 /// Port the reverse proxy server listens on
1911 port: i64,
1912 /// Automatically sync slug hostnames to /etc/hosts (deprecated)
1913 sync_hosts: bool,
1914 /// Top-level domain used for proxy URLs
1915 tld: String,
1916 /// Path to TLS certificate file (PEM format) for HTTPS proxy
1917 tls_cert: String,
1918 /// Path to TLS private key file (PEM format) for HTTPS proxy
1919 tls_key: String,
1920 /// Enable wildcard subdomain matching for proxy routes
1921 wildcard: bool,
1922 /// Compatibility alias for general.worktree
1923 worktree: bool,
1924 }
1925}
1926
1927settings_partial! {
1928 /// Partial mirror of [`SettingsSupervisor`].
1929 SettingsSupervisorPartial {
1930 /// Automatically start the supervisor when a client command needs it
1931 auto_start: bool,
1932 /// Reconcile orphaned daemon processes when supervisor starts
1933 cleanup_orphans: bool,
1934 /// Enable container/PID1 mode for running inside Docker containers
1935 container: bool,
1936 /// Consecutive CPU-over-limit samples before killing a daemon
1937 cpu_violation_threshold: i64,
1938 /// Interval for checking cron schedules
1939 cron_check_interval: String,
1940 /// File watch debounce duration
1941 file_watch_debounce: String,
1942 /// Default time between health probes
1943 health_check_interval: String,
1944 /// Default consecutive health-check failures before killing a daemon
1945 health_check_retries: i64,
1946 /// Default per-probe timeout for health_cmd
1947 health_cmd_timeout: String,
1948 /// Default per-request timeout for health_http
1949 health_http_timeout: String,
1950 /// Default per-connect timeout for health_port
1951 health_port_timeout: String,
1952 /// Timeout for HTTP ready checks
1953 http_client_timeout: String,
1954 /// Daemon log buffer flush interval
1955 log_flush_interval: String,
1956 /// What to do with live orphaned daemons on supervisor startup: adopt or kill
1957 orphan_policy: String,
1958 /// Maximum port increment attempts when auto_bump_port is enabled
1959 port_bump_attempts: i64,
1960 /// Maximum time to wait for a oneshot daemon to finish
1961 oneshot_timeout: String,
1962 /// Interval between ready checks (HTTP, TCP, command)
1963 ready_check_interval: String,
1964 /// Delay between stop and start during restart
1965 restart_delay: String,
1966 /// Maximum time to wait for daemon to stop gracefully
1967 stop_timeout: String,
1968 /// Default user to run daemon processes as
1969 user: String,
1970 /// File watcher config refresh interval
1971 watch_interval: String,
1972 /// Polling watcher filesystem scan interval
1973 watch_poll_interval: String,
1974 }
1975}
1976
1977settings_partial! {
1978 /// Partial mirror of [`SettingsTui`].
1979 SettingsTuiPartial {
1980 /// Status message display duration
1981 message_duration: String,
1982 /// Daemon list refresh interval
1983 refresh_rate: String,
1984 /// Number of stat samples to keep for graphs
1985 stat_history: i64,
1986 /// Event loop tick rate
1987 tick_rate: String,
1988 }
1989}
1990
1991settings_partial! {
1992 /// Partial mirror of [`SettingsWeb`].
1993 SettingsWebPartial {
1994 /// Automatically start web UI when supervisor starts
1995 auto_start: bool,
1996 /// URL path prefix for the web UI (e.g. "ps" serves at /ps/)
1997 base_path: String,
1998 /// Web server bind address
1999 bind_address: String,
2000 /// Default web server port
2001 bind_port: i64,
2002 /// Initial number of log lines to display
2003 log_lines: i64,
2004 /// Number of ports to try if default is in use
2005 port_attempts: i64,
2006 /// Server-Sent Events poll interval for log streaming
2007 sse_poll_interval: String,
2008 }
2009}
2010
2011settings_partial! {
2012 /// Partial mirror of [`Settings`], for `[settings]` tables in
2013 /// pitchfork.toml files.
2014 SettingsPartial {
2015 @group api: SettingsApiPartial,
2016 @group general: SettingsGeneralPartial,
2017 @group ipc: SettingsIpcPartial,
2018 @group logs: SettingsLogsPartial,
2019 @group proxy: SettingsProxyPartial,
2020 @group supervisor: SettingsSupervisorPartial,
2021 @group tui: SettingsTuiPartial,
2022 @group web: SettingsWebPartial,
2023 }
2024}
2025
2026impl_has_any_set!(
2027 SettingsApiPartial,
2028 SettingsGeneralPartial,
2029 SettingsIpcPartial,
2030 SettingsLogsArchiveHookPartial,
2031 SettingsLogsPartial,
2032 SettingsProxyPartial,
2033 SettingsSupervisorPartial,
2034 SettingsTuiPartial,
2035 SettingsWebPartial,
2036);
2037
2038impl SettingsPartial {
2039 /// Move supported legacy keys to their canonical locations before a
2040 /// read-modify-write cycle serializes this partial again.
2041 pub(crate) fn canonicalize_aliases(&mut self) {
2042 if self.general.worktree.is_none() {
2043 self.general.worktree = self.proxy.worktree.take();
2044 } else {
2045 self.proxy.worktree = None;
2046 }
2047 }
2048}
2049
2050#[cfg(test)]
2051mod tests {
2052 use super::*;
2053 use std::time::Duration;
2054
2055 #[test]
2056 fn find_in_path_takes_the_first_directory_that_has_the_file() {
2057 let empty = tempfile::tempdir().unwrap();
2058 let first = tempfile::tempdir().unwrap();
2059 let second = tempfile::tempdir().unwrap();
2060 std::fs::write(first.path().join("mise.exe"), "").unwrap();
2061 std::fs::write(second.path().join("mise.exe"), "").unwrap();
2062 // A directory with the name, not a file, does not count.
2063 std::fs::create_dir(empty.path().join("mise.exe")).unwrap();
2064
2065 let path = std::env::join_paths([empty.path(), first.path(), second.path()]).unwrap();
2066 assert_eq!(
2067 find_in_path("mise.exe", Some(&path)),
2068 Some(first.path().join("mise.exe"))
2069 );
2070 assert_eq!(find_in_path("missing.exe", Some(&path)), None);
2071 assert_eq!(find_in_path("mise.exe", None), None);
2072 }
2073
2074 #[cfg(windows)]
2075 #[test]
2076 fn resolve_mise_bin_finds_mise_exe_on_path_on_windows() {
2077 let dir = tempfile::tempdir().unwrap();
2078 let mise = dir.path().join("mise.exe");
2079 std::fs::write(&mise, "").unwrap();
2080 let path = std::env::join_paths([dir.path()]).unwrap();
2081
2082 // No `mise_bin`, and none of the Unix locations exist on Windows.
2083 let settings = Settings::default();
2084 assert_eq!(settings.resolve_mise_bin_with_path(Some(&path)), Some(mise));
2085 assert_eq!(settings.resolve_mise_bin_with_path(None), None);
2086 }
2087
2088 #[test]
2089 fn find_in_path_returns_an_absolute_path_for_a_relative_entry() {
2090 // A relative entry is found against the supervisor's working
2091 // directory, but the path is used from the daemon's, so it has to be
2092 // made absolute. Created in the working directory itself, which
2093 // always exists, unlike a build directory Cargo may put elsewhere.
2094 let dir = tempfile::tempdir_in(".").unwrap();
2095 std::fs::write(dir.path().join("mise.exe"), "").unwrap();
2096 let relative = dir
2097 .path()
2098 .strip_prefix(std::env::current_dir().unwrap())
2099 .unwrap_or(dir.path());
2100 assert!(relative.is_relative(), "{relative:?}");
2101
2102 let found = find_in_path("mise.exe", Some(relative.as_os_str())).unwrap();
2103 assert!(found.is_absolute(), "{found:?}");
2104 assert!(found.is_file());
2105 }
2106
2107 #[test]
2108 fn shell_defaults() {
2109 let settings = Settings::default();
2110 assert_eq!(settings.general.shell, "sh -c");
2111 assert_eq!(settings.general.windows_shell, "cmd /C");
2112 }
2113
2114 #[test]
2115 fn shell_precedence() {
2116 let mut settings = Settings::default();
2117 settings.general.shell = "unix-sh -c".to_string();
2118 settings.general.windows_shell = "win-sh /C".to_string();
2119 let unix = vec!["unix-sh".to_string(), "-c".to_string()];
2120 let windows = vec!["win-sh".to_string(), "/C".to_string()];
2121
2122 // An explicit general.shell wins on every platform.
2123 assert_eq!(settings.resolve_shell(true).unwrap(), unix);
2124
2125 // Left at its default, general.shell yields to windows_shell on
2126 // Windows and is the only setting consulted elsewhere.
2127 let expected = if cfg!(windows) { windows } else { unix };
2128 assert_eq!(settings.resolve_shell(false).unwrap(), expected);
2129 }
2130
2131 #[test]
2132 fn shell_is_explicit_comes_from_the_origin_not_the_text() {
2133 // `PITCHFORK_SHELL="sh -c"` is the same string as the default, and
2134 // must still count as set: on Windows it decides whether
2135 // windows_shell is consulted at all.
2136 let untouched =
2137 resolve(Settings::SETTINGS_REGISTRY, Layers::new()).expect("resolving defaults");
2138 assert!(!shell_is_explicit(&untouched));
2139
2140 let layer = EnvLayer::new([("PITCHFORK_SHELL".to_string(), "sh -c".to_string())]);
2141 let explicit = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer))
2142 .expect("resolving an override");
2143 assert!(shell_is_explicit(&explicit));
2144 }
2145
2146 #[test]
2147 fn an_unusable_shell_names_the_key_it_came_from() {
2148 let mut settings = Settings::default();
2149 settings.general.shell = "sh -c 'unbalanced".to_string();
2150 let err = settings.resolve_shell(true).unwrap_err();
2151 assert!(err.contains("general.shell"), "{err}");
2152
2153 settings.general.shell = String::new();
2154 let err = settings.resolve_shell(true).unwrap_err();
2155 assert!(err.contains("general.shell setting is empty"), "{err}");
2156
2157 settings.general.windows_shell = String::new();
2158 let key = if cfg!(windows) {
2159 "general.windows_shell"
2160 } else {
2161 "general.shell"
2162 };
2163 let err = settings.resolve_shell(false).unwrap_err();
2164 assert!(err.contains(key), "{err}");
2165 }
2166
2167 #[test]
2168 fn test_default_settings() {
2169 let settings = Settings::default();
2170
2171 // Test general settings
2172 assert_eq!(settings.general.autostop_delay, "1m");
2173 assert_eq!(settings.general.interval, "10s");
2174 assert_eq!(settings.general.log_level, "info");
2175 assert_eq!(settings.general.ready_delay, "3s");
2176
2177 // Test IPC settings
2178 assert_eq!(settings.ipc.connect_attempts, 5);
2179 assert_eq!(settings.ipc.request_timeout, "5s");
2180 assert_eq!(settings.ipc.rate_limit, 100);
2181
2182 // Test web settings
2183 assert!(!settings.web.auto_start);
2184 assert_eq!(settings.web.bind_address, "127.0.0.1");
2185 assert_eq!(settings.web.bind_port, 3120);
2186 assert_eq!(settings.web.log_lines, 100);
2187
2188 // Test TUI settings
2189 assert_eq!(settings.tui.refresh_rate, "2s");
2190 assert_eq!(settings.tui.stat_history, 60);
2191
2192 // Test supervisor settings
2193 assert!(settings.supervisor.auto_start);
2194 assert_eq!(settings.supervisor.ready_check_interval, "500ms");
2195 assert_eq!(settings.supervisor.file_watch_debounce, "1s");
2196 assert_eq!(settings.supervisor.user, "");
2197 }
2198
2199 #[test]
2200 fn a_bad_value_costs_only_its_own_setting() {
2201 // `Settings::read` is all or nothing, and the fallback for its failure used to be
2202 // `Settings::default()` — one field the merge could not hand to its type demoting all
2203 // sixty-eight settings and discarding the environment along with them.
2204 let env = EnvLayer::new([("PITCHFORK_LOG".to_string(), "debug".to_string())]);
2205 let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2206
2207 // The one door into a resolution the merge does not check: a post-merge hook, which is
2208 // what `sanitize_durations` is. A bool where an integer belongs is what a buggy one
2209 // could write.
2210 let registry = Settings::SETTINGS_REGISTRY;
2211 let rate_limit = registry.lookup("ipc.rate_limit").unwrap().id;
2212 resolved.coerced(rate_limit, Value::Bool(true), "a hook that got it wrong");
2213
2214 assert!(
2215 Settings::read(&resolved).is_err(),
2216 "the strict read still refuses it"
2217 );
2218
2219 let (settings, errors) = Settings::read_lossy(&resolved);
2220 let settings = settings.expect("every setting declares a default");
2221 assert_eq!(
2222 settings.ipc.rate_limit, 100,
2223 "the bad field takes its declared default"
2224 );
2225 assert_eq!(
2226 settings.general.log_level, "debug",
2227 "and PITCHFORK_LOG is not collateral damage"
2228 );
2229 assert_eq!(errors.0.len(), 1, "{errors}");
2230 assert_eq!(errors.0[0].key, "ipc.rate_limit");
2231 }
2232
2233 #[test]
2234 fn every_setting_declares_a_default() {
2235 // What makes the `None` arm of the lossy read unreachable: a setting with no value and
2236 // no default is a hole in this file, not something a user can cause. Adding one without
2237 // a default should fail here rather than silently demote the whole struct at runtime.
2238 let missing: Vec<&str> = Settings::SETTINGS_PROPS
2239 .iter()
2240 .filter(|meta| meta.default.is_none())
2241 .map(|meta| meta.key)
2242 .collect();
2243 assert!(
2244 missing.is_empty(),
2245 "these settings declare no default: {missing:?}"
2246 );
2247 }
2248
2249 #[test]
2250 fn test_registry_matches_previous_schema() {
2251 // The emitted config block is the settings documentation now; make
2252 // sure the registry still declares what settings.toml used to.
2253 let keys: Vec<&str> = Settings::SETTINGS_PROPS
2254 .iter()
2255 .map(|meta| meta.key)
2256 .collect();
2257 // 75 before either change, plus `supervisor.oneshot_timeout` from main,
2258 // `proxy.dns` / `proxy.dns_port`, and `proxy.idle_timeout`.
2259 assert_eq!(keys.len(), 79, "{keys:?}");
2260 assert!(keys.contains(&"general.autostop_delay"));
2261 assert!(keys.contains(&"supervisor.oneshot_timeout"));
2262 assert!(keys.contains(&"logs.archive_hook.command"));
2263 assert!(keys.contains(&"supervisor.health_check_interval"));
2264 assert!(keys.contains(&"supervisor.watch_interval"));
2265 assert!(keys.contains(&"proxy.dns"));
2266 assert!(keys.contains(&"proxy.dns_port"));
2267 assert!(keys.contains(&"proxy.idle_timeout"));
2268
2269 let registry = Settings::SETTINGS_REGISTRY;
2270 let interval = registry.get(registry.lookup("general.interval").unwrap().id);
2271 assert_eq!(interval.envs, &["PITCHFORK_INTERVAL"]);
2272 assert_eq!(interval.deprecated_envs, &["PITCHFORK_INTERVAL_SECS"]);
2273 let watch = registry.get(registry.lookup("supervisor.watch_interval").unwrap().id);
2274 assert_eq!(watch.deprecated_envs, &["PITCHFORK_WATCH_INTERVAL_MS"]);
2275 let log_level = registry.get(registry.lookup("general.log_level").unwrap().id);
2276 assert_eq!(log_level.envs, &["PITCHFORK_LOG"]);
2277 let worktree = registry.get(registry.lookup("general.worktree").unwrap().id);
2278 assert_eq!(worktree.envs, &["PITCHFORK_WORKTREE"]);
2279 assert_eq!(worktree.deprecated_envs, &["PITCHFORK_PROXY_WORKTREE"]);
2280 assert_eq!(worktree.aliases, &["proxy.worktree"]);
2281 assert_eq!(
2282 registry.lookup("proxy.worktree").unwrap().id,
2283 registry.lookup("general.worktree").unwrap().id,
2284 );
2285
2286 let spec = Settings::spec_kdl();
2287 let files = [
2288 "file \"/etc/pitchfork/config.toml\" scope=\"system\" format=\"toml\"",
2289 "file \"~/.config/pitchfork/config.toml\" scope=\"global\" format=\"toml\"",
2290 "file \".config/pitchfork.toml\" findup=#true format=\"toml\"",
2291 "file \".config/pitchfork.local.toml\" findup=#true format=\"toml\"",
2292 "file \"pitchfork.toml\" findup=#true format=\"toml\"",
2293 "file \"pitchfork.local.toml\" findup=#true format=\"toml\"",
2294 ];
2295 let positions: Vec<usize> = files
2296 .iter()
2297 .map(|file| spec.find(file).unwrap_or_else(|| panic!("missing {file}")))
2298 .collect();
2299 assert!(
2300 positions.windows(2).all(|pair| pair[0] < pair[1]),
2301 "config files must be documented from lowest to highest precedence:\n{spec}"
2302 );
2303 }
2304
2305 #[test]
2306 fn every_registry_key_round_trips_through_the_partial() {
2307 // A setting added to the derive structs but missed in `settings_partial!`
2308 // makes `pitchfork settings set` report success and write nothing.
2309 for meta in Settings::SETTINGS_PROPS {
2310 let value = match meta.ty.inner() {
2311 Ty::Bool => toml::Value::Boolean(true),
2312 Ty::Int | Ty::Uint => toml::Value::Integer(1),
2313 _ => toml::Value::String("x".to_string()),
2314 };
2315 let parts: Vec<&str> = meta.key.split('.').collect();
2316 let mut table = toml::Table::new();
2317 let mut cursor = &mut table;
2318 for part in &parts[..parts.len() - 1] {
2319 cursor = cursor
2320 .entry(part.to_string())
2321 .or_insert_with(|| toml::Value::Table(toml::Table::new()))
2322 .as_table_mut()
2323 .unwrap();
2324 }
2325 cursor.insert(parts[parts.len() - 1].to_string(), value);
2326
2327 let partial: SettingsPartial = table.clone().try_into().unwrap();
2328 let back = toml::Table::try_from(&partial).unwrap();
2329 assert_eq!(
2330 back, table,
2331 "{} is declared in the registry but not mirrored in SettingsPartial",
2332 meta.key
2333 );
2334 }
2335 }
2336
2337 #[test]
2338 fn test_parse_duration() {
2339 assert_eq!(Settings::parse_duration("1s"), Some(Duration::from_secs(1)));
2340 assert_eq!(
2341 Settings::parse_duration("500ms"),
2342 Some(Duration::from_millis(500))
2343 );
2344 assert_eq!(
2345 Settings::parse_duration("1m"),
2346 Some(Duration::from_secs(60))
2347 );
2348 assert_eq!(
2349 Settings::parse_duration("2h"),
2350 Some(Duration::from_secs(7200))
2351 );
2352 assert_eq!(Settings::parse_duration("invalid"), None);
2353 }
2354
2355 #[test]
2356 fn test_env_override() {
2357 // The environment is described rather than reached for, so this test
2358 // does not mutate process env vars (the old test needed a mutex).
2359 let env = EnvLayer::new([
2360 ("PITCHFORK_AUTOSTOP_DELAY".to_string(), "10m".to_string()),
2361 ("PITCHFORK_INTERVAL".to_string(), "5s".to_string()),
2362 (
2363 "PITCHFORK_IPC_CONNECT_ATTEMPTS".to_string(),
2364 "20".to_string(),
2365 ),
2366 (
2367 "PITCHFORK_SUPERVISOR_AUTO_START".to_string(),
2368 "false".to_string(),
2369 ),
2370 ("PITCHFORK_WEB_AUTO_START".to_string(), "true".to_string()),
2371 ]);
2372 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2373 let settings = Settings::read(&resolved).unwrap();
2374
2375 assert_eq!(settings.general.autostop_delay, "10m");
2376 assert_eq!(settings.general.interval, "5s");
2377 assert_eq!(settings.ipc.connect_attempts, 20);
2378 assert!(!settings.supervisor.auto_start);
2379 assert!(settings.web.auto_start);
2380
2381 // Fields with no corresponding env var set remain at defaults
2382 assert_eq!(settings.general.log_level, "info");
2383 assert_eq!(settings.ipc.rate_limit, 100);
2384 }
2385
2386 #[test]
2387 fn test_deprecated_env_still_works_and_warns() {
2388 let env = EnvLayer::new([
2389 ("PITCHFORK_INTERVAL_SECS".to_string(), "30s".to_string()),
2390 ("PITCHFORK_PROXY_WORKTREE".to_string(), "false".to_string()),
2391 ]);
2392 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2393 let settings = Settings::read(&resolved).unwrap();
2394 assert_eq!(settings.general.interval, "30s");
2395 assert!(!settings.general.worktree);
2396 let warnings = usage_rs::config::explain::warnings(&resolved);
2397 assert!(
2398 warnings
2399 .iter()
2400 .any(|w| w.contains("PITCHFORK_INTERVAL_SECS is deprecated")),
2401 "{warnings:?}"
2402 );
2403 assert!(
2404 warnings
2405 .iter()
2406 .any(|w| w.contains("PITCHFORK_PROXY_WORKTREE is deprecated")),
2407 "{warnings:?}"
2408 );
2409
2410 // The current name wins over the deprecated alias when both are set.
2411 let env = EnvLayer::new([
2412 ("PITCHFORK_INTERVAL_SECS".to_string(), "30s".to_string()),
2413 ("PITCHFORK_INTERVAL".to_string(), "7s".to_string()),
2414 ("PITCHFORK_PROXY_WORKTREE".to_string(), "false".to_string()),
2415 ("PITCHFORK_WORKTREE".to_string(), "true".to_string()),
2416 ]);
2417 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2418 let settings = Settings::read(&resolved).unwrap();
2419 assert_eq!(settings.general.interval, "7s");
2420 assert!(settings.general.worktree);
2421 }
2422
2423 #[test]
2424 fn test_invalid_duration_from_env_falls_back_to_default() {
2425 // The previous loader rejected invalid durations at load time; the
2426 // sanitize pass reproduces that through Resolved::coerced.
2427 let env = EnvLayer::new([(
2428 "PITCHFORK_AUTOSTOP_DELAY".to_string(),
2429 "not_a_duration".to_string(),
2430 )]);
2431 let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2432 sanitize_durations(&mut resolved);
2433 let settings = Settings::read(&resolved).unwrap();
2434 assert_eq!(settings.general.autostop_delay, "1m");
2435 assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
2436 }
2437
2438 #[test]
2439 fn test_zero_health_timeout_from_env_falls_back_to_default() {
2440 // A zero health probe timeout goes straight into tokio::time::timeout
2441 // and fails every probe immediately; the sanitize pass falls back to
2442 // the schema default like any other invalid duration.
2443 let env = EnvLayer::new([
2444 ("PITCHFORK_HEALTH_CMD_TIMEOUT".to_string(), "0s".to_string()),
2445 (
2446 "PITCHFORK_HEALTH_HTTP_TIMEOUT".to_string(),
2447 "0s".to_string(),
2448 ),
2449 (
2450 "PITCHFORK_HEALTH_PORT_TIMEOUT".to_string(),
2451 "0s".to_string(),
2452 ),
2453 ]);
2454 let mut resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&env)).unwrap();
2455 sanitize_durations(&mut resolved);
2456 let settings = Settings::read(&resolved).unwrap();
2457 assert_eq!(settings.supervisor.health_cmd_timeout, "10s");
2458 assert_eq!(
2459 settings.supervisor_health_cmd_timeout(),
2460 Duration::from_secs(10)
2461 );
2462 assert_eq!(settings.supervisor.health_http_timeout, "5s");
2463 assert_eq!(settings.supervisor.health_port_timeout, "5s");
2464 }
2465
2466 #[test]
2467 fn test_invalid_duration_fallback() {
2468 let mut settings = Settings::default();
2469
2470 // Set invalid duration values
2471 settings.general.autostop_delay = "invalid".to_string();
2472 settings.general.interval = "not_a_duration".to_string();
2473
2474 // Convenience methods should fallback to default values
2475 assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60)); // default "1m"
2476 assert_eq!(settings.general_interval(), Duration::from_secs(10)); // default "10s"
2477 }
2478
2479 #[test]
2480 fn oneshot_wait_defaults_to_an_hour() {
2481 use crate::config_types::OneshotWait;
2482 let settings = Settings::default();
2483 assert_eq!(
2484 settings.supervisor_oneshot_timeout(),
2485 Duration::from_secs(3600)
2486 );
2487 assert_eq!(
2488 settings.supervisor_oneshot_wait(),
2489 OneshotWait::For(Duration::from_secs(3600))
2490 );
2491 }
2492
2493 #[test]
2494 fn oneshot_wait_of_zero_means_no_deadline_at_all() {
2495 // A task's natural end is its exit, so `0` has to read as "wait",
2496 // never as "give up immediately" — and never as a distant substitute
2497 // deadline that a long enough task could still outlive.
2498 use crate::config_types::OneshotWait;
2499 let mut settings = Settings::default();
2500 for value in ["0", "0s"] {
2501 settings.supervisor.oneshot_timeout = value.to_string();
2502 assert!(settings.supervisor_oneshot_timeout().is_zero());
2503 assert_eq!(
2504 settings.supervisor_oneshot_wait(),
2505 OneshotWait::Unlimited,
2506 "{value} should wait without a deadline"
2507 );
2508 assert_eq!(settings.supervisor_oneshot_wait().duration(), None);
2509 }
2510 }
2511
2512 #[test]
2513 fn oneshot_wait_honours_a_configured_value() {
2514 use crate::config_types::OneshotWait;
2515 let mut settings = Settings::default();
2516 settings.supervisor.oneshot_timeout = "6h".to_string();
2517 assert_eq!(
2518 settings.supervisor_oneshot_wait(),
2519 OneshotWait::For(Duration::from_secs(6 * 3600))
2520 );
2521 assert_eq!(
2522 settings.supervisor_oneshot_wait().duration(),
2523 Some(Duration::from_secs(6 * 3600))
2524 );
2525 }
2526
2527 #[test]
2528 fn test_duration_methods_all_fields() {
2529 let settings = Settings::default();
2530
2531 assert_eq!(settings.general_autostop_delay(), Duration::from_secs(60));
2532 assert_eq!(settings.general_interval(), Duration::from_secs(10));
2533 assert_eq!(settings.general_ready_delay(), Duration::from_secs(3));
2534 assert_eq!(settings.ipc_connect_min_delay(), Duration::from_millis(100));
2535 assert_eq!(settings.ipc_connect_max_delay(), Duration::from_secs(1));
2536 assert_eq!(settings.ipc_request_timeout(), Duration::from_secs(5));
2537 assert_eq!(settings.ipc_rate_limit_window(), Duration::from_secs(1));
2538 assert_eq!(settings.web_sse_poll_interval(), Duration::from_millis(500));
2539 assert_eq!(settings.tui_refresh_rate(), Duration::from_secs(2));
2540 assert_eq!(settings.tui_tick_rate(), Duration::from_millis(100));
2541 assert_eq!(settings.tui_message_duration(), Duration::from_secs(3));
2542 assert_eq!(
2543 settings.supervisor_ready_check_interval(),
2544 Duration::from_millis(500)
2545 );
2546 assert_eq!(
2547 settings.supervisor_file_watch_debounce(),
2548 Duration::from_secs(1)
2549 );
2550 assert_eq!(
2551 settings.supervisor_log_flush_interval(),
2552 Duration::from_millis(500)
2553 );
2554 assert_eq!(settings.supervisor_stop_timeout(), Duration::from_secs(5));
2555 assert_eq!(
2556 settings.supervisor_restart_delay(),
2557 Duration::from_millis(100)
2558 );
2559 assert_eq!(
2560 settings.supervisor_cron_check_interval(),
2561 Duration::from_secs(10)
2562 );
2563 assert_eq!(
2564 settings.supervisor_http_client_timeout(),
2565 Duration::from_secs(5)
2566 );
2567 }
2568
2569 #[test]
2570 fn test_general_ready_delay_secs() {
2571 let mut settings = Settings::default();
2572
2573 // Default "3s" resolves to whole seconds.
2574 assert_eq!(settings.general_ready_delay_secs(), Ok(3));
2575
2576 // Subsecond values are rejected, not silently truncated by as_secs().
2577 settings.general.ready_delay = "500ms".to_string();
2578 let err = settings.general_ready_delay_secs().unwrap_err();
2579 assert!(err.contains("500ms"), "unexpected error: {err}");
2580 assert!(
2581 err.contains("whole number of seconds"),
2582 "unexpected error: {err}"
2583 );
2584
2585 settings.general.ready_delay = "1.5s".to_string();
2586 let err = settings.general_ready_delay_secs().unwrap_err();
2587 assert!(err.contains("1.5s"), "unexpected error: {err}");
2588
2589 // Empty string falls back to the schema default "3s".
2590 settings.general.ready_delay = String::new();
2591 assert_eq!(settings.general_ready_delay_secs(), Ok(3));
2592
2593 // Whole-second values resolve as seconds.
2594 settings.general.ready_delay = "90s".to_string();
2595 assert_eq!(settings.general_ready_delay_secs(), Ok(90));
2596 }
2597
2598 /// A directory tree, cleaned up when the test ends.
2599 struct Tree(std::path::PathBuf);
2600
2601 impl Tree {
2602 fn new(name: &str) -> Self {
2603 let dir = std::env::temp_dir()
2604 .join(format!("pitchfork_settings_{}_{name}", std::process::id()));
2605 let _ = std::fs::remove_dir_all(&dir);
2606 std::fs::create_dir_all(&dir).unwrap();
2607 Self(dir)
2608 }
2609
2610 fn write(&self, rel: &str, text: &str) -> std::path::PathBuf {
2611 let path = self.0.join(rel);
2612 if let Some(parent) = path.parent() {
2613 std::fs::create_dir_all(parent).unwrap();
2614 }
2615 std::fs::write(&path, text).unwrap();
2616 path
2617 }
2618 }
2619
2620 impl Drop for Tree {
2621 fn drop(&mut self) {
2622 let _ = std::fs::remove_dir_all(&self.0);
2623 }
2624 }
2625
2626 #[test]
2627 fn test_settings_read_from_pitchfork_toml_settings_table() {
2628 let tree = Tree::new("table");
2629 let path = tree.write(
2630 "pitchfork.toml",
2631 r#"
2632[daemons.myapp]
2633run = "node server.js"
2634
2635[settings.general]
2636autostop_delay = "5m"
2637log_level = "debug"
2638
2639[settings.web]
2640auto_start = true
2641bind_port = 8080
2642
2643[settings.supervisor]
2644auto_start = false
2645user = "postgres"
2646"#,
2647 );
2648 let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2649 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2650 let settings = Settings::read(&resolved).unwrap();
2651
2652 assert_eq!(settings.general.autostop_delay, "5m");
2653 assert_eq!(settings.general.log_level, "debug");
2654 assert!(settings.web.auto_start);
2655 assert_eq!(settings.web.bind_port, 8080);
2656 assert!(!settings.supervisor.auto_start);
2657 assert_eq!(settings.supervisor.user, "postgres");
2658 // Unset fields fall back to defaults, and [daemons] is not a warning.
2659 assert_eq!(settings.general.interval, "10s");
2660 assert_eq!(settings.ipc.connect_attempts, 5);
2661 assert!(resolved.warnings.is_empty(), "{:?}", resolved.warnings);
2662 }
2663
2664 #[test]
2665 fn test_proxy_worktree_config_alias_still_works() {
2666 let tree = Tree::new("proxy_worktree_alias");
2667 let path = tree.write("pitchfork.toml", "[settings.proxy]\nworktree = false\n");
2668 let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2669 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2670 let settings = Settings::read(&resolved).unwrap();
2671
2672 assert!(!settings.general.worktree);
2673 assert_eq!(
2674 resolved.get(
2675 Settings::SETTINGS_REGISTRY
2676 .lookup("general.worktree")
2677 .unwrap()
2678 .id
2679 ),
2680 Some(&Value::Bool(false)),
2681 );
2682 }
2683
2684 #[test]
2685 fn test_explicit_default_value_in_higher_file_still_overrides() {
2686 // "Bug 5": the old merge_from skipped values equal to the default, so
2687 // a higher-precedence file explicitly setting log_level = "info"
2688 // could not override "warn" from a lower one. usage-config's
2689 // origin-tracked merge does this natively; pin it.
2690 let tree = Tree::new("bug5");
2691 let lower = tree.write("lower.toml", "[settings.general]\nlog_level = \"warn\"\n");
2692 let higher = tree.write("higher.toml", "[settings.general]\nlog_level = \"info\"\n");
2693 let lower = FileLayer::at(&lower, FileScope::Global).under("settings");
2694 let higher = FileLayer::at(&higher, FileScope::Project).under("settings");
2695 let resolved = resolve(
2696 Settings::SETTINGS_REGISTRY,
2697 Layers::new().then(&higher).then(&lower),
2698 )
2699 .unwrap();
2700 let settings = Settings::read(&resolved).unwrap();
2701 assert_eq!(settings.general.log_level, "info");
2702
2703 // And the provenance says the higher file set it, not the default.
2704 let id = Settings::SETTINGS_REGISTRY
2705 .lookup("general.log_level")
2706 .unwrap()
2707 .id;
2708 let origin = resolved.origin(id).unwrap().describe();
2709 assert!(origin.contains("higher.toml"), "{origin}");
2710 }
2711
2712 #[test]
2713 fn test_file_precedence_local_over_base_and_nearer_dir_wins() {
2714 let tree = Tree::new("precedence");
2715 tree.write("pitchfork.toml", "[settings.general]\ninterval = \"9s\"\n");
2716 tree.write(
2717 "pitchfork.local.toml",
2718 "[settings.general]\ninterval = \"2s\"\n",
2719 );
2720 tree.write(
2721 ".config/pitchfork.toml",
2722 "[settings.general]\ninterval = \"7s\"\n[settings.web]\nbind_port = 9999\n",
2723 );
2724 let layers = Settings::file_layers_from(&tree.0);
2725 let mut chain = Layers::new();
2726 for layer in &layers {
2727 chain = chain.then(layer);
2728 }
2729 let resolved = resolve(Settings::SETTINGS_REGISTRY, chain).unwrap();
2730 let settings = Settings::read(&resolved).unwrap();
2731 // pitchfork.local.toml > pitchfork.toml > .config/pitchfork.toml
2732 assert_eq!(settings.general.interval, "2s");
2733 // A key only the lowest file sets still applies.
2734 assert_eq!(settings.web.bind_port, 9999);
2735
2736 // A nested directory's file outranks everything above it.
2737 let deep = tree.0.join("a").join("b");
2738 std::fs::create_dir_all(&deep).unwrap();
2739 tree.write(
2740 "a/b/pitchfork.toml",
2741 "[settings.general]\ninterval = \"1s\"\n",
2742 );
2743 let layers = Settings::file_layers_from(&deep);
2744 let mut chain = Layers::new();
2745 for layer in &layers {
2746 chain = chain.then(layer);
2747 }
2748 let resolved = resolve(Settings::SETTINGS_REGISTRY, chain).unwrap();
2749 let settings = Settings::read(&resolved).unwrap();
2750 assert_eq!(settings.general.interval, "1s");
2751 }
2752
2753 #[test]
2754 fn test_broken_file_is_skipped_rather_than_fatal() {
2755 let tree = Tree::new("broken");
2756 let broken = tree.write("pitchfork.toml", "[invalid toml [[");
2757 // The broken file is skipped with a warning, so the resolution still
2758 // happens and produces the defaults.
2759 let layers = Settings::file_layers_from(&tree.0);
2760 assert!(
2761 layers.iter().all(|layer| !layer.paths().contains(&broken)),
2762 "the broken project file must not become a settings layer"
2763 );
2764 }
2765
2766 #[test]
2767 fn test_unknown_settings_keys_do_not_fail_the_load() {
2768 // Forward compatibility: a config written for a newer pitchfork must
2769 // still load. Unknown keys are warned about rather than fatal.
2770 let tree = Tree::new("unknown");
2771 let path = tree.write(
2772 "pitchfork.toml",
2773 "[settings.general]\nlog_level = \"debug\"\nfrom_the_future = true\n",
2774 );
2775 let layer = FileLayer::at(&path, FileScope::Project).under("settings");
2776 let resolved = resolve(Settings::SETTINGS_REGISTRY, Layers::new().then(&layer)).unwrap();
2777 let settings = Settings::read(&resolved).unwrap();
2778 assert_eq!(settings.general.log_level, "debug");
2779 assert_eq!(resolved.warnings.len(), 1, "{:?}", resolved.warnings);
2780 }
2781
2782 #[test]
2783 fn test_partial_merge_from() {
2784 let mut base = SettingsPartial::default();
2785 base.general.log_level = Some("warn".to_string());
2786 base.web.bind_address = Some("0.0.0.0".to_string());
2787
2788 let mut overlay = SettingsPartial::default();
2789 overlay.general.log_level = Some("debug".to_string());
2790 overlay.tui.refresh_rate = Some("1s".to_string());
2791
2792 base.merge_from(&overlay);
2793 assert_eq!(base.general.log_level.as_deref(), Some("debug"));
2794 assert_eq!(base.web.bind_address.as_deref(), Some("0.0.0.0"));
2795 assert_eq!(base.tui.refresh_rate.as_deref(), Some("1s"));
2796
2797 // An empty partial changes nothing.
2798 let sealed = base.clone();
2799 base.merge_from(&SettingsPartial::default());
2800 assert_eq!(base.general.log_level, sealed.general.log_level);
2801 assert!(SettingsPartial::default().is_empty());
2802 assert!(base.has_any_set());
2803 }
2804
2805 #[test]
2806 fn test_partial_serialization_skips_unset() {
2807 let mut partial = SettingsPartial::default();
2808 partial.general.interval = Some("5s".to_string());
2809 let toml = toml::to_string_pretty(&partial).unwrap();
2810 assert_eq!(toml, "[general]\ninterval = \"5s\"\n");
2811 }
2812}