diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 39e69cc..c56f7af 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -27,11 +27,24 @@ fi echo "Running tests..." if output=$(./test.sh 2>&1); then echo "$output" - count=$(grep -oP '\d+ passed' <<< "$output" | tail -1) - write_badge "${count:-passing}" "brightgreen" + test_count=$(grep -oP '\d+(?= passed)' <<< "$output" | tail -1) else echo "$output" write_badge "failing" "red" echo "Tests failed — commit blocked." exit 1 fi + +echo "Running demo tape tests..." +if tape_output=$(./demo/test-tapes.sh 2>&1); then + echo "$tape_output" + tape_count=$(grep -oP '\d+(?= passed)' <<< "$tape_output" | tail -1) +else + echo "$tape_output" + write_badge "failing" "red" + echo "Demo tape tests failed — commit blocked." + exit 1 +fi + +total_count=$(( ${test_count:-0} + ${tape_count:-0} )) +write_badge "${total_count} passed" "brightgreen" diff --git a/README.md b/README.md index 1ba3a74..80d067f 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,16 @@ Because you want to use systemd, but miss the ease of ~crontab~`systab -e`! - 📋 access the logs of any job - 💪 enable and disable timers and services +

Edit mode

+ - - - - + + - - - +
Quick start demoEdit mode demoNotifications demoServices demoQuick start demoAll features demo
Quick startEdit modeNotificationsServicesAll features
@@ -184,7 +182,7 @@ systab -C ### Job IDs and names -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. +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`, `-X`, `-R`, `-S`, and `-L`. Names must be unique and cannot contain whitespace, pipes, or colons. ## How it works @@ -210,6 +208,8 @@ Job Creation: Management (accept hex ID or name): -D Disable a job -E Enable a disabled job + -X Delete a job (stop, disable, and remove unit files) + -R Restart a job (resets timer countdown / restarts service process) -e Edit jobs in crontab-like format -l Print jobs in crontab-like format to stdout -L [id|name] [filter] List job logs (optionally for a specific job and/or filtered) @@ -220,7 +220,8 @@ Management (accept hex ID or name): ## Future feature ideas -- [ ] `-R` flag to restart / reload +- [x] `-R` flag to restart / reload +- [x] `-X` flag to delete ## License @@ -238,6 +239,15 @@ After cloning, enable the pre-commit hook (runs ShellCheck + tests): git config core.hooksPath .githooks ``` +Common tasks via [`just`](https://github.com/casey/just): + +```bash +just check # lint + unit tests + tape tests +just test # unit tests only +just lint # ShellCheck only +just record # re-record demo GIFs with VHS +``` + ## FAQ diff --git a/badges/tests.json b/badges/tests.json index 3e4522a..b571791 100644 --- a/badges/tests.json +++ b/badges/tests.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, "label": "tests", - "message": "88 passed", + "message": "131 passed", "color": "brightgreen" } diff --git a/demo/all-features.gif b/demo/all-features.gif new file mode 100644 index 0000000..6bb5ab0 Binary files /dev/null and b/demo/all-features.gif differ diff --git a/demo/all-features.tape b/demo/all-features.tape new file mode 100644 index 0000000..da64117 --- /dev/null +++ b/demo/all-features.tape @@ -0,0 +1,206 @@ +# systab — All Features +# A complete walkthrough: timers, services, status, logs, +# disable/enable, list mode, notifications, and edit mode. + +Output demo/all-features.gif +Set Shell bash +Set Width 1200 +Set Height 700 +Set FontSize 16 + +Set TypingSpeed 50ms + +Sleep 1s + +# Clean up any pre-existing jobs with names used in this demo +Hide +Type "systab -X healthcheck_home 2>/dev/null; systab -X monitor_home 2>/dev/null; systab -X backup_home 2>/dev/null; systab -X health2_home 2>/dev/null; true" +Enter +Sleep 1s +Show + +# ── Job Creation ────────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'Recurring timer with a name'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -t 'every 5 minutes' -n healthcheck_home -c 'uptime'" +Sleep 500ms +Enter +Sleep 2s + +Hide +Type "./demo/note.sh 'One-time reminder with desktop notification'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -t 'in 30 minutes' -c 'echo reminder: meeting soon' -i" +Sleep 500ms +Enter +Sleep 2s + +Hide +Type "./demo/note.sh 'Persistent service — starts on login, auto-restarts on failure'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -s -n monitor_home -c 'ping -c 1 localhost'" +Sleep 500ms +Enter +Sleep 2s + +# ── Status ──────────────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'Status of all jobs and services'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -S" +Sleep 500ms +Enter +Sleep 3s + +# ── Logs ────────────────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'Viewing logs for a specific job'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -L healthcheck_home" +Sleep 500ms +Enter +Sleep 2s + +# ── Disable / Enable ────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'Disabling a job by name'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -D healthcheck_home" +Sleep 500ms +Enter +Sleep 1s + +Type "systab -S healthcheck_home" +Sleep 500ms +Enter +Sleep 2s + +Hide +Type "./demo/note.sh 'Re-enabling the job'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -E healthcheck_home" +Sleep 500ms +Enter +Sleep 2s + +# ── Restart ─────────────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'Restart a job — resets the timer countdown'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -R healthcheck_home" +Sleep 500ms +Enter +Sleep 2s + +# ── Notifications ───────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'Add desktop notification with -i (fires on job completion)'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -t 'daily' -n backup_home -c '/home/user/backup.sh' -i" +Sleep 500ms +Enter +Sleep 2s + +# ── List mode ───────────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'List all jobs in crontab format with -l'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -l" +Sleep 500ms +Enter +Sleep 2s + +# ── Edit mode ───────────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'Edit mode — manage jobs in your $EDITOR'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "EDITOR=nano systab -e" +Sleep 500ms +Enter +Sleep 3s + +# Navigate to end of file in nano and add a new job with flags +Ctrl+V +Sleep 500ms +Down 5 +Sleep 500ms +Type "new:n=health2_home,i,e=admin@example.com | hourly | curl -s https://example.com/health" +Sleep 1s +Enter +Sleep 500ms + +Ctrl+O +Sleep 500ms +Enter +Sleep 500ms +Ctrl+X +Sleep 3s + +# Show updated status +Hide +Type "./demo/note.sh 'Status after edit — new job added with flags'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -S" +Sleep 500ms +Enter +Sleep 3s + +# ── Clean ───────────────────────────────────────────────────── + +Hide +Type "./demo/note.sh 'Clean up completed one-time timers'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "systab -C" +Sleep 500ms +Enter +Sleep 2s + +Sleep 2s diff --git a/demo/editmode.gif b/demo/editmode.gif deleted file mode 100644 index 5b9a644..0000000 Binary files a/demo/editmode.gif and /dev/null differ diff --git a/demo/editmode.png b/demo/editmode.png new file mode 100644 index 0000000..df075e8 Binary files /dev/null and b/demo/editmode.png differ diff --git a/demo/editmode.tape b/demo/editmode.tape deleted file mode 100644 index 8ddf4e9..0000000 --- a/demo/editmode.tape +++ /dev/null @@ -1,107 +0,0 @@ -# systab — Edit Mode with Notification Flags -# Demonstrates the crontab-style editor with :flags syntax. - -Output demo/editmode.gif -Set Shell bash -Set Width 1200 -Set Height 600 -Set FontSize 16 - -Set TypingSpeed 50ms - -Sleep 1s - -# First create some jobs to edit -Hide -Type "./demo/note.sh 'Creating some jobs to work with'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -t 'every 5 minutes' -c 'echo ping'" -Enter -Sleep 2s -Type "systab -t daily -c '/home/user/backup.sh' -i" -Enter -Sleep 2s - -# List jobs in crontab format without opening editor -Hide -Type "./demo/note.sh 'Listing jobs in crontab format (-l)'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -l" -Sleep 500ms -Enter -Sleep 2s - -# Open edit mode with EDITOR=nano for visibility -Hide -Type "./demo/note.sh 'Opening edit mode — add notifications, create and modify jobs'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "EDITOR=nano systab -e" -Sleep 500ms -Enter -Sleep 3s - -# In nano: navigate to the end of the file -# Ctrl+V = page down in nano, then Down to reach last line -Ctrl+V -Sleep 500ms -Down 5 -Sleep 500ms - -# Add a new job line with notification flags -Type "new:n=health,i,e=admin@example.com | hourly | curl -s https://example.com/health" -Sleep 1s -Enter - -Sleep 1s - -# Save and exit nano -Ctrl+O -Sleep 500ms -Enter -Sleep 500ms -Ctrl+X -Sleep 3s - -# Show the result -Hide -Type "./demo/note.sh 'Changes applied — checking status'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -S | less" -Sleep 500ms -Enter -Sleep 2s -Type "/Job:" -Enter -Sleep 3s -Type "q" -Sleep 1s - -# Re-open edit mode to show flags are persisted -Hide -Type "./demo/note.sh 'Re-opening edit mode — flags are persisted'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "EDITOR=nano systab -e" -Sleep 500ms -Enter -Sleep 3s - -# Just view and exit without changes -Ctrl+X -Sleep 2s - -Sleep 2s diff --git a/demo/notifications.gif b/demo/notifications.gif deleted file mode 100644 index 0c5d060..0000000 Binary files a/demo/notifications.gif and /dev/null differ diff --git a/demo/notifications.tape b/demo/notifications.tape deleted file mode 100644 index bd14cc6..0000000 --- a/demo/notifications.tape +++ /dev/null @@ -1,96 +0,0 @@ -# systab — Notifications -# Shows status-aware desktop and email notifications. - -Output demo/notifications.gif -Set Shell bash -Set Width 1200 -Set Height 600 -Set FontSize 16 - -Set TypingSpeed 50ms - -Sleep 1s - -# Desktop notification — success case -Hide -Type "./demo/note.sh 'Desktop notification on success'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -t 'in 1 minute' -c 'echo success' -i" -Sleep 500ms -Enter -Sleep 2s - -# Desktop notification — failure case -Hide -Type "./demo/note.sh 'Desktop notification on failure'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -t 'in 1 minute' -c 'exit 1' -i" -Sleep 500ms -Enter -Sleep 2s - -# Email notification -Hide -Type "./demo/note.sh 'Email notification via sendmail'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -t 'in 1 minute' -c 'echo test' -m user@example.com" -Sleep 500ms -Enter -Sleep 2s - -# Both notifications -Hide -Type "./demo/note.sh 'Both desktop and email notifications'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -t 'every day at 9am' -n backup -c '/home/user/backup.sh' -i -m admin@example.com" -Sleep 500ms -Enter -Sleep 2s - -# Show the generated service file to see ExecStopPost lines -Hide -Type "./demo/note.sh 'Inspecting generated service file'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "cat ~/.config/systemd/user/systab_*.service | less" -Sleep 500ms -Enter -Sleep 2s -Type "/ExecStopPost" -Enter -Sleep 3s -Type "q" -Sleep 1s - -# Check status -Hide -Type "./demo/note.sh 'Checking status of all jobs'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -S | less" -Sleep 500ms -Enter -Sleep 2s -Type "/Job:" -Enter -Sleep 3s -Type "q" -Sleep 1s - -Sleep 2s diff --git a/demo/quickstart.gif b/demo/quickstart.gif index f815788..5f27161 100644 Binary files a/demo/quickstart.gif and b/demo/quickstart.gif differ diff --git a/demo/quickstart.tape b/demo/quickstart.tape index 05f3bd5..1fc4f12 100644 --- a/demo/quickstart.tape +++ b/demo/quickstart.tape @@ -1,5 +1,5 @@ # systab — Quick Start -# Creates a few jobs, checks status, views logs, then cleans up. +# Create a timer, create a service, then use edit mode to add a new job. Output demo/quickstart.gif Set Shell bash @@ -11,113 +11,87 @@ Set TypingSpeed 50ms Sleep 1s -# Create a recurring job +# Clean up any pre-existing jobs with names used in this demo Hide -Type "./demo/note.sh 'Creating a recurring health check (every 5 minutes)'" +Type "systab -X healthcheck_home 2>/dev/null; systab -X monitor_home 2>/dev/null; systab -X diskcheck_home 2>/dev/null; true" +Enter +Sleep 1s +Show + +# Create a recurring timer +Hide +Type "./demo/note.sh 'Create a recurring timer'" Enter Sleep 500ms Show Sleep 1s -Type "systab -t 'every 5 minutes' -n healthcheck -c 'echo health check OK'" +Type "systab -t 'every 5 minutes' -n healthcheck_home -c 'uptime'" Sleep 500ms Enter Sleep 2s -# Create a one-time job +# Create a persistent service Hide -Type "./demo/note.sh 'Creating a one-time reminder (30 minutes from now)'" +Type "./demo/note.sh 'Create a persistent service'" Enter Sleep 500ms Show Sleep 1s -Type "systab -t 'in 30 minutes' -c 'echo reminder: meeting soon'" +Type "systab -s -n monitor_home -c 'ping -c 1 localhost'" Sleep 500ms Enter Sleep 2s -# Check status of all jobs +# Both visible in status Hide -Type "./demo/note.sh 'Checking status of all jobs'" +Type "./demo/note.sh 'Both jobs visible in status'" Enter Sleep 500ms Show Sleep 1s -Type "systab -S | less" +Type "systab -S" Sleep 500ms Enter Sleep 2s -Type "/Job:" + +# Open edit mode: screenshot then add a new job +Hide +Type "./demo/note.sh 'Edit mode — add a new job'" +Enter +Sleep 500ms +Show +Sleep 1s +Type "EDITOR=nano systab -e" +Sleep 500ms Enter Sleep 3s -Type "q" -Sleep 1s -# View logs -Hide -Type "./demo/note.sh 'Viewing job logs'" +Screenshot demo/editmode.png + +# Navigate to end of file in nano and add a new job +Ctrl+V +Sleep 500ms +Down 5 +Sleep 500ms +Type "new:n=diskcheck_home | daily | df -h" Enter Sleep 500ms -Show -Sleep 1s -Type "systab -L | less" + +Ctrl+O Sleep 500ms Enter -Sleep 2s -Type "/Logs for" -Enter +Sleep 500ms +Ctrl+X Sleep 3s -Type "q" -Sleep 1s -# Disable a job (uses the first job ID from status) +# Show updated status Hide -Type "./demo/note.sh 'Disabling a job'" +Type "./demo/note.sh 'Status after edit — new job added'" Enter Sleep 500ms Show Sleep 1s -Type "systab -D healthcheck" -Sleep 500ms -Enter -Sleep 2s - -# Show it's disabled -Hide -Type "./demo/note.sh 'Verifying job is disabled'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -S | less" -Sleep 500ms -Enter -Sleep 2s -Type "/Disabled" -Enter -Sleep 3s -Type "q" -Sleep 1s - -# Enable it -Hide -Type "./demo/note.sh 'Enabling the disabled job'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -E healthcheck" -Sleep 500ms -Enter -Sleep 2s - -# Clean up completed one-time jobs -Hide -Type "./demo/note.sh 'Cleaning up completed one-time jobs'" -Enter -Sleep 500ms -Show -Sleep 1s -Type "systab -C" +Type "systab -S" Sleep 500ms Enter Sleep 2s diff --git a/demo/services.gif b/demo/services.gif deleted file mode 100644 index c4fd41e..0000000 Binary files a/demo/services.gif and /dev/null differ diff --git a/demo/services.tape b/demo/services.tape deleted file mode 100644 index 8d57873..0000000 --- a/demo/services.tape +++ /dev/null @@ -1,159 +0,0 @@ -# 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 diff --git a/demo/test-tapes.sh b/demo/test-tapes.sh new file mode 100755 index 0000000..c605bbf --- /dev/null +++ b/demo/test-tapes.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# test-tapes.sh — Verify all systab commands in VHS tape files run correctly. +# Reads each tape line-by-line, tracking Hide/Show blocks. Only runs +# Type "systab ..." and Type "EDITOR=..." lines that are in visible (Show) blocks. +# +# Run from the project root: ./demo/test-tapes.sh + +set -uo pipefail + +# Make ./systab callable as 'systab', matching how tapes reference it +PATH="$PWD:$PATH" +export PATH + +TAPE_DIR="demo" +SYSTEMD_USER_DIR="${HOME}/.config/systemd/user" + +passed=0 +failed=0 +total=0 +tape_job_ids=() + +if [[ -t 1 ]]; then + GREEN=$'\033[32m' RED=$'\033[31m' BOLD=$'\033[1m' RESET=$'\033[0m' +else + GREEN="" RED="" BOLD="" RESET="" +fi + +pass() { + echo "${GREEN}[PASS]${RESET} $1" + passed=$((passed + 1)) + total=$((total + 1)) +} + +fail() { + echo "${RED}[FAIL]${RESET} $1 — $2" + failed=$((failed + 1)) + total=$((total + 1)) +} + +# Stop, disable, and remove all unit files created during tape tests +cleanup_tape_jobs() { + [[ ${#tape_job_ids[@]} -eq 0 ]] && return + for id in "${tape_job_ids[@]}"; do + [[ -z "$id" ]] && continue + for ext in service timer; do + local f="$SYSTEMD_USER_DIR/systab_${id}.${ext}" + [[ -f "$f" ]] || continue + systemctl --user stop "systab_${id}.${ext}" 2>/dev/null || true + systemctl --user disable "systab_${id}.${ext}" 2>/dev/null || true + rm -f "$f" + done + done + systemctl --user daemon-reload 2>/dev/null || true + tape_job_ids=() +} + +trap cleanup_tape_jobs EXIT + +# Collect any job IDs from command output into tape_job_ids +collect_ids() { + local id + while IFS= read -r id; do + [[ -n "$id" ]] && tape_job_ids+=("$id") + done < <(sed -n 's/^\(Job\|Service\) created: \([0-9a-f]\{6\}\).*$/\2/p' <<< "$1") +} + +# Prepare a tape command for test execution: +# - EDITOR=nano → EDITOR=cat (non-interactive; cat exits 0, no file changes) +# - strip trailing " | less" (we capture stdout directly) +normalize() { + local cmd="$1" + cmd="${cmd//EDITOR=nano/EDITOR=cat}" + cmd="${cmd% | less}" + echo "$cmd" +} + +# Run all systab commands from one tape file in order +run_tape() { + local tape="$1" + local tape_name + tape_name=$(basename "$tape" .tape) + + echo "" + echo "${BOLD}=== $tape_name ===${RESET}" + + cleanup_tape_jobs # clean up IDs from previous tape + + # Pre-clean named jobs from this tape to handle leftovers from failed runs + local name + while IFS= read -r name; do + systab -X "$name" 2>/dev/null || true + done < <(grep -E '^Type "systab .*-n ' "$tape" | grep -oP '(?<=-n )\w+') + + local line raw cmd output exit_code in_hide=false + while IFS= read -r line; do + # Track Hide/Show blocks — skip commands in hidden sections + [[ "$line" == "Hide" ]] && { in_hide=true; continue; } + [[ "$line" == "Show" ]] && { in_hide=false; continue; } + $in_hide && continue + + # Only process visible Type lines with systab or EDITOR= commands + [[ "$line" == 'Type "systab '* ]] || [[ "$line" == 'Type "EDITOR='* ]] || continue + + raw="${line#Type \"}" + raw="${raw%\"}" + cmd=$(normalize "$raw") + exit_code=0 + + # Pipe /dev/null for commands that read stdin interactively + if [[ "$cmd" == *"systab -C"* ]] || [[ "$cmd" == *"systab -e"* ]]; then + output=$(eval "$cmd" < /dev/null 2>&1) || exit_code=$? + else + output=$(eval "$cmd" 2>&1) || exit_code=$? + fi + + collect_ids "$output" + + if [[ $exit_code -eq 0 ]]; then + pass "$tape_name: $raw" + else + fail "$tape_name: $raw" "exit $exit_code: ${output:0:120}" + fi + done < "$tape" +} + +echo "${BOLD}Testing systab commands from VHS tape files...${RESET}" + +for tape in "$TAPE_DIR"/*.tape; do + run_tape "$tape" +done + +echo "" +echo "---" +echo "${BOLD}${total} tape commands: ${GREEN}${passed} passed${RESET}, ${RED}${failed} failed${RESET}" +[[ $failed -eq 0 ]] diff --git a/justfile b/justfile new file mode 100644 index 0000000..7cb9146 --- /dev/null +++ b/justfile @@ -0,0 +1,25 @@ +# systab task runner — `just` to list all recipes + +# List available recipes +default: + @just --list + +# Run ShellCheck linter +lint: + shellcheck systab + +# Run unit tests +test: + ./test.sh + +# Run demo tape command tests (no VHS required) +tape-test: + ./demo/test-tapes.sh + +# Run all checks: lint + unit tests + tape tests +check: lint test tape-test + +# Record demo GIFs with VHS +record: + vhs demo/quickstart.tape + vhs demo/all-features.tape diff --git a/systab b/systab index e21273f..0c5fb6e 100755 --- a/systab +++ b/systab @@ -23,6 +23,8 @@ opt_clean=false opt_status=false opt_disable="" opt_enable="" +opt_delete="" +opt_restart="" opt_filter="" opt_output="" opt_name="" @@ -48,6 +50,8 @@ Job Creation Options: Management Options (accept hex ID or name): -D Disable a job -E Enable a disabled job + -X Delete a job (stop, disable, and remove unit files) + -R Restart a job (resets timer countdown / restarts service process) -e Edit jobs in crontab-like format -l Print jobs in crontab-like format to stdout -L [id|name] [filter] List job logs (optionally for a specific job and/or filtered) @@ -89,6 +93,14 @@ EXAMPLES: $SCRIPT_NAME -D $SCRIPT_NAME -E backup + # Delete a job permanently + $SCRIPT_NAME -X + $SCRIPT_NAME -X backup + + # Restart a job + $SCRIPT_NAME -R + $SCRIPT_NAME -R healthcheck_home + # View logs for backup jobs $SCRIPT_NAME -L backup @@ -333,6 +345,40 @@ toggleJobById() { fi } +# Delete a job (stop, disable, remove unit files) by hex ID or name +deleteJob() { + local input="$1" + validateJobId "$input" + local id="$_resolved_id" + local name + name=$(getJobName "$SYSTEMD_USER_DIR/${_job_name}.service") + local label + label=$(formatJobId "$id" "$name") + removeJob "$_job_name" + systemctl --user daemon-reload + echo "Deleted: $label" +} + +# Restart a job by hex ID or name +restartJob() { + local input="$1" + validateJobId "$input" + local id="$_resolved_id" + local name + name=$(getJobName "$SYSTEMD_USER_DIR/${_job_name}.service") + local label + label=$(formatJobId "$id" "$name") + if ! isJobEnabled "$_job_name"; then + error "Job is disabled, enable it first with -E: $label" + fi + if isJobService "$_job_name"; then + systemctl --user restart "${_job_name}.service" 2>/dev/null || true + else + systemctl --user restart "${_job_name}.timer" 2>/dev/null || true + fi + echo "Restarted: $label" +} + # Get all managed unit files of a given type (service or timer) getManagedUnits() { local ext="$1" @@ -1185,7 +1231,7 @@ cleanJobs() { # Parse command-line options parseOptions() { - while getopts "t:sc:f:n:im:oD:E:elLSCh" opt; do + while getopts "t:sc:f:n:im:oD:E:X:R:elLSCh" opt; do case $opt in t) opt_time="$OPTARG" ;; s) opt_service=true ;; @@ -1208,6 +1254,8 @@ parseOptions() { fi ;; D) opt_disable="$OPTARG" ;; E) opt_enable="$OPTARG" ;; + X) opt_delete="$OPTARG" ;; + R) opt_restart="$OPTARG" ;; e) opt_edit=true ;; l) opt_print_crontab=true ;; L) opt_list=true ;; @@ -1250,6 +1298,8 @@ parseOptions() { local manage_count=0 [[ -n "$opt_disable" ]] && manage_count=$((manage_count + 1)) [[ -n "$opt_enable" ]] && manage_count=$((manage_count + 1)) + [[ -n "$opt_delete" ]] && manage_count=$((manage_count + 1)) + [[ -n "$opt_restart" ]] && manage_count=$((manage_count + 1)) $opt_edit && manage_count=$((manage_count + 1)) $opt_print_crontab && manage_count=$((manage_count + 1)) $opt_list && manage_count=$((manage_count + 1)) @@ -1257,11 +1307,11 @@ parseOptions() { $opt_clean && manage_count=$((manage_count + 1)) if [[ $manage_count -gt 1 ]]; then - error "Options -D, -E, -e, -l, -L, -S, and -C are mutually exclusive" + error "Options -D, -E, -X, -R, -e, -l, -L, -S, and -C are mutually exclusive" fi if [[ $manage_count -gt 0 ]] && { [[ -n "$opt_time$opt_command$opt_file" ]] || $opt_service; }; then - error "Management options -D, -E, -e, -l, -L, -S, and -C cannot be used with job creation options" + error "Management options -D, -E, -X, -R, -e, -l, -L, -S, and -C cannot be used with job creation options" fi if [[ -n "$opt_command" && -n "$opt_file" ]]; then @@ -1290,6 +1340,10 @@ main() { toggleJobById "$opt_disable" disable elif [[ -n "$opt_enable" ]]; then toggleJobById "$opt_enable" enable + elif [[ -n "$opt_delete" ]]; then + deleteJob "$opt_delete" + elif [[ -n "$opt_restart" ]]; then + restartJob "$opt_restart" elif $opt_edit; then editJobs elif $opt_print_crontab; then diff --git a/test.sh b/test.sh index 972d94e..8672148 100755 --- a/test.sh +++ b/test.sh @@ -188,6 +188,58 @@ assert_output "enable job" "Enabled:" $SYSTAB -E "$id_recurring" assert_output "enable already enabled" "Already enabled:" $SYSTAB -E "$id_recurring" +# ============================================================ +# Delete (-X) +# ============================================================ + +echo "" +echo "${BOLD}--- Delete (-X) ---${RESET}" + +assert_output "create timer job for deletion" "Job created:" $SYSTAB -t "every 30 minutes" -c "echo delete_test" +extract_id; id_del=$_extracted_id + +assert_output "delete timer job by ID" "Deleted:" $SYSTAB -X "$id_del" +assert_last_output_contains "delete output contains job ID" "$id_del" + +if [[ ! -f "$SYSTEMD_USER_DIR/systab_${id_del}.service" ]]; then + pass "service file removed after delete" +else + fail "service file removed after delete" "file still exists" +fi +if [[ ! -f "$SYSTEMD_USER_DIR/systab_${id_del}.timer" ]]; then + pass "timer file removed after delete" +else + fail "timer file removed after delete" "file still exists" +fi + +assert_output "create named job for deletion" "Job created:" $SYSTAB -t "every 30 minutes" -c "echo named_delete_test" -n deltarget +extract_id; id_del_named=$_extracted_id + +assert_output "delete named job by name" "Deleted:" $SYSTAB -X deltarget +assert_last_output_contains "delete-by-name output shows name" "(deltarget)" + +if [[ ! -f "$SYSTEMD_USER_DIR/systab_${id_del_named}.service" ]]; then + pass "named job service file removed after delete" +else + fail "named job service file removed after delete" "file still exists" +fi + +assert_output "create service job for deletion" "Service created:" $SYSTAB -s -c "sleep 3600" +extract_id; id_del_svc=$_extracted_id + +assert_output "delete service job by ID" "Deleted:" $SYSTAB -X "$id_del_svc" + +if [[ ! -f "$SYSTEMD_USER_DIR/systab_${id_del_svc}.service" ]]; then + pass "service-only job file removed after delete" +else + fail "service-only job file removed after delete" "file still exists" +fi + +assert_failure "delete nonexistent job fails" $SYSTAB -X "zzzzzz" +assert_failure "-X and -D are mutually exclusive" $SYSTAB -X "$id_recurring" -D "$id_recurring" +assert_failure "-X and -E are mutually exclusive" $SYSTAB -X "$id_recurring" -E "$id_recurring" +assert_failure "-X cannot be combined with job creation" $SYSTAB -X "$id_recurring" -t daily -c "echo test" + # ============================================================ # Notifications # ============================================================ @@ -450,6 +502,26 @@ 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" +# ============================================================ +# Restart (-R) +# ============================================================ + +echo "" +echo "${BOLD}--- Restart (-R) ---${RESET}" + +assert_output "restart timer job by ID" "Restarted:" $SYSTAB -R "$id_recurring" +assert_output "restart timer job by name" "Restarted:" $SYSTAB -R mytest +assert_last_output_contains "restart by name shows name" "(mytest)" +assert_output "restart service job" "Restarted:" $SYSTAB -R "$id_svc" + +$SYSTAB -D "$id_recurring" +assert_failure "restart disabled job fails" $SYSTAB -R "$id_recurring" +$SYSTAB -E "$id_recurring" + +assert_failure "restart nonexistent job fails" $SYSTAB -R "zzzzzz" +assert_failure "-R and -D are mutually exclusive" $SYSTAB -R "$id_recurring" -D "$id_recurring" +assert_failure "-R cannot be combined with job creation" $SYSTAB -R "$id_recurring" -t daily -c "echo test" + # ============================================================ # Clean # ============================================================