adding name option

This commit is contained in:
Matthias Johnson 2026-02-15 10:11:26 -07:00
parent 308f9e6b11
commit f32bf5495b
7 changed files with 207 additions and 70 deletions

View file

@ -18,16 +18,16 @@ No build step. The script requires `bash`, `systemctl`, and optionally `notify-s
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`. Notifications (`-i` desktop, `-m` email, `-o` include output) use `ExecStopPost` so they fire on both success and failure with status-aware icons/messages. The `-o [N]` flag fetches the last N lines of journal output (default 10) and includes them in the notification body (also configurable in edit mode as `o` or `o=N`). Notification flags are persisted in the service file as a `# SYSTAB_FLAGS=` comment. - **Job creation** (`-t <time> [-n <name>] [-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. An optional `-n <name>` assigns a human-readable name that can be used interchangeably with hex IDs in all operations. 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, `-o` include output) use `ExecStopPost` so they fire on both success and failure with status-aware icons/messages. The `-o [N]` flag fetches the last N lines of journal output (default 10) and includes them in the notification body (also configurable in edit mode as `o` or `o=N`). Notification flags are persisted in the service file as a `# SYSTAB_FLAGS=` comment.
- **Management** (`-D`, `-E`, `-e`, `-L`, `-S`, `-C`, `-h` — mutually exclusive): - **Management** (`-D`, `-E`, `-e`, `-L`, `-S`, `-C`, `-h` — mutually exclusive):
- `-D <id>` / `-E <id>`: Disable (stop+disable) or enable (enable+start) a job's timer. - `-D <id|name>` / `-E <id|name>`: Disable (stop+disable) or enable (enable+start) a job's timer. Accepts hex ID or name.
- `-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, `o` = output 10 lines, `o=N` = output N lines, comma-separated). On save, diffs against the original to apply creates (ID=`new`), deletes (removed lines), updates (changed schedule/command/flags), and disable/enable (comment/uncomment lines). - `-e`: Opens `$EDITOR` with a pipe-separated crontab (`ID[:FLAGS] | SCHEDULE | COMMAND`). Flags are appended to the ID with `:` (`i` = desktop, `e=addr` = email, `o` = output 10 lines, `o=N` = output N lines, `n=name` = job name, comma-separated). On save, diffs against the original to apply creates (ID=`new`), deletes (removed lines), updates (changed schedule/command/flags), and disable/enable (comment/uncomment lines).
- `-L [id] [filter]`: Query `journalctl` logs for managed jobs (both unit messages and command output). Optional job ID to filter to a single job. - `-L [id|name] [filter]`: Query `journalctl` logs for managed jobs (both unit messages and command output). Optional job ID or name to filter to a single job.
- `-S [id]`: Show timer status via `systemctl`, including short IDs and disabled state. Optional job ID to show a single job. - `-S [id|name]`: Show timer status via `systemctl`, including short IDs, names, and disabled state. Optional job ID or name 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), `_write_unit_files` (shared service+timer creation), `create_job`/`create_job_from_edit` (thin wrappers), `edit_jobs` (crontab-style edit with diff-and-apply), `get_managed_units` (find tagged units by type), `clean_jobs` (remove elapsed one-time timers), `disable_job_by_id`/`enable_job_by_id` (disable/enable timers), `write_notify_lines` (append `ExecStopPost` notification lines), `build_flags_string`/`parse_flags` (convert between CLI options and flags format). Key functions: `parse_time` (time spec → OnCalendar), `_write_unit_files` (shared service+timer creation), `create_job`/`create_job_from_edit` (thin wrappers), `edit_jobs` (crontab-style edit with diff-and-apply), `get_managed_units` (find tagged units by type), `clean_jobs` (remove elapsed one-time timers), `disable_job_by_id`/`enable_job_by_id` (disable/enable timers), `write_notify_lines` (append `ExecStopPost` notification lines), `build_flags_string`/`parse_flags` (convert between CLI options and flags format), `resolve_job_id` (resolve hex ID or name to hex ID).
## Testing ## Testing
@ -35,11 +35,11 @@ Key functions: `parse_time` (time spec → OnCalendar), `_write_unit_files` (sha
./test.sh ./test.sh
``` ```
Runs 44 tests against real systemd user timers covering job creation, status, logs, disable/enable, notifications, time format parsing, error cases, and cleanup. All test jobs are cleaned up automatically via trap. Runs 58 tests against real systemd user timers covering job creation, job names, status, logs, disable/enable, notifications, time format parsing, error cases, and cleanup. All test jobs are cleaned up automatically via trap.
## 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. Notification flags use `:` after the ID (e.g., `a1b2c3:i,o,e=user@host`). - Edit mode uses `|` as the field delimiter (not tabs or spaces) to allow multi-word schedules. Flags use `:` after the ID (e.g., `a1b2c3:n=backup,i,o,e=user@host`).
- Notification flags (`i` = desktop, `o`/`o=N` = include output, `e=addr` = email) are persisted as `# SYSTAB_FLAGS=...` comments in service files and as `ExecStopPost=` lines using `$SERVICE_RESULT`/`$EXIT_STATUS` for status-aware messages. Unit file `printf` format strings must use `%%s` (not `%s`) since systemd expands `%s` as a specifier before the shell runs. - Flags (`i` = desktop, `o`/`o=N` = include output, `e=addr` = email, `n=name` = job name) are persisted as `# SYSTAB_FLAGS=...` comments in service files. Names are additionally stored as `# SYSTAB_NAME=...` comments. `ExecStopPost=` lines use `$SERVICE_RESULT`/`$EXIT_STATUS` for status-aware messages. Unit file `printf` format strings must use `%%s` (not `%s`) since systemd expands `%s` as a specifier before the shell runs.
- 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.

View file

@ -28,11 +28,11 @@ Requires `bash`, `systemctl`, and optionally `notify-send` (for `-i`) and `sendm
## Quick start ## Quick start
```bash ```bash
# Run a command every 5 minutes # Run a command every 5 minutes (with a name for easy reference)
systab -t "every 5 minutes" -c "curl -s https://example.com/health" systab -t "every 5 minutes" -n healthcheck -c "curl -s https://example.com/health"
# Run a backup script every day at 2am # Run a backup script every day at 2am
systab -t "every day at 2am" -f ~/backup.sh systab -t "every day at 2am" -n backup -f ~/backup.sh
# Run a one-time command in 30 minutes # Run a one-time command in 30 minutes
systab -t "in 30 minutes" -c "echo reminder" systab -t "in 30 minutes" -c "echo reminder"
@ -67,16 +67,18 @@ systab accepts several time formats:
Relative and absolute formats are parsed by `date -d`. Systemd OnCalendar values are passed through directly. Relative and absolute formats are parsed by `date -d`. Systemd OnCalendar values are passed through directly.
Note: `date -d` does not technically like the "*in* 5 minutes" syntax. `systab` removes the offending "in".
## Usage ## Usage
### Creating jobs ### Creating jobs
```bash ```bash
# Command string # Command string (with optional name)
systab -t "every 5 minutes" -c "echo hello" systab -t "every 5 minutes" -n ping -c "echo hello"
# Script file # Script file
systab -t "every day at 2am" -f ~/backup.sh systab -t "every day at 2am" -n backup -f ~/backup.sh
# From stdin # From stdin
echo "ls -la /tmp" | systab -t daily echo "ls -la /tmp" | systab -t daily
@ -100,23 +102,23 @@ systab -e
# Show status of all jobs # Show status of all jobs
systab -S systab -S
# Show status of a specific job # Show status of a specific job (by ID or name)
systab -S a1b2c3 systab -S a1b2c3
systab -S backup
# View logs (all jobs) # View logs (all jobs)
systab -L systab -L
# View logs for a specific job # View logs for a specific job (by ID or name)
systab -L a1b2c3 systab -L a1b2c3
systab -L backup
# View logs (filtered) # View logs (filtered)
systab -L error systab -L error
# Disable a job # Disable/enable a job (by ID or name)
systab -D <id> systab -D backup
systab -E backup
# Enable a disabled job
systab -E <id>
# Clean up completed one-time jobs # Clean up completed one-time jobs
systab -C systab -C
@ -127,9 +129,9 @@ systab -C
`systab -e` opens your editor with a pipe-delimited job list: `systab -e` opens your editor with a pipe-delimited job list:
``` ```
a1b2c3 | daily | /home/user/backup.sh a1b2c3:n=backup | daily | /home/user/backup.sh
d4e5f6:i | *:0/15 | curl -s https://example.com d4e5f6:i | *:0/15 | curl -s https://example.com
g7h8i9:e=user@host | weekly | ~/backup.sh g7h8i9:n=weekly-backup,e=user@host | weekly | ~/backup.sh
# aabbcc | hourly | echo "this job is disabled" # aabbcc | hourly | echo "this job is disabled"
``` ```
@ -137,11 +139,11 @@ g7h8i9:e=user@host | weekly | ~/backup.sh
- 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 disable, uncomment to enable - Comment out a line (`#`) to disable, uncomment to enable
- Append notification flags after the ID with `:``i` for desktop, `e=addr` for email, `o` for output (default 10 lines), `o=N` for custom count, comma-separated (e.g., `a1b2c3:i,o,e=user@host`) - Append flags after the ID with `:``n=name` for naming, `i` for desktop notification, `e=addr` for email, `o` for output (default 10 lines), `o=N` for custom count, comma-separated (e.g., `a1b2c3:n=backup,i,o,e=user@host`)
### Job IDs ### Job IDs and names
Each job gets a 6-character hex ID (e.g., `a1b2c3`) displayed on creation and in status output. Use this ID with `-D`, `-E`, and `-L`. Each job gets a 6-character hex ID (e.g., `a1b2c3`) displayed on creation and in status output. You can also assign a human-readable name with `-n` at creation time. Names can be used interchangeably with hex IDs in `-D`, `-E`, `-S`, and `-L`. Names must be unique and cannot contain whitespace, pipes, or colons.
## How it works ## How it works
@ -156,6 +158,7 @@ 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)
-n <name> Give the job a human-readable name (usable in place of hex ID)
-i Send desktop notification on completion (success/failure) -i Send desktop notification on completion (success/failure)
-m <email> Send email notification to address (via sendmail) -m <email> Send email notification to address (via sendmail)
-o [lines] Include job output in notifications (default: 10 lines) -o [lines] Include job output in notifications (default: 10 lines)

View file

@ -45,7 +45,7 @@ Down 5
Sleep 500ms Sleep 500ms
# Add a new job line with notification flags # Add a new job line with notification flags
Type "new:i,e=admin@example.com | hourly | curl -s https://example.com/health" Type "new:n=health,i,e=admin@example.com | hourly | curl -s https://example.com/health"
Sleep 1s Sleep 1s
Enter Enter

View file

@ -54,7 +54,7 @@ Enter
Sleep 500ms Sleep 500ms
Show Show
Sleep 1s Sleep 1s
Type "systab -t 'every day at 9am' -c '/home/user/backup.sh' -i -m admin@example.com" Type "systab -t 'every day at 9am' -n backup -c '/home/user/backup.sh' -i -m admin@example.com"
Sleep 500ms Sleep 500ms
Enter Enter
Sleep 2s Sleep 2s

View file

@ -18,7 +18,7 @@ Enter
Sleep 500ms Sleep 500ms
Show Show
Sleep 1s Sleep 1s
Type "systab -t 'every 5 minutes' -c 'echo health check OK'" Type "systab -t 'every 5 minutes' -n healthcheck -c 'echo health check OK'"
Sleep 500ms Sleep 500ms
Enter Enter
Sleep 2s Sleep 2s
@ -76,7 +76,7 @@ Enter
Sleep 500ms Sleep 500ms
Show Show
Sleep 1s Sleep 1s
Type "systab -D $(systab -S 2>/dev/null | grep -m1 'Job:' | awk '{print $2}')" Type "systab -D healthcheck"
Sleep 500ms Sleep 500ms
Enter Enter
Sleep 2s Sleep 2s
@ -105,7 +105,7 @@ Enter
Sleep 500ms Sleep 500ms
Show Show
Sleep 1s Sleep 1s
Type "systab -E $(systab -S 2>/dev/null | grep -m1 'Disabled' -B4 | grep 'Job:' | awk '{print $2}')" Type "systab -E healthcheck"
Sleep 500ms Sleep 500ms
Enter Enter
Sleep 2s Sleep 2s

175
systab
View file

@ -22,6 +22,7 @@ opt_disable=""
opt_enable="" opt_enable=""
opt_filter="" opt_filter=""
opt_output="" opt_output=""
opt_name=""
opt_jobid="" opt_jobid=""
usage() { usage() {
@ -34,6 +35,7 @@ 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
-n <name> Give the job a human-readable name (usable in place of hex ID)
-i Send desktop notification on completion (success/failure) -i Send desktop notification on completion (success/failure)
-m <email> Send email notification to address (via sendmail) -m <email> Send email notification to address (via sendmail)
-o [lines] Include job output in notifications (default: 10 lines) -o [lines] Include job output in notifications (default: 10 lines)
@ -53,11 +55,11 @@ TIME FORMATS:
Systemd: "daily", "weekly", "hourly", "*:0/15" (every 15 min) Systemd: "daily", "weekly", "hourly", "*:0/15" (every 15 min)
EXAMPLES: EXAMPLES:
# Run command every 5 minutes # Run command every 5 minutes with a name
$SCRIPT_NAME -t "every 5 minutes" -c "echo Hello" $SCRIPT_NAME -t "every 5 minutes" -n healthcheck -c "echo Hello"
# Run script every day at 2am with desktop 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" -n backup -f ~/backup.sh -i
# Run command with email notification # Run command with email notification
$SCRIPT_NAME -t "in 5 minutes" -c "echo Hello" -m user@example.com $SCRIPT_NAME -t "in 5 minutes" -c "echo Hello" -m user@example.com
@ -71,9 +73,9 @@ EXAMPLES:
# Edit existing jobs (supports adding notifications via ID:flags syntax) # Edit existing jobs (supports adding notifications via ID:flags syntax)
$SCRIPT_NAME -e $SCRIPT_NAME -e
# Disable and enable a job # Disable and enable a job (by hex ID or name)
$SCRIPT_NAME -D <id> $SCRIPT_NAME -D <id>
$SCRIPT_NAME -E <id> $SCRIPT_NAME -E backup
# View logs for backup jobs # View logs for backup jobs
$SCRIPT_NAME -L backup $SCRIPT_NAME -L backup
@ -209,35 +211,88 @@ enable_job() {
systemctl --user start "${1}.timer" 2>/dev/null || true systemctl --user start "${1}.timer" 2>/dev/null || true
} }
# Resolve a job identifier (hex ID or name) to a 6-char hex ID
# Sets _resolved_id; errors if not found
resolve_job_id() {
local input="$1"
# Try as hex ID first
if [[ "$input" =~ ^[0-9a-f]{6}$ ]]; then
local timer_file="$SYSTEMD_USER_DIR/${SCRIPT_NAME}_${input}.timer"
if [[ -f "$timer_file" ]]; then
_resolved_id="$input"
return
fi
fi
# Try as name: grep service files for SYSTAB_NAME=<input>
local file
for file in "${SYSTEMD_USER_DIR}/${SCRIPT_NAME}"_*.service; do
[[ -f "$file" ]] || continue
if grep -q "^# SYSTAB_NAME=${input}$" "$file" 2>/dev/null; then
local base
base=$(basename "$file" .service)
_resolved_id=$(job_id "$base")
return
fi
done
error "No job found with ID or name: $input"
}
# Get the human-readable name from a service file (empty string if none)
get_job_name() {
local service_file="$1"
sed -n 's/^# SYSTAB_NAME=//p' "$service_file" 2>/dev/null || true
}
# Format a job identifier for display: "id (name)" or just "id"
format_job_id() {
local id="$1" name="$2"
if [[ -n "$name" ]]; then
echo "$id ($name)"
else
echo "$id"
fi
}
# Validate a short ID refers to a managed job, sets _job_name # Validate a short ID refers to a managed job, sets _job_name
validate_job_id() { validate_job_id() {
local id="$1" resolve_job_id "$1"
local id="$_resolved_id"
_job_name="${SCRIPT_NAME}_${id}" _job_name="${SCRIPT_NAME}_${id}"
local timer_file="$SYSTEMD_USER_DIR/${_job_name}.timer" local timer_file="$SYSTEMD_USER_DIR/${_job_name}.timer"
[[ -f "$timer_file" ]] || error "No job found with ID: $id" [[ -f "$timer_file" ]] || error "No job found with ID: $id"
grep -q "^$MARKER" "$timer_file" 2>/dev/null || error "Not a managed job: $id" grep -q "^$MARKER" "$timer_file" 2>/dev/null || error "Not a managed job: $id"
} }
# Disable a job by short ID # Disable a job by short ID or name
disable_job_by_id() { disable_job_by_id() {
validate_job_id "$1" validate_job_id "$1"
local id="$_resolved_id"
local name
name=$(get_job_name "$SYSTEMD_USER_DIR/${_job_name}.service")
local label
label=$(format_job_id "$id" "$name")
if ! is_job_enabled "$_job_name"; then if ! is_job_enabled "$_job_name"; then
echo "Already disabled: $1" echo "Already disabled: $label"
return return
fi fi
disable_job "$_job_name" disable_job "$_job_name"
echo "Disabled: $1" echo "Disabled: $label"
} }
# Enable a job by short ID # Enable a job by short ID or name
enable_job_by_id() { enable_job_by_id() {
validate_job_id "$1" validate_job_id "$1"
local id="$_resolved_id"
local name
name=$(get_job_name "$SYSTEMD_USER_DIR/${_job_name}.service")
local label
label=$(format_job_id "$id" "$name")
if is_job_enabled "$_job_name"; then if is_job_enabled "$_job_name"; then
echo "Already enabled: $1" echo "Already enabled: $label"
return return
fi fi
enable_job "$_job_name" enable_job "$_job_name"
echo "Enabled: $1" echo "Enabled: $label"
} }
# Get all managed unit files of a given type (service or timer) # Get all managed unit files of a given type (service or timer)
@ -251,7 +306,7 @@ get_managed_units() {
done done
} }
# Build flags string from opt_notify and opt_email # Build flags string from opt_notify, opt_email, and opt_name
build_flags_string() { build_flags_string() {
local flags="" local flags=""
if $opt_notify; then if $opt_notify; then
@ -265,15 +320,20 @@ build_flags_string() {
[[ -n "$flags" ]] && flags+="," [[ -n "$flags" ]] && flags+=","
flags+="e=$opt_email" flags+="e=$opt_email"
fi fi
if [[ -n "$opt_name" ]]; then
[[ -n "$flags" ]] && flags+=","
flags+="n=$opt_name"
fi
echo "$flags" echo "$flags"
} }
# Parse flags string into _notify_flag (bool) and _email_addr (string) # Parse flags string into _notify_flag (bool), _email_addr (string), _name (string)
parse_flags() { parse_flags() {
local flags="$1" local flags="$1"
_notify_flag=false _notify_flag=false
_email_addr="" _email_addr=""
_output_lines="" _output_lines=""
_name=""
IFS=',' read -ra parts <<< "$flags" IFS=',' read -ra parts <<< "$flags"
for part in "${parts[@]}"; do for part in "${parts[@]}"; do
case "$part" in case "$part" in
@ -281,15 +341,18 @@ parse_flags() {
o) _output_lines=10 ;; o) _output_lines=10 ;;
o=*) _output_lines="${part#o=}" ;; o=*) _output_lines="${part#o=}" ;;
e=*) _email_addr="${part#e=}" ;; e=*) _email_addr="${part#e=}" ;;
n=*) _name="${part#n=}" ;;
esac esac
done done
} }
# Write ExecStopPost notification lines to a service file # Write ExecStopPost notification lines to a service file
# Usage: write_notify_lines <short_id> <notify_flag> <email_addr> <file> [output_lines] [job_name] # Usage: write_notify_lines <short_id> <notify_flag> <email_addr> <file> [output_lines] [job_name] [display_name]
write_notify_lines() { write_notify_lines() {
local short_id="$1" notify="$2" email="$3" file="$4" local short_id="$1" notify="$2" email="$3" file="$4"
local output_lines="${5-}" job_name="${6-}" local output_lines="${5-}" job_name="${6-}" display_name="${7-}"
local job_label="$short_id"
[[ -n "$display_name" ]] && job_label="$short_id ($display_name)"
local out_cmd="" local out_cmd=""
if [[ -n "$output_lines" && -n "$job_name" ]]; then if [[ -n "$output_lines" && -n "$job_name" ]]; then
out_cmd="out=\$(journalctl --user SYSLOG_IDENTIFIER=$job_name -n $output_lines --no-pager -o cat); " out_cmd="out=\$(journalctl --user SYSLOG_IDENTIFIER=$job_name -n $output_lines --no-pager -o cat); "
@ -302,9 +365,9 @@ write_notify_lines() {
if [[ "$notify" == true ]]; then if [[ "$notify" == true ]]; then
local notify_cmd local notify_cmd
if [[ -n "$out_cmd" ]]; then if [[ -n "$out_cmd" ]]; then
notify_cmd="${out_cmd}body=\$(printf \"Job $short_id: %%s\n%%s\" \"\$s\" \"\$out\"); notify-send -i \"\$icon\" \"systab\" \"\$body\" || true" notify_cmd="${out_cmd}body=\$(printf \"Job $job_label: %%s\n%%s\" \"\$s\" \"\$out\"); notify-send -i \"\$icon\" \"systab\" \"\$body\" || true"
else else
notify_cmd="notify-send -i \"\$icon\" \"systab\" \"Job $short_id: \$s\" || true" notify_cmd="notify-send -i \"\$icon\" \"systab\" \"Job $job_label: \$s\" || true"
fi fi
echo "ExecStopPost=/bin/sh -c '${icon_pre}${notify_cmd}'" >> "$file" echo "ExecStopPost=/bin/sh -c '${icon_pre}${notify_cmd}'" >> "$file"
fi fi
@ -314,9 +377,9 @@ write_notify_lines() {
[[ -n "$mailer" ]] || { warn "No sendmail or msmtp found, skipping email notification"; return; } [[ -n "$mailer" ]] || { warn "No sendmail or msmtp found, skipping email notification"; return; }
local mail_cmd local mail_cmd
if [[ -n "$out_cmd" ]]; then if [[ -n "$out_cmd" ]]; then
mail_cmd="${out_cmd}printf \"Subject: systab: $short_id %%s\\\\n\\\\n%%s at %%s\\\\n\\\\n%%s\\\\n\" \"\$s\" \"\$s\" \"\$(date)\" \"\$out\" | $mailer $email" mail_cmd="${out_cmd}printf \"Subject: systab: $job_label %%s\\\\n\\\\n%%s at %%s\\\\n\\\\n%%s\\\\n\" \"\$s\" \"\$s\" \"\$(date)\" \"\$out\" | $mailer $email"
else else
mail_cmd="printf \"Subject: systab: $short_id %%s\\\\n\\\\n%%s at %%s\\\\n\" \"\$s\" \"\$s\" \"\$(date)\" | $mailer $email" mail_cmd="printf \"Subject: systab: $job_label %%s\\\\n\\\\n%%s at %%s\\\\n\" \"\$s\" \"\$s\" \"\$(date)\" | $mailer $email"
fi fi
echo "ExecStopPost=/bin/sh -c '${status_pre}${mail_cmd}'" >> "$file" echo "ExecStopPost=/bin/sh -c '${status_pre}${mail_cmd}'" >> "$file"
fi fi
@ -352,7 +415,8 @@ EOF
if [[ -n "$flags" ]]; then if [[ -n "$flags" ]]; then
echo "# SYSTAB_FLAGS=$flags" >> "$service_file" echo "# SYSTAB_FLAGS=$flags" >> "$service_file"
parse_flags "$flags" parse_flags "$flags"
write_notify_lines "$short_id" "$_notify_flag" "$_email_addr" "$service_file" "$_output_lines" "$job_name" [[ -n "$_name" ]] && echo "# SYSTAB_NAME=$_name" >> "$service_file"
write_notify_lines "$short_id" "$_notify_flag" "$_email_addr" "$service_file" "$_output_lines" "$job_name" "$_name"
fi fi
# Timer file # Timer file
@ -385,6 +449,19 @@ EOF
_created_id="$short_id" _created_id="$short_id"
} }
# Check that a job name is unique across all managed jobs
check_name_unique() {
local name="$1"
[[ -d "$SYSTEMD_USER_DIR" ]] || return
local file
for file in "${SYSTEMD_USER_DIR}/${SCRIPT_NAME}"_*.service; do
[[ -f "$file" ]] || continue
if grep -q "^# SYSTAB_NAME=${name}$" "$file" 2>/dev/null; then
error "Job name '$name' is already in use"
fi
done
}
# Create a job from CLI options # Create a job from CLI options
create_job() { create_job() {
local command_to_run local command_to_run
@ -403,13 +480,20 @@ create_job() {
[[ -n "$command_to_run" ]] || error "No command provided" [[ -n "$command_to_run" ]] || error "No command provided"
fi fi
# Validate name uniqueness
if [[ -n "$opt_name" ]]; then
check_name_unique "$opt_name"
fi
local time_spec local time_spec
time_spec=$(parse_time "$opt_time") time_spec=$(parse_time "$opt_time")
_write_unit_files "$command_to_run" "$time_spec" "$(build_flags_string)" _write_unit_files "$command_to_run" "$time_spec" "$(build_flags_string)"
systemctl --user daemon-reload systemctl --user daemon-reload
echo "Job created: $_created_id" local label
label=$(format_job_id "$_created_id" "$opt_name")
echo "Job created: $label"
local job_name="${SCRIPT_NAME}_${_created_id}" local job_name="${SCRIPT_NAME}_${_created_id}"
echo "Next run: $(systemctl --user show "$job_name.timer" -p NextElapseUSecRealtime --value 2>/dev/null)" echo "Next run: $(systemctl --user show "$job_name.timer" -p NextElapseUSecRealtime --value 2>/dev/null)"
} }
@ -447,11 +531,12 @@ edit_jobs() {
# 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, # Flags (append to ID with ':'): i = desktop, e=addr = email,
# o = include output (default 10 lines), o=N = include N lines of output # o = include output (default 10 lines), o=N = include N lines of output,
# n=name = human-readable name (usable in place of hex ID)
# a1b2c3:i | daily | cmd desktop notification # a1b2c3:i | daily | cmd desktop notification
# a1b2c3:i,o | daily | cmd desktop with last 10 lines of output # a1b2c3:i,o | daily | cmd desktop with last 10 lines of output
# a1b2c3:i,o=5 | daily | cmd desktop with last 5 lines of output # a1b2c3:n=backup,i | daily | cmd named job with desktop notification
# a1b2c3:e=user@host | daily | cmd email notification # a1b2c3:e=user@host | daily | cmd email notification
# a1b2c3:i,e=user@host | daily | cmd both # a1b2c3:i,e=user@host | daily | cmd both
# #
@ -736,13 +821,14 @@ HEADER
# Handle flags changes # Handle flags changes
if [[ "$old_flags" != "$new_flags" && -f "$service_file" ]]; then if [[ "$old_flags" != "$new_flags" && -f "$service_file" ]]; then
# Remove existing notification lines and flags comment # Remove existing notification lines, flags comment, and name comment
sed -i '/^ExecStopPost=/d; /^# SYSTAB_FLAGS=/d' "$service_file" sed -i '/^ExecStopPost=/d; /^# SYSTAB_FLAGS=/d; /^# SYSTAB_NAME=/d' "$service_file"
# Add new flags and notification lines # Add new flags and notification lines
if [[ -n "$new_flags" ]]; then if [[ -n "$new_flags" ]]; then
echo "# SYSTAB_FLAGS=$new_flags" >> "$service_file" echo "# SYSTAB_FLAGS=$new_flags" >> "$service_file"
parse_flags "$new_flags" parse_flags "$new_flags"
write_notify_lines "$id" "$_notify_flag" "$_email_addr" "$service_file" "$_output_lines" "$jname" [[ -n "$_name" ]] && echo "# SYSTAB_NAME=$_name" >> "$service_file"
write_notify_lines "$id" "$_notify_flag" "$_email_addr" "$service_file" "$_output_lines" "$jname" "$_name"
fi fi
fi fi
@ -771,7 +857,8 @@ HEADER
build_job_list() { build_job_list() {
_job_list=() _job_list=()
if [[ -n "$opt_jobid" ]]; then if [[ -n "$opt_jobid" ]]; then
_job_list+=("${SCRIPT_NAME}_${opt_jobid}") resolve_job_id "$opt_jobid"
_job_list+=("${SCRIPT_NAME}_${_resolved_id}")
else else
local job local job
while IFS= read -r job; do while IFS= read -r job; do
@ -798,7 +885,11 @@ show_status() {
local timer_file="$SYSTEMD_USER_DIR/${job}.timer" local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
local service_file="$SYSTEMD_USER_DIR/${job}.service" local service_file="$SYSTEMD_USER_DIR/${job}.service"
echo "Job: $(job_id "$job")" local id name label
id=$(job_id "$job")
name=$(get_job_name "$SYSTEMD_USER_DIR/${job}.service")
label=$(format_job_id "$id" "$name")
echo "Job: $label"
# Get schedule from timer file # Get schedule from timer file
if [[ -f "$timer_file" ]]; then if [[ -f "$timer_file" ]]; then
@ -871,7 +962,11 @@ list_logs() {
[[ -n "$opt_filter" ]] && grep_args=(--grep "$opt_filter") [[ -n "$opt_filter" ]] && grep_args=(--grep "$opt_filter")
for job in "${_job_list[@]}"; do for job in "${_job_list[@]}"; do
echo "=== Logs for $(job_id "$job") ===" local id name label
id=$(job_id "$job")
name=$(get_job_name "$SYSTEMD_USER_DIR/${job}.service")
label=$(format_job_id "$id" "$name")
echo "=== Logs for $label ==="
if systemctl --user is-active "${job}.timer" &>/dev/null; then if systemctl --user is-active "${job}.timer" &>/dev/null; then
echo "Status: Active" echo "Status: Active"
@ -929,11 +1024,12 @@ clean_jobs() {
# Parse command-line options # Parse command-line options
parse_options() { parse_options() {
while getopts "t:c:f:im:oD:E:eLSCh" opt; do while getopts "t:c:f:n:im:oD:E:eLSCh" opt; do
case $opt in case $opt in
t) opt_time="$OPTARG" ;; t) opt_time="$OPTARG" ;;
c) opt_command="$OPTARG" ;; c) opt_command="$OPTARG" ;;
f) opt_file="$OPTARG" ;; f) opt_file="$OPTARG" ;;
n) opt_name="$OPTARG" ;;
i) opt_notify=true ;; i) opt_notify=true ;;
m) opt_email="$OPTARG" ;; m) opt_email="$OPTARG" ;;
o) # Optional argument: peek at next arg for a number o) # Optional argument: peek at next arg for a number
@ -962,8 +1058,8 @@ parse_options() {
# Check for trailing arguments after -S or -L # Check for trailing arguments after -S or -L
if ($opt_list || $opt_status) && [[ $OPTIND -le $# ]]; then if ($opt_list || $opt_status) && [[ $OPTIND -le $# ]]; then
local trailing="${!OPTIND}" local trailing="${!OPTIND}"
# If it looks like a job ID and the job exists, use as job filter # Try to resolve as job ID or name (run in subshell to catch error exit)
if [[ "$trailing" =~ ^[0-9a-f]{6}$ ]] && [[ -f "$SYSTEMD_USER_DIR/${SCRIPT_NAME}_${trailing}.timer" ]]; then if (resolve_job_id "$trailing") 2>/dev/null; then
opt_jobid="$trailing" opt_jobid="$trailing"
# For -L, check for a second trailing arg as text filter # For -L, check for a second trailing arg as text filter
if $opt_list; then if $opt_list; then
@ -973,10 +1069,17 @@ parse_options() {
fi fi
fi fi
elif $opt_list; then elif $opt_list; then
# Not a job ID — treat as text filter for backward compatibility # Not a job ID or name — treat as text filter for backward compatibility
opt_filter="$trailing" opt_filter="$trailing"
else else
error "No job found with ID: $trailing" error "No job found with ID or name: $trailing"
fi
fi
# Validate name format
if [[ -n "$opt_name" ]]; then
if [[ "$opt_name" =~ [[:space:]|:] ]]; then
error "Job name must not contain whitespace, pipes, or colons"
fi fi
fi fi

35
test.sh
View file

@ -94,9 +94,9 @@ assert_file_contains() {
fi fi
} }
# Extract job ID from "Job created: <id>" output # Extract job ID from "Job created: <id>" or "Job created: <id> (<name>)" output
extract_id() { extract_id() {
sed -n 's/^Job created: \([0-9a-f]\{6\}\)$/\1/p' <<< "$_last_output" sed -n 's/^Job created: \([0-9a-f]\{6\}\)\( .*\)\{0,1\}$/\1/p' <<< "$_last_output"
} }
# Remove all systab_* unit files and reload # Remove all systab_* unit files and reload
@ -271,6 +271,37 @@ assert_success "parse_time 'in 5 minutes' succeeds" bash -c '
parse_time "in 5 minutes" parse_time "in 5 minutes"
' '
# ============================================================
# Job names (-n)
# ============================================================
echo ""
echo "${BOLD}--- Job names ---${RESET}"
assert_output "create job with name" "Job created:" $SYSTAB -t "every 10 minutes" -c "echo named_test" -n mytest
id_named=$(extract_id)
assert_last_output_contains "name appears in creation output" "(mytest)"
assert_file_contains "service file has SYSTAB_NAME" \
"$SYSTEMD_USER_DIR/systab_${id_named}.service" "^# SYSTAB_NAME=mytest$"
assert_output "status by name" "(mytest)" $SYSTAB -S mytest
assert_output "logs by name" "(mytest)" $SYSTAB -L mytest
assert_output "disable by name" "Disabled:" $SYSTAB -D mytest
assert_last_output_contains "disable output shows name" "(mytest)"
assert_output "enable by name" "Enabled:" $SYSTAB -E mytest
assert_last_output_contains "enable output shows name" "(mytest)"
assert_output "status shows name" "(mytest)" $SYSTAB -S
assert_failure "duplicate name rejected" $SYSTAB -t "every 10 minutes" -c "echo dup" -n mytest
assert_failure "name with whitespace rejected" $SYSTAB -t "daily" -c "echo bad" -n "my test"
assert_failure "name with pipe rejected" $SYSTAB -t "daily" -c "echo bad" -n "my|test"
assert_failure "name with colon rejected" $SYSTAB -t "daily" -c "echo bad" -n "my:test"
# ============================================================ # ============================================================
# Error cases # Error cases
# ============================================================ # ============================================================