Initial code commit
CI / test (3.11) (push) Canceled after 0s
CI / test (3.12) (push) Canceled after 0s
CI / test (3.13) (push) Canceled after 0s
CI / test (3.14) (push) Canceled after 0s
CI / package (push) Canceled after 0s
CI / precommit-and-security (push) Canceled after 11s
CI / typecheck (push) Canceled after 11s

This commit is contained in:
2026-09-25 11:16:19 +01:00
parent 427daa7f1a
commit aded37d491
64 changed files with 8201 additions and 24 deletions
+127
View File
@@ -0,0 +1,127 @@
# Architecture
`schedls` is a thin CLI over two native scheduling backends. It holds no state
of its own: the scheduler configuration on the host is the source of truth.
```text
parse args
|
v
validate request
|
v
operation / service layer operations/
|
v
backend backends/systemd.py, backends/cron.py
|
v
result model models.py
|
v
formatter output.py
```
## Package layout
```text
src/schedls/
__init__.py version
__main__.py python -m schedls
cli.py argparse, dispatch, request building
errors.py exception hierarchy and exit codes
models.py common model (jobs, specs, schedules, commands)
output.py human tables, key/value views, JSON schema
runner.py the only place subprocesses are executed
security.py name validation, trusted paths, atomic writes
timefmt.py durations, timestamps, formatting
describe.py presentation-only schedule descriptions
convenience.py convenience flags -> native expressions
unitfile.py read-only systemd unit-file parser
interact.py confirmation handling
prompt.py opt-in interactive input collection (wizard)
backends/
base.py capability model, plans, mutation results
systemd.py discovery + transactional mutation
cron.py lossless crontab model + discovery + mutation
renderers/
systemd.py ExecStart quoting, unit rendering, ExecStart parsing
cron.py shell-safe command rendering, managed blocks
operations/
inspect.py collect, filter, list, show
calendar.py schedls calendar
doctor.py schedls doctor
mutate.py preview, confirm, apply
```
## Layers
- **Backends** implement discovery and mutation against native facilities and
never format output.
- **Renderers** are pure functions from an internal model to native text. They
are the most security-sensitive code and are covered by exhaustive tests.
- **Operations** orchestrate: gather, preview, confirm, apply.
- **CLI** translates arguments into a request, calls one operation, and maps
errors to exit codes. With `--interactive` it first fills missing fields via
**prompt** (line-based, terminal-only), then continues down the normal path.
- **Runner** is the single process-execution boundary. `shell=True` never
appears in production code.
## Mutation plans
Mutating commands do not write directly. A backend produces a `Plan`:
```text
Plan
action create | update | remove | enable | disable
summary key/value lines for the preview
files FileChange(path, content | None)
commands CommandPlan(argv, description, input_text)
warnings messages to show the user
payload backend-private state (scope, unit names, snapshots)
```
The caller previews the plan, obtains confirmation, then calls `apply`. Plans
snapshot existing content so a failed step can be rolled back. Cron plans also
record the previous crontab text and re-check it at apply time to detect
concurrent edits.
## Exit codes
```text
0 success
1 operational failure
2 invalid command-line input or invalid schedule
3 safety refusal / conflict
```
## JSON schema
`schedls --json` emits a single document on stdout; diagnostics go to stderr.
The schema version is `1`.
```json
{
"schema_version": 1,
"jobs": [
{
"name": "backup",
"backend": "systemd",
"scope": "user",
"managed": true,
"enabled": true,
"schedule": {"kind": "calendar", "expression": "*-*-* 02:00:00"},
"command": {"argv": ["/usr/local/bin/backup"], "shell": false, "raw": null},
"source": {"detail": "systemd user timer", "path": "...", "line": null},
"next_run": "2026-09-25T02:00:00+01:00",
"last_run": null,
"last_result": null,
"warnings": []
}
],
"warnings": []
}
```
Rules: no ANSI sequences; ISO 8601 timestamps with explicit offsets; stable
field names; missing data is `null`, never invented.
+283
View File
@@ -0,0 +1,283 @@
# Command-line reference
Complete reference for every `schedls` command, option and exit code. For
background on the backends see [systemd.md](systemd.md) and [cron.md](cron.md);
for the JSON document layout see [architecture.md](architecture.md#json-schema).
## Synopsis
```text
schedls [GLOBAL OPTIONS] [COMMAND] [COMMAND OPTIONS] [-- COMMAND ARGS...]
```
With no command, `schedls` behaves like `schedls list`.
## Global options
Global options may appear before the command. `--user`/`--system`,
`--backend`, `--managed`/`--unmanaged` and `--enabled`/`--disabled` filter what
`list` discovers and prints.
| Option | Meaning |
|-------------------------|---------------------------------------------------------------------|
| `--version` | print the version and exit |
| `--debug` | print helper commands and diagnostics on stderr |
| `--json` | emit a single JSON document on stdout (schema version 1) |
| `--color auto\|always\|never` | colorize human output (default `auto`; `NO_COLOR` disables) |
| `--utc` | display all times in UTC instead of local time |
| `--user` / `--system` | only show user-scope / system-scope jobs |
| `--backend systemd\|cron` | only show jobs from one backend |
| `--managed` / `--unmanaged` | only schedls-managed / only unmanaged jobs |
| `--enabled` / `--disabled` | only enabled / only disabled jobs |
`--json` is intended for scripts. It prints one document on stdout and sends
diagnostics to stderr; no ANSI escapes are emitted.
## Commands
### `list`
```console
$ schedls
$ schedls --system
$ schedls --backend cron
$ schedls --unmanaged --json
```
Lists visible scheduled jobs. Columns are `NAME`, `SCHEDULE`, `NEXT`, `BACKEND`,
`SCOPE`, `STATUS`. A missing next run is shown as `—`. Discovery warnings are
written to stderr as `Note:` lines.
### `show NAME`
```console
$ schedls show backup
```
Shows one job in detail: status, backend and scope, whether schedls manages it,
the schedule, the next and previous runs, the command, the source paths, and any
systemd extras (persistent, jitter, accuracy, working directory, environment).
### `new NAME`
```console
$ schedls new backup --timer --daily 02:00 --persistent -- /usr/local/bin/backup /srv/data
$ schedls new cleanup --cron --cron-expr '0 4 * * 0' -- /usr/local/bin/cleanup
$ schedls new backup -i
```
Creates a scheduled job. `NAME` and a backend (`--timer` or `--cron`) are
required unless `--interactive` is used.
The command to run is everything after `--`:
```console
$ schedls new backup --timer --daily 02:00 -- /usr/local/bin/backup "/srv/my data"
```
Arguments after `--` are passed literally as an argv; `schedls` does not
interpret `|`, `>`, `$()` or `$VAR`. Use `--shell SCRIPT` instead when you
explicitly want `/bin/sh -c`:
```console
$ schedls new rotate --timer --daily 03:00 --shell 'find /tmp -mtime +7 -delete'
```
`--shell` and a command after `--` are mutually exclusive.
### `edit NAME`
```console
$ schedls edit backup --daily 03:00
$ schedls edit backup --jitter 5min --no-persistent
$ schedls edit backup --command -- /usr/local/bin/backup /srv/data
$ schedls edit backup -i
```
Changes a job created by `schedls`. Only the options you pass are changed;
everything else keeps its current value.
- To replace the command, pass `--command` before the new command after `--`.
Passing `--` without `--command` is an error, so the command can never be
changed by accident.
- `--no-persistent` clears `Persistent=` on a systemd timer.
- Editing is supported for systemd timers only. Editing cron jobs is not yet
supported and reports a usage error.
### `rm NAME`
```console
$ schedls rm backup
$ schedls rm backup --dry-run
```
Removes a schedls-managed job and its files. Unmanaged jobs cannot be removed.
### `enable NAME` / `disable NAME`
```console
$ schedls enable backup
$ schedls disable backup
```
Enable or disable a systemd timer. Cron has no portable enabled/disabled
concept, so these commands explain that limitation and exit without changing
anything.
### `logs NAME`
```console
$ schedls logs backup
$ schedls logs backup --lines 200
$ schedls logs backup --since '2026-09-01' --lines 500
```
Shows recent journal output for a systemd timer's service unit. The command is
never re-executed; this only reads the journal through `journalctl --no-pager`.
| Option | Default | Meaning |
|----------------|---------|-------------------------------------------------------------------------|
| `--lines N` | `50` | number of recent journal entries to show (`journalctl --lines=N`); `N` must be between `1` and `1000000` |
| `--since TIME` | none | only entries since `TIME`, passed verbatim to `journalctl --since` |
`TIME` accepts anything `journalctl --since` accepts, such as
`'2026-09-01'`, `'2 hours ago'` or `'2026-09-01 10:00:00'`. `--since` bounds the
range and `--lines` caps how many entries within it are shown, so raise
`--lines` to see more of a longer history.
Journal output is untrusted program output. When stdout is a terminal, control
characters are escaped (`\x1b` and friends) so a log line cannot inject terminal
escapes; when output is redirected to a file or pipe the bytes are passed
through unchanged. With `--json`, the command emits a document instead of raw
text:
```json
{
"schema_version": 1,
"name": "backup",
"backend": "systemd",
"unit": "schedls-backup.service",
"content": "..."
}
```
Cron jobs have no portable per-job log interface. For a cron job, `schedls logs`
explains this (cron may mail output, redirect it, or write to the system log)
and exits `0`.
### `calendar EXPR`
```console
$ schedls calendar 'Mon..Fri 02:30'
$ schedls calendar --next 10 --utc daily
```
Validates a systemd `OnCalendar` expression with `systemd-analyze calendar` and
prints its normalized form and upcoming occurrences. Nothing is created.
| Option | Default | Meaning |
|-------------|---------|-------------------------------------|
| `--next N` | `5` | how many upcoming occurrences to show |
Requires `systemd-analyze`. Without it the command reports a missing dependency.
### `doctor`
```console
$ schedls doctor
```
Reports which native facilities are usable on this host: systemd manager
availability, `systemd-analyze`, user lingering, `crontab` availability, the
detected cron implementation, whether the current user may use cron, and
whether the local `crontab` supports syntax validation. It only inspects; it
never changes anything.
## Schedule options
`new` and `edit` accept the same schedule options. Convenience flags compile to
native syntax; see the per-backend tables in [systemd.md](systemd.md#calendar-expressions)
and [cron.md](cron.md#schedule-shortcuts).
| Option | Backend | Example |
|-------------------------|----------------|----------------------------------|
| `--calendar EXPR` | systemd only | `--calendar 'Mon..Fri 02:30'` |
| `--daily TIME` | both | `--daily 02:00` |
| `--weekdays TIME` | both | `--weekdays 08:30` |
| `--weekly DAY TIME` | both | `--weekly sun 04:00` |
| `--monthly DAY TIME` | both | `--monthly 15 06:00` |
| `--cron-expr EXPR` | cron only | `--cron-expr '0 4 * * 0'` |
| `--persistent` | systemd only | run events missed while powered off |
| `--jitter DURATION` | systemd only | `--jitter 5min` |
| `--accuracy DURATION` | systemd only | `--accuracy 1min` |
| `--working-directory PATH` | systemd only | `--working-directory /srv/data` |
| `--env KEY=VALUE` | systemd only | `--env TZ=UTC` (repeatable) |
| `--shell SCRIPT` | both | `--shell 'echo hi \| tee /tmp/log'` |
Notes:
- `--calendar` may be repeated; systemd supports multiple `OnCalendar=` entries.
- Do not combine `--calendar`/`--cron-expr` with the convenience flags; `schedls`
rejects mixing them rather than guessing which you meant.
- Backend-specific options are rejected for the wrong backend (for example
`--cron-expr` with `--timer`, or `--persistent` with `--cron`) instead of
being silently ignored.
- Cron accepts a single schedule.
- Cron environment variables are not supported yet; passing `--env` with
`--cron` is rejected rather than silently ignored.
- Cron jobs are always created for the current user. `--system` selects system
scope for systemd timers and is rejected for cron.
- `new --timer` defaults to user scope. System scope requires appropriate
privileges; `schedls` never runs `sudo` for you.
## Mutation options
These options apply to `new`, `edit`, `rm`, `enable` and `disable`:
| Option | Meaning |
|----------------|-------------------------------------------------------------------------|
| `--dry-run` | print the plan and exit without changing anything |
| `--yes` | do not ask for confirmation (for scripts) |
| `--show-files` | include the rendered file contents in the preview |
| `-i, --interactive` | collect missing fields with guided prompts (see below) |
Every mutation prints a preview of exactly what will be written (paths, and with
`--show-files` the contents) and asks for confirmation before applying. Without
a terminal, confirmation fails unless `--yes` is passed.
## Interactive mode
`new` and `edit` accept `-i`/`--interactive` to fill missing fields with guided,
line-based prompts. Provided flags act as pre-filled defaults and are not
re-asked.
```console
$ schedls new backup -i
$ schedls edit backup -i
```
- Prompts use stdin only; no external editor or pager is ever launched.
- A terminal is required. With piped or redirected input, `schedls` exits `3`
and asks you to pass flags instead.
- Interactive mode cannot be combined with `--json` (exit `2`).
- Collected values still go through the normal preview and confirmation, so
nothing is written without showing you the resolved schedule and files first.
## Exit codes
| Code | Meaning |
|------|--------------------------------------------------------------|
| `0` | success |
| `1` | operational failure (helper command, filesystem, discovery) |
| `2` | invalid command-line input or invalid schedule |
| `3` | safety refusal / conflict (unmanaged object, concurrency, no TTY for confirmation) |
## Environment
| Variable | Effect |
|------------|----------------------------------------------------|
| `NO_COLOR` | when set, disables color even with `--color auto` |
Helper commands run with a controlled environment (`LC_ALL=C`,
`SYSTEMD_COLORS=0`, `SYSTEMD_PAGER=cat`, `PAGER=cat`) and are never run through
a shell.
+90
View File
@@ -0,0 +1,90 @@
# cron backend
`schedls` reads and edits the **current user's crontab** through the `crontab`
utility. It never writes to `/var/spool/cron*` directly.
## Reading
The crontab is read with `crontab -l` and parsed losslessly into lines:
- blank lines;
- comments;
- environment assignments (`KEY=VALUE`);
- five-field entries and `@` nicknames;
- `schedls:begin` / `schedls:end` managed-block markers.
Every unrelated line is preserved exactly. `schedls` does not reformat, sort,
normalize, or rewrite crontab content it did not create.
## Managed blocks
Jobs created by `schedls` live in a delimited block:
```cron
# schedls:begin name=backup
0 2 * * * /usr/local/bin/backup /srv/data
# schedls:end name=backup
```
If markers are malformed or a `begin` has no matching `end`, mutations refuse
to proceed rather than guess.
## Command rendering
Cron executes command text through a shell. `schedls` keeps an argv internally
and serializes it safely:
1. each argument is quoted for `/bin/sh` (`shlex.quote`);
2. every `%` is escaped as `\%`, because cron implementations such as Cronie
treat an unescaped `%` as a newline.
Arguments containing NUL or newline are rejected, because no safe single-line
representation exists.
Raw shell syntax is available explicitly with `--shell`. `schedls` never infers
shell mode from characters such as `|`, `>`, `&&` or `$()`.
## Schedule shortcuts
| Flag | Example | Compiles to |
|-------------------|-----------------------|-------------------|
| `--daily TIME` | `--daily 02:00` | `0 2 * * *` |
| `--weekdays TIME` | `--weekdays 08:30` | `30 8 * * 1-5` |
| `--weekly D TIME` | `--weekly sun 04:00` | `0 4 * * 0` |
| `--monthly D TIME`| `--monthly 15 06:00` | `0 6 15 * *` |
| `--cron-expr EXPR`| `--cron-expr '0 4 * * 0'` | used verbatim |
## Installation and validation
When the local `crontab` supports syntax testing (`crontab -T`), the new
crontab is validated before installation. The native `crontab` install remains
authoritative. The previous crontab text is retained and restored if
installation fails or the crontab changed since the plan was prepared.
Capabilities are feature-detected, never assumed:
```python
CronCapabilities(
supports_validation=True,
supports_user_selection=False,
implementation="Cronie",
)
```
## Enable / disable
Cron has no universal native enabled/disabled concept. `schedls enable` and
`schedls disable` support systemd only and explain this limitation for cron.
## Logs
There is no portable per-job log interface for cron. `schedls logs` says so
rather than pretending otherwise. `--lines` and `--since` are accepted for
command-line compatibility but have no effect on cron jobs; see
[cli.md](cli.md#logs-name).
## Not yet supported
- system cron (`/etc/crontab`, `/etc/cron.d`, periodic directories);
- editing existing cron jobs;
- cross-user crontabs.
+110
View File
@@ -0,0 +1,110 @@
# Security model
This document describes what `schedls` trusts, what it does not, and how the
main threats are mitigated. It complements [SECURITY.md](../SECURITY.md).
## Trust model
- **Native schedulers are authoritative.** systemd and cron own execution.
`schedls` only reads and writes their configuration.
- **Discovered configuration is data.** Commands and schedules found on the
host are parsed and displayed, never executed.
- **Unmanaged objects are protected.** A timer or cron entry not created by
`schedls` cannot be modified or removed by default.
- **The user chooses privilege.** `schedls` never escalates on the user's
behalf.
## Threats and mitigations
### Malicious schedule names
Names must match `[A-Za-z0-9][A-Za-z0-9_.-]{0,63}`. Path separators, traversal,
whitespace and shell metacharacters are rejected. Names are never interpolated
into shell strings.
### Malicious command arguments
Arguments are kept as an argv internally. systemd units are rendered with a
dedicated serializer that always quotes arguments and escapes backslash,
double quotes, literal `$` and `%`; arguments containing NUL or newline are
rejected. cron commands are serialized with shell quoting and then have every
`%` escaped for cron's own parser. Calendar expressions are validated with
`systemd-analyze` and must not contain control characters or begin with `-`;
timer durations (`--jitter`, `--accuracy`) are validated with
`systemd-analyze timespan`. If a safe representation cannot be guaranteed,
creation is refused.
### Symlink attacks during privileged writes
Before replacing a file, `schedls` uses `lstat`, rejects symlinks and
non-regular files, and verifies ownership. Destination directories must be
real directories owned by the expected user (`root` when running as root) and
must not be group- or other-writable. Temporary files are created in the same
directory and atomically renamed.
### PATH hijacking
Critical helpers (`systemctl`, `systemd-analyze`, `journalctl`, `crontab`,
`loginctl`) are resolved once with a controlled lookup. A helper is rejected
unless the executable **and** its realpath target are regular files, are owned
by root (when `schedls` runs as root) or by root or the current user, and live
in a directory that is not group- or other-writable. Helper processes run with
a fixed `PATH` rather than the caller's.
### Path traversal through discovered unit names
A `Unit=` value read from a timer file is treated as untrusted data. Mutation
never uses it to build a filesystem path: unit names are always derived from
the validated job name (`schedls-<name>.timer` / `.service`). Generated unit
names are re-validated before they are written to a temporary directory, so a
crafted unit cannot redirect writes or removals outside the trusted unit
directory.
### Untrusted output
`journalctl` output is program output, not configuration. When stdout is a
terminal, control characters are escaped before display so a log line cannot
inject terminal escape sequences; redirected output is passed through
unchanged. Machine-readable `--json` output escapes control characters as part
of JSON encoding.
### Environment injection and pagers
Helper commands run with a controlled environment (`LC_ALL=C`,
`SYSTEMD_COLORS=0`, `SYSTEMD_PAGER=cat`, `PAGER=cat`) and never with a shell.
Pagers and editors are never launched while `schedls` is running. Environment
values passed with `--env` are written to unit files with systemd quoting and
are world-readable like any other unit; do not put secrets in them.
### TOCTOU races
Plans snapshot existing content. At apply time the current content is compared
against the snapshot; a mismatch aborts the operation. Privileged critical
sections are kept small and use file descriptors / atomic rename where
practical.
### Corrupt or malicious existing configuration
Configuration is treated as opaque data. Malformed `schedls` cron markers cause
mutation to fail closed rather than guess. Unknown crontab lines are preserved
byte-for-byte. A systemd unit whose name starts with `schedls-` is only treated
as managed when its file carries the `# Managed by schedls` and `# Name:`
markers, so a same-named foreign unit is not modified.
### Partial operations
systemd creation writes both units, reloads the manager, and enables the timer;
if a later step fails, or the operation is interrupted, the previous state is
restored and a partially enabled timer is disabled again. Cron installs are
validated first and restored from the previous crontab text on failure.
### Supply chain
Runtime dependencies are zero. Development dependencies are locked and audited
in CI.
## Availability, privacy
`0.1.0` requires `systemd-analyze` to create or validate calendar expressions
and refuses the operation if it is unavailable. `schedls` makes no network
requests and collects no telemetry.
+106
View File
@@ -0,0 +1,106 @@
# systemd backend
`schedls` manages systemd **timers**. A timer is always paired with a
oneshot service that holds the command.
## Layout
| Scope | Unit directory | Manager |
|--------|------------------------------|----------------------|
| user | `~/.config/systemd/user` | `systemctl --user` |
| system | `/etc/systemd/system` | `systemctl` |
`new --timer` defaults to user scope. System scope must be requested
explicitly with `--system` and appropriate privileges; `schedls` never invokes
`sudo` for you.
## Created files
For a job named `backup`:
```ini
# ~/.config/systemd/user/schedls-backup.service
# Managed by schedls
# Name: backup
[Unit]
Description=schedls job backup
[Service]
Type=oneshot
ExecStart="/usr/local/bin/backup" "/srv/data"
```
```ini
# ~/.config/systemd/user/schedls-backup.timer
# Managed by schedls
# Name: backup
[Unit]
Description=schedls job backup (timer)
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
Unit=schedls-backup.service
[Install]
WantedBy=timers.target
```
The `schedls-` prefix and the header comments provide provenance without a
database.
## Argument handling
`ExecStart=` is not shell syntax. `schedls` serializes each argument with
systemd's own quoting rules and always quotes arguments, so a value such as
`$(...)`, `%i`, `$HOME` or an argument beginning with `-` is passed literally.
Use `--shell` to explicitly request `/bin/sh -c`.
Every rendered unit is validated with `systemd-analyze verify` before
installation.
## Calendar expressions
Convenience flags compile to native `OnCalendar=` values:
| Flag | Example | Compiles to |
|------------------|----------------------------|------------------------------------|
| `--daily TIME` | `--daily 02:00` | `*-*-* 02:00:00` |
| `--weekdays TIME`| `--weekdays 08:30` | `Mon..Fri *-*-* 08:30:00` |
| `--weekly D TIME`| `--weekly sun 04:00` | `Sun *-*-* 04:00:00` |
| `--monthly D TIME`| `--monthly 1 06:00` | `*-*-01 06:00:00` |
| `--calendar EXPR`| `--calendar 'Mon..Fri 02:30'` | used verbatim |
`--calendar` may be repeated; systemd supports multiple `OnCalendar=` entries.
## Discovery
Timers are listed with `systemctl list-unit-files` and `list-units`, then
properties are read with `systemctl show`. OnCalendar, Persistent and the
command are read from the unit fragments. The next elapse is taken from
`NextElapseUSecRealtime`; monotonic timers have no wall-clock next run and are
reported as unknown.
## Logs
`schedls logs NAME` queries the service unit's journal via `journalctl
--no-pager`. The job command is never re-executed.
Two options control how much is shown, both passed to `journalctl`:
| Option | Default | Meaning |
|----------------|---------|---------------------------------------------------------------|
| `--lines N` | `50` | number of recent entries (`journalctl --lines=N`) |
| `--since TIME` | none | lower bound on entry time (`journalctl --since=TIME`) |
`TIME` accepts any value `journalctl --since` accepts, for example
`'2026-09-01'`, `'2 hours ago'` or `'2026-09-01 10:00:00'`. `--since` bounds the
range and `--lines` caps how many entries within it are shown. See
[cli.md](cli.md#logs-name) for the full command reference.
## Lingering
User timers only run while the user's systemd manager is alive. `schedls`
warns when lingering is disabled but never enables it.