Add status-aware notifications, edit mode flags, job ID filtering
Notifications now use ExecStopPost (fires on success and failure) with status-aware icons/messages instead of ExecStartPost (success only). Desktop uses dialog-information/dialog-error, email uses sendmail. Flags (i, e=addr) are persisted as # SYSTAB_FLAGS= comments in service files and exposed in edit mode via ID:flags syntax. Edit mode validates schedules before applying (re-edit loop like crontab -e). -S and -L accept an optional job ID to filter to a single job. Extract trim() helper to replace inline whitespace-stripping idiom. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
37efa43d0a
commit
ef442d464c
3 changed files with 283 additions and 97 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -12,22 +12,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
./systab [OPTIONS]
|
./systab [OPTIONS]
|
||||||
```
|
```
|
||||||
|
|
||||||
No build step. The script requires `bash`, `systemctl`, and optionally `notify-send` (for `-i`) and `mail` (for `-m`).
|
No build step. The script requires `bash`, `systemctl`, and optionally `notify-send` (for `-i`) and `sendmail`/`msmtp` (for `-m`).
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The script has two modes controlled by CLI flags:
|
The script has two modes controlled by CLI flags:
|
||||||
|
|
||||||
- **Job creation** (`-t <time> [-c <cmd> | -f <script> | stdin]`): Generates a systemd `.service` + `.timer` pair with a 6-char hex short ID, reloads the daemon, and enables/starts the timer. Time specs are parsed via `parse_time` which handles natural language (`every 5 minutes`), `date -d` relative/absolute times, and raw systemd OnCalendar values. One-time jobs get `Persistent=false` and `RemainAfterElapse=no` (auto-unload after firing). All jobs log stdout/stderr to the journal via `SyslogIdentifier`.
|
- **Job creation** (`-t <time> [-c <cmd> | -f <script> | stdin]`): Generates a systemd `.service` + `.timer` pair with a 6-char hex short ID, reloads the daemon, and enables/starts the timer. Time specs are parsed via `parse_time` which handles natural language (`every 5 minutes`), `date -d` relative/absolute times, and raw systemd OnCalendar values. One-time jobs get `Persistent=false` and `RemainAfterElapse=no` (auto-unload after firing). All jobs log stdout/stderr to the journal via `SyslogIdentifier`. Notifications (`-i` desktop, `-m` email) use `ExecStopPost` so they fire on both success and failure with status-aware icons/messages. Notification flags are persisted in the service file as a `# SYSTAB_FLAGS=` comment.
|
||||||
|
|
||||||
- **Management** (`-P`, `-R`, `-E`, `-L`, `-S`, `-C` — mutually exclusive):
|
- **Management** (`-P`, `-R`, `-E`, `-L`, `-S`, `-C` — mutually exclusive):
|
||||||
- `-P <id>` / `-R <id>`: Pause (stop+disable) or resume (enable+start) a job's timer.
|
- `-P <id>` / `-R <id>`: Pause (stop+disable) or resume (enable+start) a job's timer.
|
||||||
- `-E`: Opens `$EDITOR` with a pipe-separated crontab (`ID | SCHEDULE | COMMAND`). On save, diffs against the original to apply creates (ID=`new`), deletes (removed lines), updates (changed schedule/command), and pause/resume (comment/uncomment lines).
|
- `-E`: Opens `$EDITOR` with a pipe-separated crontab (`ID[:FLAGS] | SCHEDULE | COMMAND`). Notification flags are appended to the ID with `:` (`i` = desktop, `e=addr` = email, comma-separated for both). On save, diffs against the original to apply creates (ID=`new`), deletes (removed lines), updates (changed schedule/command/flags), and pause/resume (comment/uncomment lines).
|
||||||
- `-L [filter]`: Query `journalctl` logs for managed jobs (both unit messages and command output).
|
- `-L [id] [filter]`: Query `journalctl` logs for managed jobs (both unit messages and command output). Optional job ID to filter to a single job.
|
||||||
- `-S`: Show timer status via `systemctl`, including short IDs and disabled state.
|
- `-S [id]`: Show timer status via `systemctl`, including short IDs and disabled state. Optional job ID to show a single job.
|
||||||
- `-C`: Interactively clean up elapsed one-time timers (removes unit files from disk).
|
- `-C`: Interactively clean up elapsed one-time timers (removes unit files from disk).
|
||||||
|
|
||||||
Key functions: `parse_time` (time spec → OnCalendar), `create_job` (generates unit files), `edit_jobs` (crontab-style edit with diff-and-apply), `get_managed_services`/`get_managed_timers` (find tagged units), `clean_jobs` (remove elapsed one-time timers), `pause_job`/`resume_job` (disable/enable timers).
|
Key functions: `parse_time` (time spec → OnCalendar), `create_job` (generates unit files), `edit_jobs` (crontab-style edit with diff-and-apply), `get_managed_services`/`get_managed_timers` (find tagged units), `clean_jobs` (remove elapsed one-time timers), `pause_job`/`resume_job` (disable/enable timers), `write_notify_lines` (append `ExecStopPost` notification lines), `build_flags_string`/`parse_flags` (convert between CLI options and flags format).
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|
@ -44,5 +44,6 @@ There are no automated tests. Test manually with systemd user timers:
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- ShellCheck can be used for linting: `shellcheck systab`.
|
- ShellCheck can be used for linting: `shellcheck systab`.
|
||||||
- Edit mode uses `|` as the field delimiter (not tabs or spaces) to allow multi-word schedules.
|
- Edit mode uses `|` as the field delimiter (not tabs or spaces) to allow multi-word schedules. Notification flags use `:` after the ID (e.g., `a1b2c3:i,e=user@host`).
|
||||||
|
- Notification flags are persisted as `# SYSTAB_FLAGS=...` comments in service files and as `ExecStopPost=` lines using `$SERVICE_RESULT`/`$EXIT_STATUS` for status-aware messages.
|
||||||
- Journal logs are queried with `USER_UNIT` OR `SYSLOG_IDENTIFIER` to capture both systemd messages and command output.
|
- Journal logs are queried with `USER_UNIT` OR `SYSLOG_IDENTIFIER` to capture both systemd messages and command output.
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -10,7 +10,7 @@ Copy the `systab` script somewhere on your `$PATH`:
|
||||||
cp systab ~/.local/bin/
|
cp systab ~/.local/bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires `bash`, `systemctl`, and optionally `notify-send` (for `-i`) and `mail` (for `-m`).
|
Requires `bash`, `systemctl`, and optionally `notify-send` (for `-i`) and `sendmail`/`msmtp` (for `-m`).
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|
@ -68,10 +68,10 @@ systab -t "every day at 2am" -f ~/backup.sh
|
||||||
# From stdin
|
# From stdin
|
||||||
echo "ls -la /tmp" | systab -t daily
|
echo "ls -la /tmp" | systab -t daily
|
||||||
|
|
||||||
# With desktop notification on completion
|
# With desktop notification (success/failure with status icon)
|
||||||
systab -t "in 1 hour" -c "make build" -i
|
systab -t "in 1 hour" -c "make build" -i
|
||||||
|
|
||||||
# With email notification
|
# With email notification (via sendmail)
|
||||||
systab -t "every day at 6am" -c "df -h" -m user@example.com
|
systab -t "every day at 6am" -c "df -h" -m user@example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -84,9 +84,15 @@ systab -E
|
||||||
# Show status of all jobs
|
# Show status of all jobs
|
||||||
systab -S
|
systab -S
|
||||||
|
|
||||||
|
# Show status of a specific job
|
||||||
|
systab -S a1b2c3
|
||||||
|
|
||||||
# View logs (all jobs)
|
# View logs (all jobs)
|
||||||
systab -L
|
systab -L
|
||||||
|
|
||||||
|
# View logs for a specific job
|
||||||
|
systab -L a1b2c3
|
||||||
|
|
||||||
# View logs (filtered)
|
# View logs (filtered)
|
||||||
systab -L error
|
systab -L error
|
||||||
|
|
||||||
|
|
@ -106,14 +112,16 @@ systab -C
|
||||||
|
|
||||||
```
|
```
|
||||||
a1b2c3 | daily | /home/user/backup.sh
|
a1b2c3 | daily | /home/user/backup.sh
|
||||||
d4e5f6 | *:0/15 | curl -s https://example.com
|
d4e5f6:i | *:0/15 | curl -s https://example.com
|
||||||
# g7h8i9 | hourly | echo "this job is paused"
|
g7h8i9:e=user@host | weekly | ~/backup.sh
|
||||||
|
# aabbcc | hourly | echo "this job is paused"
|
||||||
```
|
```
|
||||||
|
|
||||||
- Edit the schedule or command to update a job
|
- Edit the schedule or command to update a job
|
||||||
- Delete a line to remove a job
|
- Delete a line to remove a job
|
||||||
- Add a line with `new` as the ID to create a job: `new | every 5 minutes | echo hello`
|
- Add a line with `new` as the ID to create a job: `new | every 5 minutes | echo hello`
|
||||||
- Comment out a line (`#`) to pause, uncomment to resume
|
- Comment out a line (`#`) to pause, uncomment to resume
|
||||||
|
- Append notification flags after the ID with `:` — `i` for desktop, `e=addr` for email, comma-separated for both (e.g., `a1b2c3:i,e=user@host`)
|
||||||
|
|
||||||
### Job IDs
|
### Job IDs
|
||||||
|
|
||||||
|
|
@ -123,6 +131,8 @@ Each job gets a 6-character hex ID (e.g., `a1b2c3`) displayed on creation and in
|
||||||
|
|
||||||
systab creates systemd `.service` and `.timer` unit file pairs in `~/.config/systemd/user/`. Each managed unit is tagged with a `# SYSTAB_MANAGED` marker comment. One-time jobs auto-unload after firing. Job output (stdout/stderr) is captured in the systemd journal and viewable via `systab -L`.
|
systab creates systemd `.service` and `.timer` unit file pairs in `~/.config/systemd/user/`. Each managed unit is tagged with a `# SYSTAB_MANAGED` marker comment. One-time jobs auto-unload after firing. Job output (stdout/stderr) is captured in the systemd journal and viewable via `systab -L`.
|
||||||
|
|
||||||
|
Notifications use `ExecStopPost` so they fire after the service completes regardless of success or failure. Desktop notifications show `dialog-information` or `dialog-error` icons based on `$SERVICE_RESULT`. Notification flags are persisted as `# SYSTAB_FLAGS=` comments in service files, so they survive across edit sessions.
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -130,15 +140,15 @@ Job Creation:
|
||||||
-t <time> Time specification (required for job creation)
|
-t <time> Time specification (required for job creation)
|
||||||
-c <command> Command string to execute
|
-c <command> Command string to execute
|
||||||
-f <script> Script file to execute (reads stdin if neither -c nor -f)
|
-f <script> Script file to execute (reads stdin if neither -c nor -f)
|
||||||
-i Send desktop notification on completion
|
-i Send desktop notification on completion (success/failure)
|
||||||
-m <email> Send email notification to address
|
-m <email> Send email notification to address (via sendmail)
|
||||||
|
|
||||||
Management:
|
Management:
|
||||||
-P <id> Pause (disable) a job
|
-P <id> Pause (disable) a job
|
||||||
-R <id> Resume (enable) a paused job
|
-R <id> Resume (enable) a paused job
|
||||||
-E Edit jobs in crontab-like format
|
-E Edit jobs in crontab-like format
|
||||||
-L [filter] List job logs (optionally filtered)
|
-L [id] [filter] List job logs (optionally for a specific job and/or filtered)
|
||||||
-S Show status of all managed jobs
|
-S [id] Show status of all managed jobs (or a specific job)
|
||||||
-C Clean up completed one-time jobs
|
-C Clean up completed one-time jobs
|
||||||
-h Show help
|
-h Show help
|
||||||
```
|
```
|
||||||
|
|
|
||||||
289
systab
289
systab
|
|
@ -21,6 +21,7 @@ opt_status=false
|
||||||
opt_pause=""
|
opt_pause=""
|
||||||
opt_resume=""
|
opt_resume=""
|
||||||
opt_filter=""
|
opt_filter=""
|
||||||
|
opt_jobid=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
@ -32,15 +33,15 @@ Job Creation Options:
|
||||||
-t <time> Time specification (see TIME FORMATS below)
|
-t <time> Time specification (see TIME FORMATS below)
|
||||||
-c <command> Command string to execute
|
-c <command> Command string to execute
|
||||||
-f <script> Script file to execute
|
-f <script> Script file to execute
|
||||||
-i Send desktop notification on completion
|
-i Send desktop notification on completion (success/failure)
|
||||||
-m <email> Send email notification to address
|
-m <email> Send email notification to address (via sendmail)
|
||||||
|
|
||||||
Management Options:
|
Management Options:
|
||||||
-P <id> Pause (disable) a job
|
-P <id> Pause (disable) a job
|
||||||
-R <id> Resume (enable) a paused job
|
-R <id> Resume (enable) a paused job
|
||||||
-E Edit jobs in crontab-like format
|
-E Edit jobs in crontab-like format
|
||||||
-L [filter] List job logs (optionally filtered)
|
-L [id] [filter] List job logs (optionally for a specific job and/or filtered)
|
||||||
-S Show status of all managed jobs and timers
|
-S [id] Show status of all managed jobs (or a specific job)
|
||||||
-C Clean up completed one-time jobs
|
-C Clean up completed one-time jobs
|
||||||
|
|
||||||
TIME FORMATS:
|
TIME FORMATS:
|
||||||
|
|
@ -53,16 +54,19 @@ EXAMPLES:
|
||||||
# Run command every 5 minutes
|
# Run command every 5 minutes
|
||||||
$SCRIPT_NAME -t "every 5 minutes" -c "echo Hello"
|
$SCRIPT_NAME -t "every 5 minutes" -c "echo Hello"
|
||||||
|
|
||||||
# Run script every day at 2am with notification
|
# Run script every day at 2am with desktop notification
|
||||||
$SCRIPT_NAME -t "every day at 2am" -f ~/backup.sh -i
|
$SCRIPT_NAME -t "every day at 2am" -f ~/backup.sh -i
|
||||||
|
|
||||||
|
# Run command with email notification
|
||||||
|
$SCRIPT_NAME -t "in 5 minutes" -c "echo Hello" -m user@example.com
|
||||||
|
|
||||||
# Run command in 5 minutes (one-time)
|
# Run command in 5 minutes (one-time)
|
||||||
$SCRIPT_NAME -t "in 5 minutes" -c "echo Hello"
|
$SCRIPT_NAME -t "in 5 minutes" -c "echo Hello"
|
||||||
|
|
||||||
# Read command from stdin
|
# Read command from stdin
|
||||||
echo "ls -la" | $SCRIPT_NAME -t "next monday at 9am"
|
echo "ls -la" | $SCRIPT_NAME -t "next monday at 9am"
|
||||||
|
|
||||||
# Edit existing jobs
|
# Edit existing jobs (supports adding notifications via ID:flags syntax)
|
||||||
$SCRIPT_NAME -E
|
$SCRIPT_NAME -E
|
||||||
|
|
||||||
# Pause and resume a job
|
# Pause and resume a job
|
||||||
|
|
@ -91,6 +95,13 @@ warn() {
|
||||||
echo "Warning: $*" >&2
|
echo "Warning: $*" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Trim leading and trailing whitespace, result in _trimmed
|
||||||
|
trim() {
|
||||||
|
_trimmed="$1"
|
||||||
|
_trimmed="${_trimmed#"${_trimmed%%[![:space:]]*}"}"
|
||||||
|
_trimmed="${_trimmed%"${_trimmed##*[![:space:]]}"}"
|
||||||
|
}
|
||||||
|
|
||||||
# Parse time specification into systemd OnCalendar format
|
# Parse time specification into systemd OnCalendar format
|
||||||
parse_time() {
|
parse_time() {
|
||||||
local time_spec="$1"
|
local time_spec="$1"
|
||||||
|
|
@ -261,6 +272,49 @@ get_managed_timers() {
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Build flags string from opt_notify and opt_email
|
||||||
|
build_flags_string() {
|
||||||
|
local flags=""
|
||||||
|
if $opt_notify; then
|
||||||
|
flags="i"
|
||||||
|
fi
|
||||||
|
if [[ -n "$opt_email" ]]; then
|
||||||
|
[[ -n "$flags" ]] && flags+=","
|
||||||
|
flags+="e=$opt_email"
|
||||||
|
fi
|
||||||
|
echo "$flags"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse flags string into _notify_flag (bool) and _email_addr (string)
|
||||||
|
parse_flags() {
|
||||||
|
local flags="$1"
|
||||||
|
_notify_flag=false
|
||||||
|
_email_addr=""
|
||||||
|
IFS=',' read -ra parts <<< "$flags"
|
||||||
|
for part in "${parts[@]}"; do
|
||||||
|
case "$part" in
|
||||||
|
i) _notify_flag=true ;;
|
||||||
|
e=*) _email_addr="${part#e=}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write ExecStopPost notification lines to a service file
|
||||||
|
# Usage: write_notify_lines <short_id> <notify_flag> <email_addr> <file>
|
||||||
|
write_notify_lines() {
|
||||||
|
local short_id="$1" notify="$2" email="$3" file="$4"
|
||||||
|
if [[ "$notify" == true ]]; then
|
||||||
|
cat >> "$file" <<EOF
|
||||||
|
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then icon=dialog-information; s=completed; else icon=dialog-error; s="failed (\$EXIT_STATUS)"; fi; notify-send -i "\$icon" "systab" "Job $short_id: \$s" || true'
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
if [[ -n "$email" ]]; then
|
||||||
|
cat >> "$file" <<EOF
|
||||||
|
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then s=completed; else s="failed (\$EXIT_STATUS)"; fi; printf "Subject: systab: $short_id %s\\n\\n%s at %s\\n" "\$s" "\$s" "\$(date)" | sendmail $email'
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Create systemd service and timer files
|
# Create systemd service and timer files
|
||||||
create_job() {
|
create_job() {
|
||||||
local job_name command_to_run time_spec
|
local job_name command_to_run time_spec
|
||||||
|
|
@ -286,6 +340,11 @@ create_job() {
|
||||||
# Create systemd user directory if needed
|
# Create systemd user directory if needed
|
||||||
mkdir -p "$SYSTEMD_USER_DIR"
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
|
|
||||||
|
# Build flags
|
||||||
|
local flags short_id
|
||||||
|
flags=$(build_flags_string)
|
||||||
|
short_id=$(job_id "$job_name")
|
||||||
|
|
||||||
# Create service file
|
# Create service file
|
||||||
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
|
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
|
||||||
cat > "$service_file" <<EOF
|
cat > "$service_file" <<EOF
|
||||||
|
|
@ -301,22 +360,10 @@ StandardError=journal
|
||||||
SyslogIdentifier=$job_name
|
SyslogIdentifier=$job_name
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Add notification wrapper if needed
|
# Add flags comment and notification lines
|
||||||
if $opt_notify || [[ -n "$opt_email" ]]; then
|
if [[ -n "$flags" ]]; then
|
||||||
if $opt_notify; then
|
echo "# SYSTAB_FLAGS=$flags" >> "$service_file"
|
||||||
# Get current user's runtime directory and display
|
write_notify_lines "$short_id" "$opt_notify" "$opt_email" "$service_file"
|
||||||
local user_id
|
|
||||||
user_id=$(id -u)
|
|
||||||
cat >> "$service_file" <<EOF
|
|
||||||
ExecStartPost=/bin/sh -c 'export DISPLAY=:0; export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$user_id/bus; /usr/bin/notify-send "$SCRIPT_NAME" "Job completed: $job_name" || true'
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "$opt_email" ]]; then
|
|
||||||
cat >> "$service_file" <<EOF
|
|
||||||
ExecStartPost=/bin/sh -c 'echo "Job $job_name completed at \$(date)" | mail -s "$SCRIPT_NAME: Job completed" "$opt_email"'
|
|
||||||
EOF
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create timer file
|
# Create timer file
|
||||||
|
|
@ -360,13 +407,14 @@ remove_job() {
|
||||||
rm -f "$SYSTEMD_USER_DIR/${job_name}.service" "$SYSTEMD_USER_DIR/${job_name}.timer"
|
rm -f "$SYSTEMD_USER_DIR/${job_name}.service" "$SYSTEMD_USER_DIR/${job_name}.timer"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create a job from edit mode (schedule + command, prints short ID)
|
# Create a job from edit mode (schedule + command + flags, prints short ID)
|
||||||
create_job_from_edit() {
|
create_job_from_edit() {
|
||||||
local schedule command_to_run="$2"
|
local schedule command_to_run="$2" flags="${3-}"
|
||||||
schedule=$(parse_time "$1")
|
schedule=$(parse_time "$1")
|
||||||
local job_name
|
local job_name short_id
|
||||||
|
|
||||||
job_name="${SCRIPT_NAME}_$(generate_id)"
|
job_name="${SCRIPT_NAME}_$(generate_id)"
|
||||||
|
short_id=$(job_id "$job_name")
|
||||||
|
|
||||||
mkdir -p "$SYSTEMD_USER_DIR"
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
|
|
||||||
|
|
@ -384,6 +432,13 @@ StandardError=journal
|
||||||
SyslogIdentifier=$job_name
|
SyslogIdentifier=$job_name
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Add flags and notification lines
|
||||||
|
if [[ -n "$flags" ]]; then
|
||||||
|
echo "# SYSTAB_FLAGS=$flags" >> "$SYSTEMD_USER_DIR/${job_name}.service"
|
||||||
|
parse_flags "$flags"
|
||||||
|
write_notify_lines "$short_id" "$_notify_flag" "$_email_addr" "$SYSTEMD_USER_DIR/${job_name}.service"
|
||||||
|
fi
|
||||||
|
|
||||||
# Timer file
|
# Timer file
|
||||||
cat > "$SYSTEMD_USER_DIR/${job_name}.timer" <<EOF
|
cat > "$SYSTEMD_USER_DIR/${job_name}.timer" <<EOF
|
||||||
$MARKER
|
$MARKER
|
||||||
|
|
@ -425,11 +480,16 @@ edit_jobs() {
|
||||||
{
|
{
|
||||||
cat <<'HEADER'
|
cat <<'HEADER'
|
||||||
# systab jobs — edit schedule/command, add/remove lines
|
# systab jobs — edit schedule/command, add/remove lines
|
||||||
# Format: ID | SCHEDULE | COMMAND (pipe-separated)
|
# Format: ID[:FLAGS] | SCHEDULE | COMMAND (pipe-separated)
|
||||||
# Remove a line to delete a job.
|
# Remove a line to delete a job.
|
||||||
# Add a line with "new" as ID to create a job: new | daily | /path/to/cmd
|
# Add a line with "new" as ID to create a job: new | daily | /path/to/cmd
|
||||||
# Comment out a line to disable, uncomment to re-enable.
|
# Comment out a line to disable, uncomment to re-enable.
|
||||||
#
|
#
|
||||||
|
# Notification flags (append to ID with ':'): i = desktop, e=addr = email
|
||||||
|
# a1b2c3:i | daily | cmd desktop notification
|
||||||
|
# a1b2c3:e=user@host | daily | cmd email notification
|
||||||
|
# a1b2c3:i,e=user@host | daily | cmd both
|
||||||
|
#
|
||||||
# Schedule formats (systemd OnCalendar):
|
# Schedule formats (systemd OnCalendar):
|
||||||
# hourly, daily, weekly, monthly, yearly
|
# hourly, daily, weekly, monthly, yearly
|
||||||
# *:0/15 every 15 minutes
|
# *:0/15 every 15 minutes
|
||||||
|
|
@ -446,14 +506,20 @@ HEADER
|
||||||
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
||||||
|
|
||||||
if [[ -f "$timer_file" && -f "$service_file" ]]; then
|
if [[ -f "$timer_file" && -f "$service_file" ]]; then
|
||||||
local id schedule command
|
local id schedule command flags id_field
|
||||||
id=$(job_id "$job")
|
id=$(job_id "$job")
|
||||||
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
||||||
command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//")
|
command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//")
|
||||||
if is_job_enabled "$job"; then
|
flags=$(grep "^# SYSTAB_FLAGS=" "$service_file" 2>/dev/null | sed 's/^# SYSTAB_FLAGS=//' || true)
|
||||||
printf '%s | %s | %s\n' "$id" "$schedule" "$command"
|
if [[ -n "$flags" ]]; then
|
||||||
|
id_field="$id:$flags"
|
||||||
else
|
else
|
||||||
printf '# %s | %s | %s\n' "$id" "$schedule" "$command"
|
id_field="$id"
|
||||||
|
fi
|
||||||
|
if is_job_enabled "$job"; then
|
||||||
|
printf '%s | %s | %s\n' "$id_field" "$schedule" "$command"
|
||||||
|
else
|
||||||
|
printf '# %s | %s | %s\n' "$id_field" "$schedule" "$command"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done < <(get_managed_timers)
|
done < <(get_managed_timers)
|
||||||
|
|
@ -462,7 +528,44 @@ HEADER
|
||||||
# Save original for diffing
|
# Save original for diffing
|
||||||
cp "$temp_file" "$orig_file"
|
cp "$temp_file" "$orig_file"
|
||||||
|
|
||||||
# Open in editor
|
# Validate all time specs in a crontab file, returns 0 if valid
|
||||||
|
# Prints error messages for each bad schedule
|
||||||
|
validate_crontab_schedules() {
|
||||||
|
local file="$1" errors=0 line
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
local is_comment=false
|
||||||
|
# Strip comment prefix for disabled jobs
|
||||||
|
if [[ "$line" =~ ^#[[:space:]]+(.*) ]]; then
|
||||||
|
line="${BASH_REMATCH[1]}"
|
||||||
|
is_comment=true
|
||||||
|
fi
|
||||||
|
[[ "$line" == *"|"* ]] || continue
|
||||||
|
# Extract and trim ID field
|
||||||
|
local id_field="${line%%|*}"
|
||||||
|
trim "$id_field"; id_field="$_trimmed"
|
||||||
|
# Strip :flags suffix
|
||||||
|
local id_bare="${id_field%%:*}"
|
||||||
|
# Only validate lines with a valid job ID (6-char hex or "new")
|
||||||
|
if $is_comment; then
|
||||||
|
[[ "$id_bare" =~ ^[0-9a-f]{6}$ ]] || continue
|
||||||
|
else
|
||||||
|
[[ "$id_bare" =~ ^[0-9a-f]{6}$ || "$id_bare" == "new" ]] || continue
|
||||||
|
fi
|
||||||
|
local sched="${line#*|}"
|
||||||
|
sched="${sched%%|*}"
|
||||||
|
trim "$sched"; sched="$_trimmed"
|
||||||
|
[[ -z "$sched" ]] && continue
|
||||||
|
if ! (parse_time "$sched") &>/dev/null; then
|
||||||
|
echo "Bad schedule: \"$sched\"" >&2
|
||||||
|
errors=$((errors + 1))
|
||||||
|
fi
|
||||||
|
done < "$file"
|
||||||
|
return "$errors"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Edit loop: open editor, validate, re-edit on errors (like crontab -e)
|
||||||
|
while true; do
|
||||||
if ! "${EDITOR:-vi}" "$temp_file"; then
|
if ! "${EDITOR:-vi}" "$temp_file"; then
|
||||||
echo "Editor exited with error, aborting."
|
echo "Editor exited with error, aborting."
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -474,51 +577,76 @@ HEADER
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Validate schedules before applying
|
||||||
|
if validate_crontab_schedules "$temp_file"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Errors in systab file, can't install." >&2
|
||||||
|
read -p "Do you want to retry the same edit? (y/n) " -n 1 -r </dev/tty
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Aborted."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
# Parse files into associative arrays: id -> "schedule\tcmd"
|
# Parse files into associative arrays: id -> "schedule\tcmd"
|
||||||
# "new" lines are collected separately since there can be multiple
|
# "new" lines are collected separately since there can be multiple
|
||||||
# commented_ids tracks disabled (commented-out) job lines
|
# commented_ids tracks disabled (commented-out) job lines
|
||||||
declare -A orig_jobs edited_jobs orig_commented edited_commented
|
declare -A orig_jobs edited_jobs orig_commented edited_commented
|
||||||
declare -a new_jobs=()
|
declare -a new_jobs=()
|
||||||
|
|
||||||
# Parse a crontab line into id, sched, cmd (split on first two | delimiters)
|
# Parse a crontab line into id, flags, sched, cmd (split on first two | delimiters)
|
||||||
|
# _parsed_id gets the bare ID, _parsed_flags gets any flags after ':'
|
||||||
parse_crontab_line() {
|
parse_crontab_line() {
|
||||||
local line="$1"
|
local line="$1"
|
||||||
_parsed_id="" _parsed_sched="" _parsed_cmd=""
|
_parsed_id="" _parsed_flags="" _parsed_sched="" _parsed_cmd=""
|
||||||
if [[ "$line" == *"|"* ]]; then
|
if [[ "$line" == *"|"* ]]; then
|
||||||
_parsed_id="${line%%|*}"
|
local id_field="${line%%|*}"
|
||||||
local rest="${line#*|}"
|
local rest="${line#*|}"
|
||||||
_parsed_sched="${rest%%|*}"
|
_parsed_sched="${rest%%|*}"
|
||||||
_parsed_cmd="${rest#*|}"
|
_parsed_cmd="${rest#*|}"
|
||||||
# Trim leading/trailing whitespace
|
# Trim leading/trailing whitespace
|
||||||
_parsed_id="${_parsed_id#"${_parsed_id%%[![:space:]]*}"}" _parsed_id="${_parsed_id%"${_parsed_id##*[![:space:]]}"}"
|
trim "$id_field"; id_field="$_trimmed"
|
||||||
_parsed_sched="${_parsed_sched#"${_parsed_sched%%[![:space:]]*}"}" _parsed_sched="${_parsed_sched%"${_parsed_sched##*[![:space:]]}"}"
|
trim "$_parsed_sched"; _parsed_sched="$_trimmed"
|
||||||
_parsed_cmd="${_parsed_cmd#"${_parsed_cmd%%[![:space:]]*}"}" _parsed_cmd="${_parsed_cmd%"${_parsed_cmd##*[![:space:]]}"}"
|
trim "$_parsed_cmd"; _parsed_cmd="$_trimmed"
|
||||||
|
# Split id_field on ':' into ID and flags
|
||||||
|
if [[ "$id_field" == *:* ]]; then
|
||||||
|
_parsed_id="${id_field%%:*}"
|
||||||
|
_parsed_flags="${id_field#*:}"
|
||||||
|
else
|
||||||
|
_parsed_id="$id_field"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse a file, populating jobs and commented arrays
|
# Parse a file, populating jobs and commented arrays
|
||||||
|
# Values stored as "flags<TAB>schedule|cmd"
|
||||||
# Usage: parse_crontab_file <file> <jobs_var> <commented_var> [new_jobs_var]
|
# Usage: parse_crontab_file <file> <jobs_var> <commented_var> [new_jobs_var]
|
||||||
parse_crontab_file() {
|
parse_crontab_file() {
|
||||||
local file="$1" jobs_ref="$2" commented_ref="$3" new_ref="${4-}"
|
local file="$1" jobs_ref="$2" commented_ref="$3" new_ref="${4-}"
|
||||||
local line
|
local line tab=$'\t'
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
[[ -z "$line" ]] && continue
|
[[ -z "$line" ]] && continue
|
||||||
# Check for commented job line: "# <id> <sched> <cmd>"
|
# Check for commented job line: "# <id> <sched> <cmd>"
|
||||||
if [[ "$line" =~ ^#[[:space:]]+(.*) ]]; then
|
if [[ "$line" =~ ^#[[:space:]]+(.*) ]]; then
|
||||||
local uncommented="${BASH_REMATCH[1]}"
|
local uncommented="${BASH_REMATCH[1]}"
|
||||||
parse_crontab_line "$uncommented"
|
parse_crontab_line "$uncommented"
|
||||||
# Only treat as disabled job if ID looks like a hex short ID
|
# Only treat as disabled job if ID looks like a hex short ID (with optional :flags)
|
||||||
if [[ -n "$_parsed_id" && -n "$_parsed_sched" && "$_parsed_id" =~ ^[0-9a-f]{6}$ ]]; then
|
if [[ -n "$_parsed_id" && -n "$_parsed_sched" && "$_parsed_id" =~ ^[0-9a-f]{6}$ ]]; then
|
||||||
eval "${commented_ref}[\"\$_parsed_id\"]=\"\${_parsed_sched}|\${_parsed_cmd}\""
|
local _val="${_parsed_flags}${tab}${_parsed_sched}|${_parsed_cmd}"
|
||||||
|
eval "${commented_ref}[\"\$_parsed_id\"]=\$_val"
|
||||||
fi
|
fi
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
parse_crontab_line "$line"
|
parse_crontab_line "$line"
|
||||||
[[ -n "$_parsed_id" && -n "$_parsed_sched" ]] || continue
|
[[ -n "$_parsed_id" && -n "$_parsed_sched" ]] || continue
|
||||||
|
local _val="${_parsed_flags}${tab}${_parsed_sched}|${_parsed_cmd}"
|
||||||
if [[ "$_parsed_id" == "new" && -n "$new_ref" ]]; then
|
if [[ "$_parsed_id" == "new" && -n "$new_ref" ]]; then
|
||||||
eval "${new_ref}+=(\"\${_parsed_sched}|\${_parsed_cmd}\")"
|
eval "${new_ref}+=(\"\$_val\")"
|
||||||
else
|
else
|
||||||
eval "${jobs_ref}[\"\$_parsed_id\"]=\"\${_parsed_sched}|\${_parsed_cmd}\""
|
eval "${jobs_ref}[\"\$_parsed_id\"]=\$_val"
|
||||||
fi
|
fi
|
||||||
done < "$file"
|
done < "$file"
|
||||||
}
|
}
|
||||||
|
|
@ -545,11 +673,13 @@ HEADER
|
||||||
|
|
||||||
# Creations: "new" lines from edited file
|
# Creations: "new" lines from edited file
|
||||||
for entry in "${new_jobs[@]}"; do
|
for entry in "${new_jobs[@]}"; do
|
||||||
local sched cmd
|
local new_flags new_rest sched cmd
|
||||||
sched="${entry%%|*}"
|
new_flags="${entry%% *}"
|
||||||
cmd="${entry#*|}"
|
new_rest="${entry#* }"
|
||||||
|
sched="${new_rest%%|*}"
|
||||||
|
cmd="${new_rest#*|}"
|
||||||
local result
|
local result
|
||||||
result=$(create_job_from_edit "$sched" "$cmd")
|
result=$(create_job_from_edit "$sched" "$cmd" "$new_flags")
|
||||||
echo "Created: $result"
|
echo "Created: $result"
|
||||||
created=$((created + 1))
|
created=$((created + 1))
|
||||||
needs_reload=true
|
needs_reload=true
|
||||||
|
|
@ -577,7 +707,7 @@ HEADER
|
||||||
# Skip if deleted
|
# Skip if deleted
|
||||||
[[ -z "${edited_jobs[$id]+x}" && -z "${edited_commented[$id]+x}" ]] && continue
|
[[ -z "${edited_jobs[$id]+x}" && -z "${edited_commented[$id]+x}" ]] && continue
|
||||||
|
|
||||||
# Get old and new values
|
# Get old and new values (format: "flags\tsched|cmd")
|
||||||
local old_entry="" new_entry=""
|
local old_entry="" new_entry=""
|
||||||
if $was_commented; then
|
if $was_commented; then
|
||||||
old_entry="${orig_commented[$id]}"
|
old_entry="${orig_commented[$id]}"
|
||||||
|
|
@ -590,6 +720,13 @@ HEADER
|
||||||
new_entry="${edited_jobs[$id]}"
|
new_entry="${edited_jobs[$id]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Split into flags and sched|cmd parts
|
||||||
|
local old_flags old_rest new_flags new_rest
|
||||||
|
old_flags="${old_entry%% *}"
|
||||||
|
old_rest="${old_entry#* }"
|
||||||
|
new_flags="${new_entry%% *}"
|
||||||
|
new_rest="${new_entry#* }"
|
||||||
|
|
||||||
# Handle enable/disable transitions
|
# Handle enable/disable transitions
|
||||||
if ! $was_commented && $now_commented; then
|
if ! $was_commented && $now_commented; then
|
||||||
disable_job "$jname"
|
disable_job "$jname"
|
||||||
|
|
@ -603,13 +740,13 @@ HEADER
|
||||||
needs_reload=true
|
needs_reload=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Handle schedule/command changes (whether active or commented)
|
# Handle schedule/command/flags changes (whether active or commented)
|
||||||
if [[ "$old_entry" != "$new_entry" ]]; then
|
if [[ "$old_entry" != "$new_entry" ]]; then
|
||||||
local old_sched new_sched old_cmd new_cmd
|
local old_sched new_sched old_cmd new_cmd
|
||||||
old_sched="${old_entry%%|*}"
|
old_sched="${old_rest%%|*}"
|
||||||
new_sched="${new_entry%%|*}"
|
new_sched="${new_rest%%|*}"
|
||||||
old_cmd="${old_entry#*|}"
|
old_cmd="${old_rest#*|}"
|
||||||
new_cmd="${new_entry#*|}"
|
new_cmd="${new_rest#*|}"
|
||||||
|
|
||||||
local timer_file="$SYSTEMD_USER_DIR/${jname}.timer"
|
local timer_file="$SYSTEMD_USER_DIR/${jname}.timer"
|
||||||
local service_file="$SYSTEMD_USER_DIR/${jname}.service"
|
local service_file="$SYSTEMD_USER_DIR/${jname}.service"
|
||||||
|
|
@ -633,6 +770,19 @@ HEADER
|
||||||
sed -i "s|^ExecStart=.*|ExecStart=${SHELL:-/bin/bash} -c '$new_cmd'|" "$service_file"
|
sed -i "s|^ExecStart=.*|ExecStart=${SHELL:-/bin/bash} -c '$new_cmd'|" "$service_file"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Handle flags changes
|
||||||
|
if [[ "$old_flags" != "$new_flags" && -f "$service_file" ]]; then
|
||||||
|
# Remove existing notification lines and flags comment
|
||||||
|
sed -i '/^ExecStopPost=/d' "$service_file"
|
||||||
|
sed -i '/^# SYSTAB_FLAGS=/d' "$service_file"
|
||||||
|
# Add new flags and notification lines
|
||||||
|
if [[ -n "$new_flags" ]]; then
|
||||||
|
echo "# SYSTAB_FLAGS=$new_flags" >> "$service_file"
|
||||||
|
parse_flags "$new_flags"
|
||||||
|
write_notify_lines "$id" "$_notify_flag" "$_email_addr" "$service_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Only restart if the job is active (not being disabled)
|
# Only restart if the job is active (not being disabled)
|
||||||
if ! $now_commented; then
|
if ! $now_commented; then
|
||||||
systemctl --user restart "${jname}.timer" 2>/dev/null || true
|
systemctl --user restart "${jname}.timer" 2>/dev/null || true
|
||||||
|
|
@ -652,15 +802,19 @@ HEADER
|
||||||
echo "Summary: $created created, $updated updated, $deleted deleted, $enabled enabled, $disabled disabled"
|
echo "Summary: $created created, $updated updated, $deleted deleted, $enabled enabled, $disabled disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show status of all managed jobs
|
# Show status of all managed jobs (or a single job if opt_jobid is set)
|
||||||
show_status() {
|
show_status() {
|
||||||
local job
|
local job
|
||||||
local job_list=()
|
local job_list=()
|
||||||
|
|
||||||
|
if [[ -n "$opt_jobid" ]]; then
|
||||||
|
job_list+=("${SCRIPT_NAME}_${opt_jobid}")
|
||||||
|
else
|
||||||
while IFS= read -r job; do
|
while IFS= read -r job; do
|
||||||
[[ -z "$job" ]] && continue
|
[[ -z "$job" ]] && continue
|
||||||
job_list+=("$job")
|
job_list+=("$job")
|
||||||
done < <(get_managed_timers)
|
done < <(get_managed_timers)
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ${#job_list[@]} -eq 0 ]]; then
|
if [[ ${#job_list[@]} -eq 0 ]]; then
|
||||||
echo "No managed jobs found."
|
echo "No managed jobs found."
|
||||||
|
|
@ -746,7 +900,7 @@ show_status() {
|
||||||
systemctl --user list-timers "systab_*" --no-pager 2>/dev/null || echo "none active"
|
systemctl --user list-timers "systab_*" --no-pager 2>/dev/null || echo "none active"
|
||||||
}
|
}
|
||||||
|
|
||||||
# List logs
|
# List logs (or logs for a single job if opt_jobid is set)
|
||||||
list_logs() {
|
list_logs() {
|
||||||
local grep_cmd
|
local grep_cmd
|
||||||
if command -v rg &>/dev/null; then
|
if command -v rg &>/dev/null; then
|
||||||
|
|
@ -757,10 +911,15 @@ list_logs() {
|
||||||
|
|
||||||
local job
|
local job
|
||||||
local job_list=()
|
local job_list=()
|
||||||
|
|
||||||
|
if [[ -n "$opt_jobid" ]]; then
|
||||||
|
job_list+=("${SCRIPT_NAME}_${opt_jobid}")
|
||||||
|
else
|
||||||
while IFS= read -r job; do
|
while IFS= read -r job; do
|
||||||
[[ -z "$job" ]] && continue
|
[[ -z "$job" ]] && continue
|
||||||
job_list+=("$job")
|
job_list+=("$job")
|
||||||
done < <(get_managed_services)
|
done < <(get_managed_services)
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ${#job_list[@]} -eq 0 ]]; then
|
if [[ ${#job_list[@]} -eq 0 ]]; then
|
||||||
echo "No managed jobs found."
|
echo "No managed jobs found."
|
||||||
|
|
@ -855,9 +1014,25 @@ parse_options() {
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Check if there's a filter argument after -L
|
# Check for trailing arguments after -S or -L
|
||||||
if $opt_list && [[ $OPTIND -le $# ]]; then
|
if ($opt_list || $opt_status) && [[ $OPTIND -le $# ]]; then
|
||||||
opt_filter="${!OPTIND}"
|
local trailing="${!OPTIND}"
|
||||||
|
# If it looks like a job ID and the job exists, use as job filter
|
||||||
|
if [[ "$trailing" =~ ^[0-9a-f]{6}$ ]] && [[ -f "$SYSTEMD_USER_DIR/${SCRIPT_NAME}_${trailing}.timer" ]]; then
|
||||||
|
opt_jobid="$trailing"
|
||||||
|
# For -L, check for a second trailing arg as text filter
|
||||||
|
if $opt_list; then
|
||||||
|
local next_idx=$((OPTIND + 1))
|
||||||
|
if [[ $next_idx -le $# ]]; then
|
||||||
|
opt_filter="${!next_idx}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
elif $opt_list; then
|
||||||
|
# Not a job ID — treat as text filter for backward compatibility
|
||||||
|
opt_filter="$trailing"
|
||||||
|
else
|
||||||
|
error "No job found with ID: $trailing"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Validate mutually exclusive options
|
# Validate mutually exclusive options
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue