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:
- **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):
- `-D <id>` / `-E <id>`: Disable (stop+disable) or enable (enable+start) a job's timer.
- `-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).
- `-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 [id]`: Show timer status via `systemctl`, including short IDs and disabled state. Optional job ID to show a single job.
- `-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`). 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|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|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).
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
@ -35,11 +35,11 @@ Key functions: `parse_time` (time spec → OnCalendar), `_write_unit_files` (sha
./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
- 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`).
- 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.
- 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`).
- 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.

View file

@ -28,11 +28,11 @@ Requires `bash`, `systemctl`, and optionally `notify-send` (for `-i`) and `sendm
## Quick start
```bash
# Run a command every 5 minutes
systab -t "every 5 minutes" -c "curl -s https://example.com/health"
# Run a command every 5 minutes (with a name for easy reference)
systab -t "every 5 minutes" -n healthcheck -c "curl -s https://example.com/health"
# 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
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.
Note: `date -d` does not technically like the "*in* 5 minutes" syntax. `systab` removes the offending "in".
## Usage
### Creating jobs
```bash
# Command string
systab -t "every 5 minutes" -c "echo hello"
# Command string (with optional name)
systab -t "every 5 minutes" -n ping -c "echo hello"
# Script file
systab -t "every day at 2am" -f ~/backup.sh
systab -t "every day at 2am" -n backup -f ~/backup.sh
# From stdin
echo "ls -la /tmp" | systab -t daily
@ -100,23 +102,23 @@ systab -e
# Show status of all jobs
systab -S
# Show status of a specific job
# Show status of a specific job (by ID or name)
systab -S a1b2c3
systab -S backup
# View logs (all jobs)
systab -L
# View logs for a specific job
# View logs for a specific job (by ID or name)
systab -L a1b2c3
systab -L backup
# View logs (filtered)
systab -L error
# Disable a job
systab -D <id>
# Enable a disabled job
systab -E <id>
# Disable/enable a job (by ID or name)
systab -D backup
systab -E backup
# Clean up completed one-time jobs
systab -C
@ -127,9 +129,9 @@ systab -C
`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
g7h8i9:e=user@host | weekly | ~/backup.sh
g7h8i9:n=weekly-backup,e=user@host | weekly | ~/backup.sh
# 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
- 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
- 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
@ -156,6 +158,7 @@ Job Creation:
-t <time> Time specification (required for job creation)
-c <command> Command string to execute
-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)
-m <email> Send email notification to address (via sendmail)
-o [lines] Include job output in notifications (default: 10 lines)

View file

@ -45,7 +45,7 @@ Down 5
Sleep 500ms
# 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
Enter

View file

@ -54,7 +54,7 @@ Enter
Sleep 500ms
Show
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
Enter
Sleep 2s

View file

@ -18,7 +18,7 @@ Enter
Sleep 500ms
Show
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
Enter
Sleep 2s
@ -76,7 +76,7 @@ Enter
Sleep 500ms
Show
Sleep 1s
Type "systab -D $(systab -S 2>/dev/null | grep -m1 'Job:' | awk '{print $2}')"
Type "systab -D healthcheck"
Sleep 500ms
Enter
Sleep 2s
@ -105,7 +105,7 @@ Enter
Sleep 500ms
Show
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
Enter
Sleep 2s

175
systab
View file

@ -22,6 +22,7 @@ opt_disable=""
opt_enable=""
opt_filter=""
opt_output=""
opt_name=""
opt_jobid=""
usage() {
@ -34,6 +35,7 @@ Job Creation Options:
-t <time> Time specification (see TIME FORMATS below)
-c <command> Command string 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)
-m <email> Send email notification to address (via sendmail)
-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)
EXAMPLES:
# Run command every 5 minutes
$SCRIPT_NAME -t "every 5 minutes" -c "echo Hello"
# Run command every 5 minutes with a name
$SCRIPT_NAME -t "every 5 minutes" -n healthcheck -c "echo Hello"
# 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
$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)
$SCRIPT_NAME -e
# Disable and enable a job
# Disable and enable a job (by hex ID or name)
$SCRIPT_NAME -D <id>
$SCRIPT_NAME -E <id>
$SCRIPT_NAME -E backup
# View logs for backup jobs
$SCRIPT_NAME -L backup
@ -209,35 +211,88 @@ enable_job() {
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_job_id() {
local id="$1"
resolve_job_id "$1"
local id="$_resolved_id"
_job_name="${SCRIPT_NAME}_${id}"
local timer_file="$SYSTEMD_USER_DIR/${_job_name}.timer"
[[ -f "$timer_file" ]] || error "No job found with ID: $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() {
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
echo "Already disabled: $1"
echo "Already disabled: $label"
return
fi
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() {
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
echo "Already enabled: $1"
echo "Already enabled: $label"
return
fi
enable_job "$_job_name"
echo "Enabled: $1"
echo "Enabled: $label"
}
# Get all managed unit files of a given type (service or timer)
@ -251,7 +306,7 @@ get_managed_units() {
done
}
# Build flags string from opt_notify and opt_email
# Build flags string from opt_notify, opt_email, and opt_name
build_flags_string() {
local flags=""
if $opt_notify; then
@ -265,15 +320,20 @@ build_flags_string() {
[[ -n "$flags" ]] && flags+=","
flags+="e=$opt_email"
fi
if [[ -n "$opt_name" ]]; then
[[ -n "$flags" ]] && flags+=","
flags+="n=$opt_name"
fi
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() {
local flags="$1"
_notify_flag=false
_email_addr=""
_output_lines=""
_name=""
IFS=',' read -ra parts <<< "$flags"
for part in "${parts[@]}"; do
case "$part" in
@ -281,15 +341,18 @@ parse_flags() {
o) _output_lines=10 ;;
o=*) _output_lines="${part#o=}" ;;
e=*) _email_addr="${part#e=}" ;;
n=*) _name="${part#n=}" ;;
esac
done
}
# 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() {
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=""
if [[ -n "$output_lines" && -n "$job_name" ]]; then
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
local notify_cmd
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
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
echo "ExecStopPost=/bin/sh -c '${icon_pre}${notify_cmd}'" >> "$file"
fi
@ -314,9 +377,9 @@ write_notify_lines() {
[[ -n "$mailer" ]] || { warn "No sendmail or msmtp found, skipping email notification"; return; }
local mail_cmd
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
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
echo "ExecStopPost=/bin/sh -c '${status_pre}${mail_cmd}'" >> "$file"
fi
@ -352,7 +415,8 @@ EOF
if [[ -n "$flags" ]]; then
echo "# SYSTAB_FLAGS=$flags" >> "$service_file"
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
# Timer file
@ -385,6 +449,19 @@ EOF
_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_job() {
local command_to_run
@ -403,13 +480,20 @@ create_job() {
[[ -n "$command_to_run" ]] || error "No command provided"
fi
# Validate name uniqueness
if [[ -n "$opt_name" ]]; then
check_name_unique "$opt_name"
fi
local time_spec
time_spec=$(parse_time "$opt_time")
_write_unit_files "$command_to_run" "$time_spec" "$(build_flags_string)"
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}"
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
# Comment out a line to disable, uncomment to re-enable.
#
# Notification flags (append to ID with ':'): i = desktop, e=addr = email,
# o = include output (default 10 lines), o=N = include N lines of output
# Flags (append to ID with ':'): i = desktop, e=addr = email,
# 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,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:i,e=user@host | daily | cmd both
#
@ -736,13 +821,14 @@ HEADER
# Handle flags changes
if [[ "$old_flags" != "$new_flags" && -f "$service_file" ]]; then
# Remove existing notification lines and flags comment
sed -i '/^ExecStopPost=/d; /^# SYSTAB_FLAGS=/d' "$service_file"
# Remove existing notification lines, flags comment, and name comment
sed -i '/^ExecStopPost=/d; /^# SYSTAB_FLAGS=/d; /^# SYSTAB_NAME=/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" "$_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
@ -771,7 +857,8 @@ HEADER
build_job_list() {
_job_list=()
if [[ -n "$opt_jobid" ]]; then
_job_list+=("${SCRIPT_NAME}_${opt_jobid}")
resolve_job_id "$opt_jobid"
_job_list+=("${SCRIPT_NAME}_${_resolved_id}")
else
local job
while IFS= read -r job; do
@ -798,7 +885,11 @@ show_status() {
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
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
if [[ -f "$timer_file" ]]; then
@ -871,7 +962,11 @@ list_logs() {
[[ -n "$opt_filter" ]] && grep_args=(--grep "$opt_filter")
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
echo "Status: Active"
@ -929,11 +1024,12 @@ clean_jobs() {
# Parse command-line 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
t) opt_time="$OPTARG" ;;
c) opt_command="$OPTARG" ;;
f) opt_file="$OPTARG" ;;
n) opt_name="$OPTARG" ;;
i) opt_notify=true ;;
m) opt_email="$OPTARG" ;;
o) # Optional argument: peek at next arg for a number
@ -962,8 +1058,8 @@ parse_options() {
# Check for trailing arguments after -S or -L
if ($opt_list || $opt_status) && [[ $OPTIND -le $# ]]; then
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
# Try to resolve as job ID or name (run in subshell to catch error exit)
if (resolve_job_id "$trailing") 2>/dev/null; then
opt_jobid="$trailing"
# For -L, check for a second trailing arg as text filter
if $opt_list; then
@ -973,13 +1069,20 @@ parse_options() {
fi
fi
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"
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
# Validate mutually exclusive options
local manage_count=0
[[ -n "$opt_disable" ]] && manage_count=$((manage_count + 1))

35
test.sh
View file

@ -94,9 +94,9 @@ assert_file_contains() {
fi
}
# Extract job ID from "Job created: <id>" output
# Extract job ID from "Job created: <id>" or "Job created: <id> (<name>)" output
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
@ -271,6 +271,37 @@ assert_success "parse_time 'in 5 minutes' succeeds" bash -c '
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
# ============================================================