Implement full -E edit mode with short IDs
Add 6-char hex short IDs (SYSTAB_ID) embedded in unit files for human-friendly job identification. The -E flag now opens a real crontab-like editor where jobs can be created, updated, and deleted by editing tab-separated ID/SCHEDULE/COMMAND lines. Legacy jobs without IDs get one auto-assigned on first edit. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bfb273dc66
commit
456111627d
1 changed files with 295 additions and 46 deletions
341
systab
341
systab
|
|
@ -1,20 +1,12 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# catbsysd - A cron/at/batch-like interface for systemd
|
# systab - A cron/at/batch-like interface for systemd
|
||||||
# Managed jobs are marked with: # CATBSYSD_MANAGED
|
# Managed jobs are marked with: # SYSTAB_MANAGED
|
||||||
|
|
||||||
readonly SCRIPT_NAME="systab"
|
readonly SCRIPT_NAME="systab"
|
||||||
readonly SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
|
readonly SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
|
||||||
readonly MARKER="# CATBSYSD_MANAGED"
|
readonly MARKER="# SYSTAB_MANAGED"
|
||||||
readonly CRONTAB_EXAMPLE="# Example crontab format (for reference):
|
|
||||||
# Min Hour Day Month DayOfWeek Command
|
|
||||||
# * * * * * /path/to/command
|
|
||||||
# 0 2 * * * /path/to/nightly-job
|
|
||||||
# */15 * * * * /path/to/every-15-min
|
|
||||||
#
|
|
||||||
# Actual systemd timers below (OnCalendar format):
|
|
||||||
"
|
|
||||||
|
|
||||||
# Global variables for options
|
# Global variables for options
|
||||||
opt_time=""
|
opt_time=""
|
||||||
|
|
@ -128,6 +120,56 @@ generate_job_name() {
|
||||||
echo "${SCRIPT_NAME}_${timestamp}_$$"
|
echo "${SCRIPT_NAME}_${timestamp}_$$"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Generate a random 6-char hex short ID
|
||||||
|
generate_short_id() {
|
||||||
|
od -An -tx1 -N3 /dev/urandom | tr -d ' \n'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract SYSTAB_ID from a job's timer file; empty if missing
|
||||||
|
get_job_id() {
|
||||||
|
local job_name="$1"
|
||||||
|
local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer"
|
||||||
|
if [[ -f "$timer_file" ]]; then
|
||||||
|
sed -n 's/^# SYSTAB_ID=//p' "$timer_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find job name by short ID; empty if not found
|
||||||
|
get_job_by_id() {
|
||||||
|
local target_id="$1"
|
||||||
|
local job
|
||||||
|
while IFS= read -r job; do
|
||||||
|
[[ -z "$job" ]] && continue
|
||||||
|
local jid
|
||||||
|
jid=$(get_job_id "$job")
|
||||||
|
if [[ "$jid" == "$target_id" ]]; then
|
||||||
|
echo "$job"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done < <(get_managed_timers)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure a managed job has a SYSTAB_ID; assign one if missing
|
||||||
|
ensure_job_id() {
|
||||||
|
local job_name="$1"
|
||||||
|
local existing_id
|
||||||
|
existing_id=$(get_job_id "$job_name")
|
||||||
|
if [[ -n "$existing_id" ]]; then
|
||||||
|
echo "$existing_id"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local new_id
|
||||||
|
new_id=$(generate_short_id)
|
||||||
|
# Insert SYSTAB_ID line after the MARKER in both files
|
||||||
|
for ext in timer service; do
|
||||||
|
local unit_file="$SYSTEMD_USER_DIR/${job_name}.${ext}"
|
||||||
|
if [[ -f "$unit_file" ]]; then
|
||||||
|
sed -i "s/^$MARKER$/&\n# SYSTAB_ID=$new_id/" "$unit_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$new_id"
|
||||||
|
}
|
||||||
|
|
||||||
# Get all managed service files
|
# Get all managed service files
|
||||||
get_managed_services() {
|
get_managed_services() {
|
||||||
if [[ ! -d "$SYSTEMD_USER_DIR" ]]; then
|
if [[ ! -d "$SYSTEMD_USER_DIR" ]]; then
|
||||||
|
|
@ -194,11 +236,16 @@ create_job() {
|
||||||
|
|
||||||
# Create systemd user directory if needed
|
# Create systemd user directory if needed
|
||||||
mkdir -p "$SYSTEMD_USER_DIR"
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
|
|
||||||
|
# Generate short ID for human-friendly display
|
||||||
|
local short_id
|
||||||
|
short_id=$(generate_short_id)
|
||||||
|
|
||||||
# Create service file
|
# Create service file
|
||||||
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
|
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
|
||||||
cat > "$service_file" <<EOF
|
cat > "$service_file" <<EOF
|
||||||
$MARKER
|
$MARKER
|
||||||
|
# SYSTAB_ID=$short_id
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=$SCRIPT_NAME job: $job_name
|
Description=$SCRIPT_NAME job: $job_name
|
||||||
|
|
||||||
|
|
@ -238,6 +285,7 @@ EOF
|
||||||
local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer"
|
local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer"
|
||||||
cat > "$timer_file" <<EOF
|
cat > "$timer_file" <<EOF
|
||||||
$MARKER
|
$MARKER
|
||||||
|
# SYSTAB_ID=$short_id
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Timer for $SCRIPT_NAME job: $job_name
|
Description=Timer for $SCRIPT_NAME job: $job_name
|
||||||
|
|
||||||
|
|
@ -260,46 +308,235 @@ 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"
|
||||||
|
|
||||||
echo "Job created: $job_name"
|
echo "Job created: $short_id ($job_name)"
|
||||||
echo "Next run: $(systemctl --user list-timers "$job_name.timer" --no-pager | tail -n 2 | head -n 1 | awk '{print $1, $2, $3}')"
|
echo "Next run: $(systemctl --user list-timers "$job_name.timer" --no-pager | tail -n 2 | head -n 1 | awk '{print $1, $2, $3}')"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Remove a managed job (stop, disable, delete unit files)
|
||||||
|
remove_job() {
|
||||||
|
local job_name="$1"
|
||||||
|
systemctl --user stop "${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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create a job from edit mode (schedule + command, returns short ID)
|
||||||
|
create_job_from_edit() {
|
||||||
|
local schedule="$1" command_to_run="$2"
|
||||||
|
local job_name short_id
|
||||||
|
|
||||||
|
job_name=$(generate_job_name)
|
||||||
|
short_id=$(generate_short_id)
|
||||||
|
|
||||||
|
mkdir -p "$SYSTEMD_USER_DIR"
|
||||||
|
|
||||||
|
# Service file
|
||||||
|
cat > "$SYSTEMD_USER_DIR/${job_name}.service" <<EOF
|
||||||
|
$MARKER
|
||||||
|
# SYSTAB_ID=$short_id
|
||||||
|
[Unit]
|
||||||
|
Description=$SCRIPT_NAME job: $job_name
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=${SHELL:-/bin/bash} -c '$command_to_run'
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Timer file
|
||||||
|
cat > "$SYSTEMD_USER_DIR/${job_name}.timer" <<EOF
|
||||||
|
$MARKER
|
||||||
|
# SYSTAB_ID=$short_id
|
||||||
|
[Unit]
|
||||||
|
Description=Timer for $SCRIPT_NAME job: $job_name
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=$schedule
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if ! is_recurring "$schedule"; then
|
||||||
|
echo "Persistent=false" >> "$SYSTEMD_USER_DIR/${job_name}.timer"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> "$SYSTEMD_USER_DIR/${job_name}.timer" <<EOF
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl --user enable "$job_name.timer"
|
||||||
|
systemctl --user start "$job_name.timer"
|
||||||
|
|
||||||
|
echo "$short_id ($job_name)"
|
||||||
|
}
|
||||||
|
|
||||||
# Edit jobs in crontab-like format
|
# Edit jobs in crontab-like format
|
||||||
edit_jobs() {
|
edit_jobs() {
|
||||||
local temp_file
|
local temp_file orig_file
|
||||||
temp_file="${TMPDIR:-/dev/shm}/catbsysd_edit_$$"
|
temp_file="${TMPDIR:-/dev/shm}/systab_edit_$$"
|
||||||
trap 'rm -f "$temp_file"' EXIT
|
orig_file="${TMPDIR:-/dev/shm}/systab_orig_$$"
|
||||||
|
trap 'rm -f "$temp_file" "$orig_file"' EXIT
|
||||||
# Create temp file with header
|
|
||||||
echo "$CRONTAB_EXAMPLE" > "$temp_file"
|
# Build crontab content
|
||||||
|
{
|
||||||
# Add existing jobs
|
cat <<'HEADER'
|
||||||
|
# systab jobs — edit schedule/command, add/remove lines
|
||||||
|
# Format: ID SCHEDULE COMMAND
|
||||||
|
# Lines starting with # are ignored. Remove a line to delete a job.
|
||||||
|
# Add a line with "new" as ID to create a job: new daily /path/to/cmd
|
||||||
|
HEADER
|
||||||
|
|
||||||
|
local job
|
||||||
|
while IFS= read -r job; do
|
||||||
|
[[ -z "$job" ]] && continue
|
||||||
|
|
||||||
|
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
|
||||||
|
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
||||||
|
|
||||||
|
if [[ -f "$timer_file" && -f "$service_file" ]]; then
|
||||||
|
local short_id schedule command
|
||||||
|
short_id=$(ensure_job_id "$job")
|
||||||
|
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
||||||
|
command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//")
|
||||||
|
printf '%s\t%s\t%s\n' "$short_id" "$schedule" "$command"
|
||||||
|
fi
|
||||||
|
done < <(get_managed_timers)
|
||||||
|
} > "$temp_file"
|
||||||
|
|
||||||
|
# Save original for diffing
|
||||||
|
cp "$temp_file" "$orig_file"
|
||||||
|
|
||||||
|
# Open in editor
|
||||||
|
if ! "${EDITOR:-vi}" "$temp_file"; then
|
||||||
|
echo "Editor exited with error, aborting."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for no changes
|
||||||
|
if diff -q "$orig_file" "$temp_file" &>/dev/null; then
|
||||||
|
echo "No changes."
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Parse files into associative arrays: id -> "schedule\tcmd"
|
||||||
|
declare -A orig_jobs edited_jobs
|
||||||
|
# Also track id order for original (to map id -> job_name)
|
||||||
|
declare -A id_to_jobname
|
||||||
|
|
||||||
|
# Build id -> jobname mapping from current managed timers
|
||||||
local job
|
local job
|
||||||
while IFS= read -r job; do
|
while IFS= read -r job; do
|
||||||
[[ -z "$job" ]] && continue
|
[[ -z "$job" ]] && continue
|
||||||
|
local jid
|
||||||
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
|
jid=$(get_job_id "$job")
|
||||||
local service_file="$SYSTEMD_USER_DIR/${job}.service"
|
if [[ -n "$jid" ]]; then
|
||||||
|
id_to_jobname["$jid"]="$job"
|
||||||
if [[ -f "$timer_file" && -f "$service_file" ]]; then
|
|
||||||
local schedule command
|
|
||||||
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
|
|
||||||
command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//")
|
|
||||||
|
|
||||||
echo "# Job: $job"
|
|
||||||
echo "# Schedule: $schedule"
|
|
||||||
echo "# Command: $command"
|
|
||||||
echo ""
|
|
||||||
fi
|
fi
|
||||||
done < <(get_managed_timers)
|
done < <(get_managed_timers)
|
||||||
|
|
||||||
# Open in editor
|
# Parse original file
|
||||||
"${EDITOR:-vi}" "$temp_file"
|
while IFS= read -r line; do
|
||||||
|
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
|
||||||
# Note: Full parsing and updating would be complex
|
local id sched cmd
|
||||||
# For now, just show the jobs for reference
|
id=$(printf '%s' "$line" | cut -f1)
|
||||||
echo "Note: Editing support is view-only. Use -C to clean up jobs."
|
sched=$(printf '%s' "$line" | cut -f2)
|
||||||
echo "Use command-line options to create new jobs."
|
cmd=$(printf '%s' "$line" | cut -f3-)
|
||||||
|
[[ -n "$id" && -n "$sched" ]] || continue
|
||||||
|
orig_jobs["$id"]="${sched} ${cmd}"
|
||||||
|
done < "$orig_file"
|
||||||
|
|
||||||
|
# Parse edited file
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
|
||||||
|
local id sched cmd
|
||||||
|
id=$(printf '%s' "$line" | cut -f1)
|
||||||
|
sched=$(printf '%s' "$line" | cut -f2)
|
||||||
|
cmd=$(printf '%s' "$line" | cut -f3-)
|
||||||
|
[[ -n "$id" && -n "$sched" ]] || continue
|
||||||
|
edited_jobs["$id"]="${sched} ${cmd}"
|
||||||
|
done < "$temp_file"
|
||||||
|
|
||||||
|
local created=0 deleted=0 updated=0 needs_reload=false
|
||||||
|
|
||||||
|
# Deletions: IDs in original but not in edited
|
||||||
|
for id in "${!orig_jobs[@]}"; do
|
||||||
|
if [[ -z "${edited_jobs[$id]+x}" ]]; then
|
||||||
|
local jname="${id_to_jobname[$id]:-}"
|
||||||
|
if [[ -n "$jname" ]]; then
|
||||||
|
remove_job "$jname"
|
||||||
|
echo "Deleted: $id ($jname)"
|
||||||
|
deleted=$((deleted + 1))
|
||||||
|
needs_reload=true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Creations: IDs in edited but not in original (must be "new")
|
||||||
|
for id in "${!edited_jobs[@]}"; do
|
||||||
|
if [[ -z "${orig_jobs[$id]+x}" ]]; then
|
||||||
|
if [[ "$id" != "new" ]]; then
|
||||||
|
warn "Ignoring unknown ID '$id' — use 'new' to create jobs"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
local entry="${edited_jobs[$id]}"
|
||||||
|
local sched cmd
|
||||||
|
sched=$(printf '%s' "$entry" | cut -f1)
|
||||||
|
cmd=$(printf '%s' "$entry" | cut -f2-)
|
||||||
|
local result
|
||||||
|
result=$(create_job_from_edit "$sched" "$cmd")
|
||||||
|
echo "Created: $result"
|
||||||
|
created=$((created + 1))
|
||||||
|
needs_reload=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Updates: IDs in both but with changed values
|
||||||
|
for id in "${!edited_jobs[@]}"; do
|
||||||
|
[[ "$id" == "new" ]] && continue
|
||||||
|
if [[ -n "${orig_jobs[$id]+x}" && "${orig_jobs[$id]}" != "${edited_jobs[$id]}" ]]; then
|
||||||
|
local jname="${id_to_jobname[$id]:-}"
|
||||||
|
if [[ -z "$jname" ]]; then
|
||||||
|
warn "Cannot find job for ID '$id', skipping update"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
local old_entry="${orig_jobs[$id]}" new_entry="${edited_jobs[$id]}"
|
||||||
|
local old_sched new_sched old_cmd new_cmd
|
||||||
|
old_sched=$(printf '%s' "$old_entry" | cut -f1)
|
||||||
|
new_sched=$(printf '%s' "$new_entry" | cut -f1)
|
||||||
|
old_cmd=$(printf '%s' "$old_entry" | cut -f2-)
|
||||||
|
new_cmd=$(printf '%s' "$new_entry" | cut -f2-)
|
||||||
|
|
||||||
|
local timer_file="$SYSTEMD_USER_DIR/${jname}.timer"
|
||||||
|
local service_file="$SYSTEMD_USER_DIR/${jname}.service"
|
||||||
|
|
||||||
|
if [[ "$old_sched" != "$new_sched" && -f "$timer_file" ]]; then
|
||||||
|
sed -i "s|^OnCalendar=.*|OnCalendar=$new_sched|" "$timer_file"
|
||||||
|
|
||||||
|
# Update Persistent line based on new schedule
|
||||||
|
if is_recurring "$new_sched"; then
|
||||||
|
sed -i '/^Persistent=false$/d' "$timer_file"
|
||||||
|
elif ! grep -q "^Persistent=false" "$timer_file"; then
|
||||||
|
sed -i "/^OnCalendar=/a Persistent=false" "$timer_file"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$old_cmd" != "$new_cmd" && -f "$service_file" ]]; then
|
||||||
|
sed -i "s|^ExecStart=.*|ExecStart=${SHELL:-/bin/bash} -c '$new_cmd'|" "$service_file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl --user restart "${jname}.timer" 2>/dev/null || true
|
||||||
|
echo "Updated: $id ($jname)"
|
||||||
|
updated=$((updated + 1))
|
||||||
|
needs_reload=true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if $needs_reload; then
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Summary: $created created, $updated updated, $deleted deleted"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show status of all managed jobs
|
# Show status of all managed jobs
|
||||||
|
|
@ -325,8 +562,14 @@ show_status() {
|
||||||
for job in "${job_list[@]}"; do
|
for job in "${job_list[@]}"; do
|
||||||
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"
|
local short_id
|
||||||
|
short_id=$(get_job_id "$job")
|
||||||
|
if [[ -n "$short_id" ]]; then
|
||||||
|
echo "Job: $job (ID: $short_id)"
|
||||||
|
else
|
||||||
|
echo "Job: $job"
|
||||||
|
fi
|
||||||
|
|
||||||
# Get schedule from timer file
|
# Get schedule from timer file
|
||||||
if [[ -f "$timer_file" ]]; then
|
if [[ -f "$timer_file" ]]; then
|
||||||
|
|
@ -391,7 +634,7 @@ show_status() {
|
||||||
# Show systemctl list-timers summary
|
# Show systemctl list-timers summary
|
||||||
echo "Active Timers:"
|
echo "Active Timers:"
|
||||||
echo "--------------"
|
echo "--------------"
|
||||||
systemctl --user list-timers "catbsysd_*" --no-pager 2>/dev/null || echo "none active"
|
systemctl --user list-timers "systab_*" --no-pager 2>/dev/null || echo "none active"
|
||||||
}
|
}
|
||||||
|
|
||||||
# List logs
|
# List logs
|
||||||
|
|
@ -420,7 +663,13 @@ list_logs() {
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
for job in "${job_list[@]}"; do
|
for job in "${job_list[@]}"; do
|
||||||
echo "=== Logs for $job ==="
|
local short_id
|
||||||
|
short_id=$(get_job_id "$job")
|
||||||
|
if [[ -n "$short_id" ]]; then
|
||||||
|
echo "=== Logs for $job (ID: $short_id) ==="
|
||||||
|
else
|
||||||
|
echo "=== Logs for $job ==="
|
||||||
|
fi
|
||||||
|
|
||||||
# Show timer status
|
# Show timer status
|
||||||
if systemctl --user is-active "${job}.timer" &>/dev/null; then
|
if systemctl --user is-active "${job}.timer" &>/dev/null; then
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue