Introduces a new job type for long-running systemd user services alongside the existing timer-based jobs. Services use Type=simple with Restart=on-failure and WantedBy=default.target — no .timer unit is created. - New -s flag creates a service job; mutually exclusive with -t/-i/-m/-o - Service jobs tagged with # SYSTAB_TYPE=service in their unit file - enable/disable (-E/-D) start/stop the service in addition to toggling the enabled state, mirroring timer behaviour - -S status shows ActiveState/SubState from systemd directly (avoids false "Inactive" for services in activating state) - -L logs, -e edit mode, -D/-E disable/enable all handle service jobs - Edit mode represents service jobs with 'service' as the schedule column (e.g. new:s,n=name | service | /path/to/cmd) - daemon-reload runs before enable/start during service creation so systemd registers the new unit file first - 22 new tests covering unit file contents, active state, disable/enable, named services, edit mode representation, and flag conflict errors - New demo/services.tape and regenerated demo GIFs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"label": "tests",
|
"label": "tests",
|
||||||
"message": "58 passed",
|
"message": "81 passed",
|
||||||
"color": "brightgreen"
|
"color": "brightgreen"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 733 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 619 KiB After Width: | Height: | Size: 656 KiB |
|
Before Width: | Height: | Size: 785 KiB After Width: | Height: | Size: 948 KiB |
BIN
demo/services.gif
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
159
demo/services.tape
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# systab — Persistent Services
|
||||||
|
# Creates a managed service (no timer), checks status, disables/enables,
|
||||||
|
# inspects via edit mode, then cleans up.
|
||||||
|
|
||||||
|
Output demo/services.gif
|
||||||
|
Set Shell bash
|
||||||
|
Set Width 1200
|
||||||
|
Set Height 600
|
||||||
|
Set FontSize 16
|
||||||
|
|
||||||
|
Set TypingSpeed 50ms
|
||||||
|
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# Create a persistent service
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Creating a persistent service job (runs on login, auto-restarts)'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "systab -s -n monitor -c 'sleep 3600'"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Check status — should show Type: Service, Active (running)
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Checking status — service is running'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "systab -S monitor"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Inspect the generated unit file
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Inspecting the generated unit file'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "cat ~/.config/systemd/user/systab_*.service | less"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
Type "/SYSTAB_TYPE"
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
Type "q"
|
||||||
|
Sleep 1s
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Viewing service logs'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "systab -L monitor"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Disable the service
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Disabling the service (stops it)'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "systab -D monitor"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Verify disabled
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Verifying service is disabled/stopped'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "systab -S monitor"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Re-enable
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Re-enabling the service'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "systab -E monitor"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Open edit mode — service appears with 'service' in schedule column
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Service jobs appear in edit mode with schedule = service'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "EDITOR=nano systab -e"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
|
||||||
|
# Just view and exit
|
||||||
|
Ctrl+X
|
||||||
|
Sleep 2s
|
||||||
|
|
||||||
|
# Also show a service created via edit mode
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Creating a service via edit mode: new:s | service | cmd'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "EDITOR=nano systab -e"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
|
||||||
|
Ctrl+V
|
||||||
|
Sleep 500ms
|
||||||
|
Down 5
|
||||||
|
Sleep 500ms
|
||||||
|
Type "new:s,n=watcher | service | sleep 7200"
|
||||||
|
Sleep 1s
|
||||||
|
Enter
|
||||||
|
|
||||||
|
Ctrl+O
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Ctrl+X
|
||||||
|
Sleep 3s
|
||||||
|
|
||||||
|
# Final status
|
||||||
|
Hide
|
||||||
|
Type "./demo/note.sh 'Both services visible in status'"
|
||||||
|
Enter
|
||||||
|
Sleep 500ms
|
||||||
|
Show
|
||||||
|
Sleep 1s
|
||||||
|
Type "systab -S"
|
||||||
|
Sleep 500ms
|
||||||
|
Enter
|
||||||
|
Sleep 3s
|
||||||
|
|
||||||
|
Sleep 2s
|
||||||
228
systab
|
|
@ -26,6 +26,7 @@ opt_filter=""
|
||||||
opt_output=""
|
opt_output=""
|
||||||
opt_name=""
|
opt_name=""
|
||||||
opt_jobid=""
|
opt_jobid=""
|
||||||
|
opt_service=false
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
@ -35,6 +36,7 @@ Create and manage systemd timer jobs with cron/at-like simplicity.
|
||||||
|
|
||||||
Job Creation Options:
|
Job Creation Options:
|
||||||
-t <time> Time specification (see TIME FORMATS below)
|
-t <time> Time specification (see TIME FORMATS below)
|
||||||
|
-s Create a persistent service (no timer; mutually exclusive with -t/-i/-m/-o)
|
||||||
-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)
|
-n <name> Give the job a human-readable name (usable in place of hex ID)
|
||||||
|
|
@ -72,6 +74,9 @@ EXAMPLES:
|
||||||
# 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"
|
||||||
|
|
||||||
|
# Create a persistent service job
|
||||||
|
$SCRIPT_NAME -s -n foobar -c "/usr/bin/foobar.sh"
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
|
@ -198,21 +203,35 @@ job_id() {
|
||||||
echo "${1#"${SCRIPT_NAME}"_}"
|
echo "${1#"${SCRIPT_NAME}"_}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if a job's timer is enabled
|
# Check if a job's timer (or service) is enabled
|
||||||
is_job_enabled() {
|
is_job_enabled() {
|
||||||
|
if is_job_service "$1"; then
|
||||||
|
systemctl --user is-enabled "${1}.service" &>/dev/null
|
||||||
|
else
|
||||||
systemctl --user is-enabled "${1}.timer" &>/dev/null
|
systemctl --user is-enabled "${1}.timer" &>/dev/null
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Disable a job (stop + disable timer)
|
# Disable a job (stop + disable timer or service)
|
||||||
disable_job() {
|
disable_job() {
|
||||||
|
if is_job_service "$1"; then
|
||||||
|
systemctl --user stop "${1}.service" 2>/dev/null || true
|
||||||
|
systemctl --user disable "${1}.service" 2>/dev/null || true
|
||||||
|
else
|
||||||
systemctl --user stop "${1}.timer" 2>/dev/null || true
|
systemctl --user stop "${1}.timer" 2>/dev/null || true
|
||||||
systemctl --user disable "${1}.timer" 2>/dev/null || true
|
systemctl --user disable "${1}.timer" 2>/dev/null || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Enable a job (enable + start timer)
|
# Enable a job (enable + start timer or service)
|
||||||
enable_job() {
|
enable_job() {
|
||||||
|
if is_job_service "$1"; then
|
||||||
|
systemctl --user enable "${1}.service" 2>/dev/null || true
|
||||||
|
systemctl --user start "${1}.service" 2>/dev/null || true
|
||||||
|
else
|
||||||
systemctl --user enable "${1}.timer" 2>/dev/null || true
|
systemctl --user enable "${1}.timer" 2>/dev/null || true
|
||||||
systemctl --user start "${1}.timer" 2>/dev/null || true
|
systemctl --user start "${1}.timer" 2>/dev/null || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Resolve a job identifier (hex ID or name) to a 6-char hex ID
|
# Resolve a job identifier (hex ID or name) to a 6-char hex ID
|
||||||
|
|
@ -222,7 +241,8 @@ resolve_job_id() {
|
||||||
# Try as hex ID first
|
# Try as hex ID first
|
||||||
if [[ "$input" =~ ^[0-9a-f]{6}$ ]]; then
|
if [[ "$input" =~ ^[0-9a-f]{6}$ ]]; then
|
||||||
local timer_file="$SYSTEMD_USER_DIR/${SCRIPT_NAME}_${input}.timer"
|
local timer_file="$SYSTEMD_USER_DIR/${SCRIPT_NAME}_${input}.timer"
|
||||||
if [[ -f "$timer_file" ]]; then
|
local service_file="$SYSTEMD_USER_DIR/${SCRIPT_NAME}_${input}.service"
|
||||||
|
if [[ -f "$timer_file" ]] || { [[ -f "$service_file" ]] && grep -q "^# SYSTAB_TYPE=service$" "$service_file" 2>/dev/null; }; then
|
||||||
_resolved_id="$input"
|
_resolved_id="$input"
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
@ -247,6 +267,23 @@ get_job_name() {
|
||||||
sed -n 's/^# SYSTAB_NAME=//p' "$service_file" 2>/dev/null || true
|
sed -n 's/^# SYSTAB_NAME=//p' "$service_file" 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if a job is a service-only job (not timer-backed)
|
||||||
|
is_job_service() {
|
||||||
|
grep -q "^# SYSTAB_TYPE=service$" "$SYSTEMD_USER_DIR/${1}.service" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get managed service-only units (no timer)
|
||||||
|
get_managed_service_jobs() {
|
||||||
|
[[ -d "$SYSTEMD_USER_DIR" ]] || return
|
||||||
|
local file
|
||||||
|
for file in "$SYSTEMD_USER_DIR"/*.service; do
|
||||||
|
[[ -f "$file" ]] || continue
|
||||||
|
grep -q "^$MARKER" "$file" 2>/dev/null || continue
|
||||||
|
grep -q "^# SYSTAB_TYPE=service$" "$file" 2>/dev/null || continue
|
||||||
|
basename "$file" .service
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# Format a job identifier for display: "id (name)" or just "id"
|
# Format a job identifier for display: "id (name)" or just "id"
|
||||||
format_job_id() {
|
format_job_id() {
|
||||||
local id="$1" name="$2"
|
local id="$1" name="$2"
|
||||||
|
|
@ -262,9 +299,9 @@ validate_job_id() {
|
||||||
resolve_job_id "$1"
|
resolve_job_id "$1"
|
||||||
local id="$_resolved_id"
|
local id="$_resolved_id"
|
||||||
_job_name="${SCRIPT_NAME}_${id}"
|
_job_name="${SCRIPT_NAME}_${id}"
|
||||||
local timer_file="$SYSTEMD_USER_DIR/${_job_name}.timer"
|
local service_file="$SYSTEMD_USER_DIR/${_job_name}.service"
|
||||||
[[ -f "$timer_file" ]] || error "No job found with ID: $id"
|
[[ -f "$service_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" "$service_file" 2>/dev/null || error "Not a managed job: $id"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Toggle a job's enabled state by short ID or name
|
# Toggle a job's enabled state by short ID or name
|
||||||
|
|
@ -303,11 +340,15 @@ get_managed_units() {
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build flags string from opt_notify, opt_email, and opt_name
|
# Build flags string from opt_service, opt_notify, opt_email, and opt_name
|
||||||
build_flags_string() {
|
build_flags_string() {
|
||||||
local flags=""
|
local flags=""
|
||||||
|
if $opt_service; then
|
||||||
|
flags="s"
|
||||||
|
fi
|
||||||
if $opt_notify; then
|
if $opt_notify; then
|
||||||
flags="i"
|
[[ -n "$flags" ]] && flags+=","
|
||||||
|
flags+="i"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$opt_output" ]]; then
|
if [[ -n "$opt_output" ]]; then
|
||||||
[[ -n "$flags" ]] && flags+=","
|
[[ -n "$flags" ]] && flags+=","
|
||||||
|
|
@ -331,9 +372,11 @@ parse_flags() {
|
||||||
_email_addr=""
|
_email_addr=""
|
||||||
_output_lines=""
|
_output_lines=""
|
||||||
_name=""
|
_name=""
|
||||||
|
_service_flag=false
|
||||||
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
|
||||||
|
s) _service_flag=true ;;
|
||||||
i) _notify_flag=true ;;
|
i) _notify_flag=true ;;
|
||||||
o) _output_lines=10 ;;
|
o) _output_lines=10 ;;
|
||||||
o=*) _output_lines="${part#o=}" ;;
|
o=*) _output_lines="${part#o=}" ;;
|
||||||
|
|
@ -382,11 +425,12 @@ write_notify_lines() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Write service + timer unit files, enable and start the timer
|
# Write service + timer unit files (or service-only), enable and start
|
||||||
# Usage: _write_unit_files <command> <schedule> [flags]
|
# Usage: _write_unit_files <command> <schedule> [flags] [job_type]
|
||||||
|
# job_type: "timer" (default) or "service"
|
||||||
# Sets _created_id to the short ID of the new job
|
# Sets _created_id to the short ID of the new job
|
||||||
_write_unit_files() {
|
_write_unit_files() {
|
||||||
local command_to_run="$1" schedule="$2" flags="${3-}"
|
local command_to_run="$1" schedule="$2" flags="${3-}" job_type="${4-timer}"
|
||||||
local job_name short_id
|
local job_name short_id
|
||||||
|
|
||||||
job_name="${SCRIPT_NAME}_$(generate_id)"
|
job_name="${SCRIPT_NAME}_$(generate_id)"
|
||||||
|
|
@ -394,8 +438,39 @@ _write_unit_files() {
|
||||||
|
|
||||||
mkdir -p "$SYSTEMD_USER_DIR"
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
|
|
||||||
# Service file
|
|
||||||
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
|
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
|
||||||
|
|
||||||
|
if [[ "$job_type" == "service" ]]; then
|
||||||
|
# Service-only unit (persistent, no timer)
|
||||||
|
cat > "$service_file" <<EOF
|
||||||
|
$MARKER
|
||||||
|
# SYSTAB_TYPE=service
|
||||||
|
[Unit]
|
||||||
|
Description=$SCRIPT_NAME service: $job_name
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${SHELL:-/bin/bash} -c '$command_to_run'
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=$job_name
|
||||||
|
Restart=on-failure
|
||||||
|
EOF
|
||||||
|
if [[ -n "$flags" ]]; then
|
||||||
|
echo "# SYSTAB_FLAGS=$flags" >> "$service_file"
|
||||||
|
parse_flags "$flags"
|
||||||
|
[[ -n "$_name" ]] && echo "# SYSTAB_NAME=$_name" >> "$service_file"
|
||||||
|
fi
|
||||||
|
cat >> "$service_file" <<EOF
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
EOF
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable "${job_name}.service"
|
||||||
|
systemctl --user start "${job_name}.service"
|
||||||
|
else
|
||||||
|
# Timer-backed unit
|
||||||
cat > "$service_file" <<EOF
|
cat > "$service_file" <<EOF
|
||||||
$MARKER
|
$MARKER
|
||||||
[Unit]
|
[Unit]
|
||||||
|
|
@ -442,6 +517,7 @@ EOF
|
||||||
|
|
||||||
systemctl --user enable "$job_name.timer"
|
systemctl --user enable "$job_name.timer"
|
||||||
systemctl --user start "$job_name.timer"
|
systemctl --user start "$job_name.timer"
|
||||||
|
fi
|
||||||
|
|
||||||
_created_id="$short_id"
|
_created_id="$short_id"
|
||||||
}
|
}
|
||||||
|
|
@ -482,32 +558,53 @@ create_job() {
|
||||||
check_name_unique "$opt_name"
|
check_name_unique "$opt_name"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
local label job_name
|
||||||
|
if $opt_service; then
|
||||||
|
_write_unit_files "$command_to_run" "service" "$(build_flags_string)" service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
job_name="${SCRIPT_NAME}_${_created_id}"
|
||||||
|
label=$(format_job_id "$_created_id" "$opt_name")
|
||||||
|
echo "Service created: $label"
|
||||||
|
if systemctl --user is-active "${job_name}.service" &>/dev/null; then
|
||||||
|
echo "Status: Active (running)"
|
||||||
|
else
|
||||||
|
echo "Status: Inactive (check: systemctl --user status ${job_name}.service)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
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
|
||||||
|
job_name="${SCRIPT_NAME}_${_created_id}"
|
||||||
local label
|
|
||||||
label=$(format_job_id "$_created_id" "$opt_name")
|
label=$(format_job_id "$_created_id" "$opt_name")
|
||||||
echo "Job created: $label"
|
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)"
|
echo "Next run: $(systemctl --user show "$job_name.timer" -p NextElapseUSecRealtime --value 2>/dev/null)"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Remove a managed job (stop, disable, delete unit files)
|
# Remove a managed job (stop, disable, delete unit files)
|
||||||
remove_job() {
|
remove_job() {
|
||||||
local job_name="$1"
|
local job_name="$1"
|
||||||
|
if is_job_service "$job_name"; then
|
||||||
|
systemctl --user stop "${job_name}.service" 2>/dev/null || true
|
||||||
|
systemctl --user disable "${job_name}.service" 2>/dev/null || true
|
||||||
|
rm -f "$SYSTEMD_USER_DIR/${job_name}.service"
|
||||||
|
else
|
||||||
systemctl --user stop "${job_name}.timer" 2>/dev/null || true
|
systemctl --user stop "${job_name}.timer" 2>/dev/null || true
|
||||||
systemctl --user disable "${job_name}.timer" 2>/dev/null || true
|
systemctl --user disable "${job_name}.timer" 2>/dev/null || true
|
||||||
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"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create a job from edit mode (schedule + command + flags, prints short ID)
|
# Create a job from edit mode (schedule + command + flags, prints short ID)
|
||||||
create_job_from_edit() {
|
create_job_from_edit() {
|
||||||
|
if [[ "$1" == "service" ]]; then
|
||||||
|
_write_unit_files "$2" "service" "${3-}" service
|
||||||
|
else
|
||||||
local schedule
|
local schedule
|
||||||
schedule=$(parse_time "$1")
|
schedule=$(parse_time "$1")
|
||||||
_write_unit_files "$2" "$schedule" "${3-}"
|
_write_unit_files "$2" "$schedule" "${3-}"
|
||||||
|
fi
|
||||||
echo "$_created_id"
|
echo "$_created_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -528,7 +625,7 @@ 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.
|
||||||
#
|
#
|
||||||
# Flags (append to ID with ':'): i = desktop, e=addr = email,
|
# Flags (append to ID with ':'): s = service, 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)
|
# n=name = human-readable name (usable in place of hex ID)
|
||||||
# a1b2c3:i | daily | cmd desktop notification
|
# a1b2c3:i | daily | cmd desktop notification
|
||||||
|
|
@ -536,8 +633,11 @@ edit_jobs() {
|
||||||
# a1b2c3:n=backup,i | daily | cmd named job with desktop notification
|
# 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
|
||||||
|
# a1b2c3:s | service | cmd persistent service (no timer)
|
||||||
|
# new:s,n=name | service | cmd new persistent service
|
||||||
#
|
#
|
||||||
# Schedule formats (systemd OnCalendar):
|
# Schedule formats (systemd OnCalendar):
|
||||||
|
# service persistent service (started on login, auto-restarted)
|
||||||
# hourly, daily, weekly, monthly, yearly
|
# hourly, daily, weekly, monthly, yearly
|
||||||
# *:0/15 every 15 minutes
|
# *:0/15 every 15 minutes
|
||||||
# *-*-* 02:00:00 daily at 2am
|
# *-*-* 02:00:00 daily at 2am
|
||||||
|
|
@ -570,6 +670,28 @@ HEADER
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done < <(get_managed_units timer)
|
done < <(get_managed_units timer)
|
||||||
|
|
||||||
|
while IFS= read -r job; do
|
||||||
|
[[ -z "$job" ]] && continue
|
||||||
|
|
||||||
|
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
||||||
|
if [[ -f "$service_file" ]]; then
|
||||||
|
local id command flags id_field
|
||||||
|
id=$(job_id "$job")
|
||||||
|
command=$(get_job_command "$service_file")
|
||||||
|
flags=$(grep "^# SYSTAB_FLAGS=" "$service_file" 2>/dev/null | sed 's/^# SYSTAB_FLAGS=//' || true)
|
||||||
|
if [[ -n "$flags" ]]; then
|
||||||
|
id_field="$id:$flags"
|
||||||
|
else
|
||||||
|
id_field="$id:s"
|
||||||
|
fi
|
||||||
|
if is_job_enabled "$job"; then
|
||||||
|
printf '%s | service | %s\n' "$id_field" "$command"
|
||||||
|
else
|
||||||
|
printf '# %s | service | %s\n' "$id_field" "$command"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < <(get_managed_service_jobs)
|
||||||
} > "$temp_file"
|
} > "$temp_file"
|
||||||
|
|
||||||
# Save original for diffing
|
# Save original for diffing
|
||||||
|
|
@ -603,6 +725,7 @@ HEADER
|
||||||
sched="${sched%%|*}"
|
sched="${sched%%|*}"
|
||||||
trim "$sched"; sched="$_trimmed"
|
trim "$sched"; sched="$_trimmed"
|
||||||
[[ -z "$sched" ]] && continue
|
[[ -z "$sched" ]] && continue
|
||||||
|
[[ "$sched" == "service" ]] && continue
|
||||||
if ! (parse_time "$sched") &>/dev/null; then
|
if ! (parse_time "$sched") &>/dev/null; then
|
||||||
echo "Bad schedule: \"$sched\"" >&2
|
echo "Bad schedule: \"$sched\"" >&2
|
||||||
errors=$((errors + 1))
|
errors=$((errors + 1))
|
||||||
|
|
@ -831,8 +954,12 @@ HEADER
|
||||||
|
|
||||||
# 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
|
||||||
|
if is_job_service "$jname"; then
|
||||||
|
systemctl --user restart "${jname}.service" 2>/dev/null || true
|
||||||
|
else
|
||||||
systemctl --user restart "${jname}.timer" 2>/dev/null || true
|
systemctl --user restart "${jname}.timer" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Updated: $id"
|
echo "Updated: $id"
|
||||||
updated=$((updated + 1))
|
updated=$((updated + 1))
|
||||||
|
|
@ -871,7 +998,22 @@ build_job_list() {
|
||||||
|
|
||||||
# Show status of all managed jobs (or a single job if opt_jobid is set)
|
# Show status of all managed jobs (or a single job if opt_jobid is set)
|
||||||
show_status() {
|
show_status() {
|
||||||
build_job_list timer || return
|
# Build combined list: timer jobs + service-only jobs
|
||||||
|
_job_list=()
|
||||||
|
if [[ -n "$opt_jobid" ]]; then
|
||||||
|
resolve_job_id "$opt_jobid"
|
||||||
|
_job_list+=("${SCRIPT_NAME}_${_resolved_id}")
|
||||||
|
else
|
||||||
|
local job
|
||||||
|
while IFS= read -r job; do
|
||||||
|
[[ -z "$job" ]] && continue
|
||||||
|
_job_list+=("$job")
|
||||||
|
done < <(get_managed_units timer; get_managed_service_jobs)
|
||||||
|
fi
|
||||||
|
if [[ ${#_job_list[@]} -eq 0 ]]; then
|
||||||
|
echo "No managed jobs found."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
local count=${#_job_list[@]}
|
local count=${#_job_list[@]}
|
||||||
echo "Managed Jobs Status - $count total"
|
echo "Managed Jobs Status - $count total"
|
||||||
|
|
@ -888,13 +1030,28 @@ show_status() {
|
||||||
label=$(format_job_id "$id" "$name")
|
label=$(format_job_id "$id" "$name")
|
||||||
echo "Job: $label"
|
echo "Job: $label"
|
||||||
|
|
||||||
# Get schedule from timer file
|
if is_job_service "$job"; then
|
||||||
|
# Service-only job display
|
||||||
|
echo " Type: Service"
|
||||||
|
if [[ -f "$service_file" ]]; then
|
||||||
|
local command
|
||||||
|
command=$(get_job_command "$service_file")
|
||||||
|
echo " Command: $command"
|
||||||
|
fi
|
||||||
|
if ! is_job_enabled "$job"; then
|
||||||
|
echo " Service: Disabled"
|
||||||
|
else
|
||||||
|
local svc_active svc_sub
|
||||||
|
svc_active=$(systemctl --user show "${job}.service" -p ActiveState --value 2>/dev/null)
|
||||||
|
svc_sub=$(systemctl --user show "${job}.service" -p SubState --value 2>/dev/null)
|
||||||
|
echo " Service: ${svc_active} (${svc_sub})"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Timer-backed job display
|
||||||
if [[ -f "$timer_file" ]]; then
|
if [[ -f "$timer_file" ]]; then
|
||||||
local schedule
|
local schedule
|
||||||
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
||||||
echo " Schedule: $schedule"
|
echo " Schedule: $schedule"
|
||||||
|
|
||||||
# Check if recurring
|
|
||||||
if grep -q "^Persistent=false" "$timer_file"; then
|
if grep -q "^Persistent=false" "$timer_file"; then
|
||||||
echo " Type: One-time"
|
echo " Type: One-time"
|
||||||
else
|
else
|
||||||
|
|
@ -902,19 +1059,16 @@ show_status() {
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get command from service file
|
|
||||||
if [[ -f "$service_file" ]]; then
|
if [[ -f "$service_file" ]]; then
|
||||||
local command
|
local command
|
||||||
command=$(get_job_command "$service_file")
|
command=$(get_job_command "$service_file")
|
||||||
echo " Command: $command"
|
echo " Command: $command"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Timer status
|
|
||||||
if ! is_job_enabled "$job"; then
|
if ! is_job_enabled "$job"; then
|
||||||
echo " Timer: Disabled"
|
echo " Timer: Disabled"
|
||||||
elif systemctl --user is-active "${job}.timer" &>/dev/null; then
|
elif systemctl --user is-active "${job}.timer" &>/dev/null; then
|
||||||
echo " Timer: Active"
|
echo " Timer: Active"
|
||||||
|
|
||||||
local next_run last_run
|
local next_run last_run
|
||||||
next_run=$(systemctl --user show "${job}.timer" -p NextElapseUSecRealtime --value 2>/dev/null)
|
next_run=$(systemctl --user show "${job}.timer" -p NextElapseUSecRealtime --value 2>/dev/null)
|
||||||
last_run=$(systemctl --user show "${job}.timer" -p LastTriggerUSec --value 2>/dev/null)
|
last_run=$(systemctl --user show "${job}.timer" -p LastTriggerUSec --value 2>/dev/null)
|
||||||
|
|
@ -924,7 +1078,6 @@ show_status() {
|
||||||
echo " Timer: Inactive/Completed"
|
echo " Timer: Inactive/Completed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Service status
|
|
||||||
if systemctl --user is-failed "${job}.service" &>/dev/null; then
|
if systemctl --user is-failed "${job}.service" &>/dev/null; then
|
||||||
echo " Service: Failed"
|
echo " Service: Failed"
|
||||||
elif systemctl --user is-active "${job}.service" &>/dev/null; then
|
elif systemctl --user is-active "${job}.service" &>/dev/null; then
|
||||||
|
|
@ -938,6 +1091,7 @@ show_status() {
|
||||||
echo " Service: Completed"
|
echo " Service: Completed"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
done
|
done
|
||||||
|
|
@ -965,7 +1119,13 @@ list_logs() {
|
||||||
label=$(format_job_id "$id" "$name")
|
label=$(format_job_id "$id" "$name")
|
||||||
echo "=== Logs for $label ==="
|
echo "=== Logs for $label ==="
|
||||||
|
|
||||||
if systemctl --user is-active "${job}.timer" &>/dev/null; then
|
if is_job_service "$job"; then
|
||||||
|
local svc_active svc_sub
|
||||||
|
svc_active=$(systemctl --user show "${job}.service" -p ActiveState --value 2>/dev/null)
|
||||||
|
svc_sub=$(systemctl --user show "${job}.service" -p SubState --value 2>/dev/null)
|
||||||
|
echo "Status: ${svc_active} (${svc_sub})"
|
||||||
|
systemctl --user status "${job}.service" --no-pager -l 2>/dev/null | head -n 3 || true
|
||||||
|
elif systemctl --user is-active "${job}.timer" &>/dev/null; then
|
||||||
echo "Status: Active"
|
echo "Status: Active"
|
||||||
systemctl --user status "${job}.timer" --no-pager -l | head -n 3
|
systemctl --user status "${job}.timer" --no-pager -l | head -n 3
|
||||||
else
|
else
|
||||||
|
|
@ -1021,9 +1181,10 @@ clean_jobs() {
|
||||||
|
|
||||||
# Parse command-line options
|
# Parse command-line options
|
||||||
parse_options() {
|
parse_options() {
|
||||||
while getopts "t:c:f:n:im:oD:E:eLSCh" opt; do
|
while getopts "t:sc:f:n:im:oD:E:eLSCh" opt; do
|
||||||
case $opt in
|
case $opt in
|
||||||
t) opt_time="$OPTARG" ;;
|
t) opt_time="$OPTARG" ;;
|
||||||
|
s) opt_service=true ;;
|
||||||
c) opt_command="$OPTARG" ;;
|
c) opt_command="$OPTARG" ;;
|
||||||
f) opt_file="$OPTARG" ;;
|
f) opt_file="$OPTARG" ;;
|
||||||
n) opt_name="$OPTARG" ;;
|
n) opt_name="$OPTARG" ;;
|
||||||
|
|
@ -1093,7 +1254,7 @@ parse_options() {
|
||||||
error "Options -D, -E, -e, -L, -S, and -C are mutually exclusive"
|
error "Options -D, -E, -e, -L, -S, and -C are mutually exclusive"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $manage_count -gt 0 && -n "$opt_time$opt_command$opt_file" ]]; then
|
if [[ $manage_count -gt 0 ]] && { [[ -n "$opt_time$opt_command$opt_file" ]] || $opt_service; }; then
|
||||||
error "Management options -D, -E, -e, -L, -S, and -C cannot be used with job creation options"
|
error "Management options -D, -E, -e, -L, -S, and -C cannot be used with job creation options"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -1103,7 +1264,14 @@ parse_options() {
|
||||||
|
|
||||||
# Validate create mode requirements
|
# Validate create mode requirements
|
||||||
if [[ $manage_count -eq 0 ]]; then
|
if [[ $manage_count -eq 0 ]]; then
|
||||||
[[ -n "$opt_time" ]] || error "Option -t is required for job creation"
|
if $opt_service; then
|
||||||
|
[[ -z "$opt_time" ]] || error "Options -s and -t are mutually exclusive"
|
||||||
|
$opt_notify && error "Option -i is not supported for service jobs"
|
||||||
|
[[ -z "$opt_email" ]] || error "Option -m is not supported for service jobs"
|
||||||
|
[[ -z "$opt_output" ]] || error "Option -o is not supported for service jobs"
|
||||||
|
else
|
||||||
|
[[ -n "$opt_time" ]] || error "Option -t or -s is required for job creation"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
82
test.sh
|
|
@ -97,10 +97,10 @@ assert_file_contains() {
|
||||||
# Track test-created job IDs for targeted cleanup
|
# Track test-created job IDs for targeted cleanup
|
||||||
test_job_ids=()
|
test_job_ids=()
|
||||||
|
|
||||||
# Extract job ID from "Job created: <id>" or "Job created: <id> (<name>)" output
|
# Extract job ID from "Job created: <id>", "Service created: <id>", or with name suffix
|
||||||
# Sets _extracted_id and appends to test_job_ids for cleanup tracking
|
# Sets _extracted_id and appends to test_job_ids for cleanup tracking
|
||||||
extract_id() {
|
extract_id() {
|
||||||
_extracted_id=$(sed -n 's/^Job created: \([0-9a-f]\{6\}\)\( .*\)\{0,1\}$/\1/p' <<< "$_last_output")
|
_extracted_id=$(sed -n 's/^\(Job\|Service\) created: \([0-9a-f]\{6\}\)\( .*\)\{0,1\}$/\2/p' <<< "$_last_output")
|
||||||
test_job_ids+=("$_extracted_id")
|
test_job_ids+=("$_extracted_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,6 +333,84 @@ else
|
||||||
pass "-o without -i/-m has no ExecStopPost"
|
pass "-o without -i/-m has no ExecStopPost"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Services (-s)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "${BOLD}--- Services ---${RESET}"
|
||||||
|
|
||||||
|
# Create a persistent service job (sleep 3600 stays running)
|
||||||
|
assert_output "create service job" "Service created:" $SYSTAB -s -c "sleep 3600"
|
||||||
|
extract_id; id_svc=$_extracted_id
|
||||||
|
|
||||||
|
if [[ -z "$id_svc" ]]; then
|
||||||
|
echo "FATAL: could not extract service job ID, aborting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Unit file checks (mirrors tape: cat the service file)
|
||||||
|
assert_file_contains "service file has SYSTAB_TYPE=service" \
|
||||||
|
"$SYSTEMD_USER_DIR/systab_${id_svc}.service" "^# SYSTAB_TYPE=service$"
|
||||||
|
assert_file_contains "service file has Type=simple" \
|
||||||
|
"$SYSTEMD_USER_DIR/systab_${id_svc}.service" "^Type=simple$"
|
||||||
|
assert_file_contains "service file has Restart=on-failure" \
|
||||||
|
"$SYSTEMD_USER_DIR/systab_${id_svc}.service" "^Restart=on-failure$"
|
||||||
|
assert_file_contains "service file has WantedBy=default.target" \
|
||||||
|
"$SYSTEMD_USER_DIR/systab_${id_svc}.service" "^WantedBy=default.target$"
|
||||||
|
|
||||||
|
# No timer file should exist (mirrors tape: no timer)
|
||||||
|
if [[ -f "$SYSTEMD_USER_DIR/systab_${id_svc}.timer" ]]; then
|
||||||
|
fail "service job has no .timer file" "timer file unexpectedly exists"
|
||||||
|
else
|
||||||
|
pass "service job has no .timer file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Service should be active (mirrors tape: "Active (running)")
|
||||||
|
if systemctl --user is-active "systab_${id_svc}.service" &>/dev/null; then
|
||||||
|
pass "service job is active"
|
||||||
|
else
|
||||||
|
fail "service job is active" "service not running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Status shows Type: Service and real systemd state (mirrors tape: systab -S monitor)
|
||||||
|
assert_output "status shows Type: Service" "Type: Service" $SYSTAB -S "$id_svc"
|
||||||
|
assert_output "status shows active state" "Service: active" $SYSTAB -S "$id_svc"
|
||||||
|
|
||||||
|
# Logs work for service jobs (mirrors tape: systab -L monitor)
|
||||||
|
assert_output "logs for service job" "Logs for" $SYSTAB -L "$id_svc"
|
||||||
|
|
||||||
|
# Service with a name (mirrors tape: -s -n monitor)
|
||||||
|
assert_output "create service job with name" "Service created:" $SYSTAB -s -n svctest -c "sleep 3600"
|
||||||
|
extract_id; id_svc_named=$_extracted_id
|
||||||
|
assert_last_output_contains "service name appears in creation output" "(svctest)"
|
||||||
|
assert_file_contains "service file has SYSTAB_NAME" \
|
||||||
|
"$SYSTEMD_USER_DIR/systab_${id_svc_named}.service" "^# SYSTAB_NAME=svctest$"
|
||||||
|
|
||||||
|
# Disable stops the service (mirrors tape: systab -D monitor)
|
||||||
|
assert_output "disable service job" "Disabled:" $SYSTAB -D "$id_svc"
|
||||||
|
assert_output "disabled service shows in status" "Disabled" $SYSTAB -S "$id_svc"
|
||||||
|
assert_output "disable already disabled service" "Already disabled:" $SYSTAB -D "$id_svc"
|
||||||
|
|
||||||
|
# Enable restarts the service (mirrors tape: systab -E monitor)
|
||||||
|
assert_output "enable service job" "Enabled:" $SYSTAB -E "$id_svc"
|
||||||
|
assert_output "enable already enabled service" "Already enabled:" $SYSTAB -E "$id_svc"
|
||||||
|
|
||||||
|
# Edit mode shows service jobs with 'service' in schedule column
|
||||||
|
# (mirrors tape: EDITOR=nano systab -e shows "id:s | service | cmd")
|
||||||
|
edit_output=$(EDITOR=cat $SYSTAB -e 2>&1 || true)
|
||||||
|
if [[ "$edit_output" == *"| service |"* ]]; then
|
||||||
|
pass "edit mode shows service job with 'service' schedule"
|
||||||
|
else
|
||||||
|
fail "edit mode shows service job with 'service' schedule" "not found in: $edit_output"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Mutually exclusive flags (mirrors tape design: -s conflicts with -t/-i/-m/-o)
|
||||||
|
assert_failure "-s and -t are mutually exclusive" $SYSTAB -s -t daily -c "echo test"
|
||||||
|
assert_failure "-s and -i are mutually exclusive" $SYSTAB -s -i -c "echo test"
|
||||||
|
assert_failure "-s and -m are mutually exclusive" $SYSTAB -s -m user@example.com -c "echo test"
|
||||||
|
assert_failure "-s and -o are mutually exclusive" $SYSTAB -s -o -c "echo test"
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Clean
|
# Clean
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
|
||||||