feat: Add new tools and configurations for Castle platform
- Introduced `uv.lock` for dependency management with various packages including `pytest`, `colorama`, and `pluggy`. - Added `pyrightconfig.json` for Python type checking configuration. - Expanded `recommendations.md` with detailed scaling recommendations and project management strategies. - Created shared `ruff.toml` for consistent linting across projects. - Developed `Castle Tools` with various utilities including Android backup, browser automation, document conversion, and search tools. - Implemented `backup-collect` and `schedule` tools for system administration tasks. - Enhanced `search` functionality with indexing and querying capabilities using Tantivy. - Added comprehensive documentation for each tool, including usage examples and installation instructions.
This commit is contained in:
17
tools/system/pyproject.toml
Normal file
17
tools/system/pyproject.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
name = "castle-system"
|
||||
version = "0.1.0"
|
||||
description = "Castle system administration tools"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
backup-collect = "system.backup_collect:main"
|
||||
schedule = "system.schedule:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/system"]
|
||||
0
tools/system/src/system/__init__.py
Normal file
0
tools/system/src/system/__init__.py
Normal file
137
tools/system/src/system/backup_collect.md
Normal file
137
tools/system/src/system/backup_collect.md
Normal file
@@ -0,0 +1,137 @@
|
||||
---
|
||||
command: backup-collect
|
||||
script: system/backup-collect.py
|
||||
description: Collect files from various sources into backup directory
|
||||
version: 1.0.0
|
||||
category: system
|
||||
system_dependencies:
|
||||
- rsync
|
||||
---
|
||||
|
||||
# backup-collect
|
||||
|
||||
Collect system information and configuration files for backup purposes.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
which backup-collect
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Collect system information
|
||||
backup-collect
|
||||
|
||||
# Specify output directory
|
||||
backup-collect -o /path/to/backup
|
||||
|
||||
# Collect specific categories
|
||||
backup-collect --category config
|
||||
backup-collect --category packages
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
backup-collect [options]
|
||||
|
||||
Options:
|
||||
-o, --output DIR Output directory for collected data
|
||||
--category CAT Collect specific category (config, packages, system)
|
||||
-v, --verbose Enable verbose output
|
||||
-h, --help Show help message
|
||||
```
|
||||
|
||||
## What It Collects
|
||||
|
||||
### System Information
|
||||
- OS version and details
|
||||
- Kernel information
|
||||
- Hardware details
|
||||
- System uptime
|
||||
|
||||
### Configuration Files
|
||||
- User dotfiles (.bashrc, .vimrc, etc.)
|
||||
- Application configs
|
||||
- SSH keys (with permissions preserved)
|
||||
- Custom scripts
|
||||
|
||||
### Package Lists
|
||||
- Installed system packages
|
||||
- Python packages
|
||||
- npm packages
|
||||
- Other package managers
|
||||
|
||||
## Features
|
||||
|
||||
- **Comprehensive** - Collects essential system data
|
||||
- **Organized** - Structured output directory
|
||||
- **Safe** - Preserves permissions
|
||||
- **Selective** - Choose what to collect
|
||||
- **Portable** - Outputs in standard formats
|
||||
|
||||
## Examples
|
||||
|
||||
### Full Backup
|
||||
|
||||
```bash
|
||||
backup-collect -o ~/backups/system-$(date +%Y%m%d)
|
||||
```
|
||||
|
||||
### Scheduled Backup
|
||||
|
||||
```bash
|
||||
# Weekly system backup
|
||||
schedule add system-backup \
|
||||
--command "backup-collect -o /mnt/backups/system-\$(date +\%Y\%m\%d)" \
|
||||
--schedule "weekly" \
|
||||
--on-calendar "Sun 02:00:00"
|
||||
```
|
||||
|
||||
### Selective Collection
|
||||
|
||||
```bash
|
||||
# Only collect configs
|
||||
backup-collect --category config -o ~/configs
|
||||
|
||||
# Only package lists
|
||||
backup-collect --category packages -o ~/packages
|
||||
```
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
backup-directory/
|
||||
├── system/
|
||||
│ ├── os-release
|
||||
│ ├── kernel-version
|
||||
│ └── hardware-info
|
||||
├── config/
|
||||
│ ├── dotfiles/
|
||||
│ ├── ssh/
|
||||
│ └── app-configs/
|
||||
└── packages/
|
||||
├── apt-packages.txt
|
||||
├── pip-packages.txt
|
||||
└── npm-packages.txt
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **System migration** - Document current setup
|
||||
- **Disaster recovery** - Quick system restoration
|
||||
- **Configuration management** - Track system changes
|
||||
- **Compliance** - Document installed software
|
||||
- **Development** - Share development environment setup
|
||||
|
||||
## Notes
|
||||
|
||||
- Does not collect sensitive data by default
|
||||
- SSH keys are collected but not passwords
|
||||
- Large files are skipped
|
||||
- Runs without root (collects what's accessible)
|
||||
- Safe to run frequently
|
||||
289
tools/system/src/system/backup_collect.py
Executable file
289
tools/system/src/system/backup_collect.py
Executable file
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
backup-collect: Collect files from various sources into backup directory
|
||||
|
||||
Incrementally backs up specified directories to the central backup location
|
||||
using rsync. Creates hardlinks to unchanged files from previous backups to
|
||||
save disk space.
|
||||
|
||||
Usage: backup-collect [options]
|
||||
|
||||
Examples:
|
||||
backup-collect # Backup using default configuration
|
||||
backup-collect -c custom.json # Use custom configuration file
|
||||
backup-collect --full # Perform a full backup instead of incremental
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
# Constants
|
||||
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.config/toolkit")
|
||||
DEFAULT_CONFIG_FILE = os.path.join(DEFAULT_CONFIG_DIR, "backup-config.json")
|
||||
DEFAULT_BACKUP_DIR = os.environ.get("DBACKUP", "/data/backup")
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger("backup-collect")
|
||||
|
||||
|
||||
def create_default_config(config_path):
|
||||
"""Create a default configuration file if none exists."""
|
||||
default_config = {
|
||||
"sources": [
|
||||
{
|
||||
"name": "config",
|
||||
"paths": [
|
||||
os.path.expanduser("~/.config"),
|
||||
],
|
||||
"exclusions": [
|
||||
os.path.expanduser("~/.config/Code/Cache/*"),
|
||||
"*/Cache/*",
|
||||
"*/CacheStorage/*",
|
||||
],
|
||||
}
|
||||
],
|
||||
"retention": {"local": 7},
|
||||
"schedule": {"incremental": "daily", "full": "weekly"},
|
||||
}
|
||||
|
||||
# Create config directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(default_config, f, indent=2)
|
||||
|
||||
logger.info(f"Created default configuration at {config_path}")
|
||||
return default_config
|
||||
|
||||
|
||||
def load_config(config_path):
|
||||
"""Load the configuration file or create default if it doesn't exist."""
|
||||
if not os.path.exists(config_path):
|
||||
return create_default_config(config_path)
|
||||
|
||||
try:
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
logger.info(f"Loaded configuration from {config_path}")
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading configuration: {e}")
|
||||
logger.info("Using default configuration")
|
||||
return create_default_config(config_path + ".default")
|
||||
|
||||
|
||||
def run_backup(source, backup_dir, date_str, incremental=True):
|
||||
"""Run rsync backup for a given source configuration."""
|
||||
source_name = source["name"]
|
||||
source_paths = source["paths"]
|
||||
exclusions = source.get("exclusions", [])
|
||||
|
||||
# Create dated and latest directory names
|
||||
dated_dir = os.path.join(backup_dir, f"{source_name}-{date_str}")
|
||||
latest_dir = os.path.join(backup_dir, f"{source_name}-latest")
|
||||
previous_dir = os.path.join(backup_dir, f"{source_name}-previous")
|
||||
|
||||
logger.info(f"Backing up {source_name} to {dated_dir}")
|
||||
|
||||
# Create backup directory if it doesn't exist
|
||||
os.makedirs(dated_dir, exist_ok=True)
|
||||
|
||||
for source_path in source_paths:
|
||||
# Expand user paths like ~/.config
|
||||
expanded_path = os.path.expanduser(source_path)
|
||||
|
||||
if not os.path.exists(expanded_path):
|
||||
logger.warning(f"Source path does not exist: {expanded_path}")
|
||||
continue
|
||||
|
||||
# Prepare rsync command
|
||||
rsync_cmd = ["rsync", "-av", "--delete", "--block-size=131072"]
|
||||
|
||||
# Add exclusions
|
||||
for exclusion in exclusions:
|
||||
rsync_cmd.extend(["--exclude", exclusion])
|
||||
|
||||
# For incremental backups, use hardlinks to previous backup
|
||||
if incremental and os.path.exists(latest_dir):
|
||||
rsync_cmd.extend(["--link-dest", latest_dir])
|
||||
|
||||
# Add source and destination with path preservation
|
||||
if os.path.isdir(expanded_path):
|
||||
# For directories, preserve the trailing slash behavior
|
||||
source_with_slash = (
|
||||
expanded_path if expanded_path.endswith("/") else expanded_path + "/"
|
||||
)
|
||||
rsync_cmd.extend(["-R", source_with_slash]) # -R preserves relative path
|
||||
rsync_cmd.append(dated_dir + "/")
|
||||
else:
|
||||
# For files, preserve the full path structure
|
||||
rsync_cmd.extend(["-R", expanded_path]) # -R preserves relative path
|
||||
rsync_cmd.append(dated_dir + "/")
|
||||
|
||||
# Execute rsync
|
||||
logger.info(f"Running: {' '.join(rsync_cmd)}")
|
||||
try:
|
||||
# Use check=False to continue even if there are errors
|
||||
result = subprocess.run(
|
||||
rsync_cmd, check=False, capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Backup of {expanded_path} completed successfully")
|
||||
else:
|
||||
logger.error(f"Backup failed for {expanded_path}: {result.stderr}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Backup failed for {expanded_path}: {e}")
|
||||
return False
|
||||
|
||||
# Update symlinks
|
||||
try:
|
||||
# Move previous latest to previous if it exists
|
||||
if os.path.exists(latest_dir) and os.path.islink(latest_dir):
|
||||
# Remove existing previous symlink first
|
||||
if os.path.exists(previous_dir) or os.path.islink(previous_dir):
|
||||
os.unlink(previous_dir)
|
||||
os.symlink(os.readlink(latest_dir), previous_dir)
|
||||
|
||||
# Update latest symlink
|
||||
if os.path.exists(latest_dir) or os.path.islink(latest_dir):
|
||||
os.unlink(latest_dir)
|
||||
os.symlink(os.path.basename(dated_dir), latest_dir)
|
||||
logger.info(f"Updated symlinks for {source_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update symlinks: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def cleanup_old_backups(source, backup_dir, retention):
|
||||
"""Remove old backups beyond the retention period."""
|
||||
source_name = source["name"]
|
||||
logger.info(f"Cleaning up old backups for {source_name}")
|
||||
|
||||
try:
|
||||
# Find all dated backups for this source
|
||||
backups = sorted(
|
||||
[
|
||||
d
|
||||
for d in os.listdir(backup_dir)
|
||||
if os.path.isdir(os.path.join(backup_dir, d))
|
||||
and d.startswith(f"{source_name}-")
|
||||
and not os.path.islink(os.path.join(backup_dir, d))
|
||||
]
|
||||
)
|
||||
|
||||
# Keep only the most recent 'retention' number of backups
|
||||
if len(backups) > retention:
|
||||
to_delete = backups[:-retention]
|
||||
for backup in to_delete:
|
||||
backup_path = os.path.join(backup_dir, backup)
|
||||
logger.info(f"Removing old backup: {backup_path}")
|
||||
# Use subprocess to remove directories that might have read-only files
|
||||
subprocess.run(["rm", "-rf", backup_path], check=True)
|
||||
|
||||
logger.info(f"Cleanup completed, kept {min(len(backups), retention)} backups")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Collect files from various sources into backup directory",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__.split("\n\n", 1)[1]
|
||||
if __doc__
|
||||
else "", # Use the docstring as extended help
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--config", help=f"Path to config file (default: {DEFAULT_CONFIG_FILE})"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--backup-dir",
|
||||
help=f"Backup directory (default: $DBACKUP or {DEFAULT_BACKUP_DIR})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--full",
|
||||
action="store_true",
|
||||
help="Perform a full backup instead of incremental",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d", "--debug", action="store_true", help="Enable debug output"
|
||||
)
|
||||
parser.add_argument("--version", action="version", version="backup-collect 1.0.0")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set verbosity
|
||||
if args.verbose or args.debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Extra debug info
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
# Load configuration
|
||||
config_path = args.config or DEFAULT_CONFIG_FILE
|
||||
config = load_config(config_path)
|
||||
|
||||
# Determine backup directory
|
||||
backup_dir = args.backup_dir or DEFAULT_BACKUP_DIR
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
logger.info(f"Using backup directory: {backup_dir}")
|
||||
|
||||
# Current date for backup naming
|
||||
date_str = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
|
||||
# Determine if using incremental backup
|
||||
incremental = not args.full
|
||||
logger.info(f"Performing {'incremental' if incremental else 'full'} backup")
|
||||
|
||||
success = True
|
||||
|
||||
# Debug output of all config
|
||||
if args.debug:
|
||||
logger.debug(f"Using config: {json.dumps(config, indent=2)}")
|
||||
logger.debug(f"Backup dir: {backup_dir}")
|
||||
logger.debug(f"Sources to backup: {len(config.get('sources', []))}")
|
||||
for source in config.get("sources", []):
|
||||
logger.debug(f"Source: {source['name']}, Paths: {source['paths']}")
|
||||
|
||||
# Process each source
|
||||
for source in config.get("sources", []):
|
||||
logger.info(f"Processing source: {source['name']}")
|
||||
if not run_backup(source, backup_dir, date_str, incremental):
|
||||
logger.error(f"Backup failed for {source['name']}")
|
||||
success = False
|
||||
|
||||
# Cleanup old backups
|
||||
retention = config.get("retention", {}).get("local", 7)
|
||||
cleanup_old_backups(source, backup_dir, retention)
|
||||
|
||||
if success:
|
||||
logger.info("✅ All backups completed successfully")
|
||||
return 0
|
||||
else:
|
||||
logger.error("One or more backups failed")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
388
tools/system/src/system/schedule.md
Normal file
388
tools/system/src/system/schedule.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# schedule
|
||||
|
||||
A tool to easily create, manage, and monitor systemd user timers and services following best practices.
|
||||
|
||||
## Overview
|
||||
|
||||
`schedule` simplifies the management of systemd user timers by providing a simple command-line interface to create timer/service pairs that execute bash commands on a schedule. It handles all the boilerplate configuration and follows systemd best practices for security and resource management.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the toolkit:
|
||||
|
||||
```bash
|
||||
cd /data/repos/toolkit
|
||||
make install
|
||||
```
|
||||
|
||||
The `schedule` command will be available in your PATH.
|
||||
|
||||
### Enable Linger (Optional but Recommended)
|
||||
|
||||
By default, systemd user units only run while you're logged in. To allow timers to run even when you're logged out, enable linger:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
This is essential for timers that should run 24/7, like automated sync jobs.
|
||||
|
||||
## Features
|
||||
|
||||
- **Easy timer creation** - Create timers with a single command
|
||||
- **Multiple schedule types** - Support for interval-based and calendar-based schedules
|
||||
- **Environment variables** - Support for environment files and inline variables
|
||||
- **Pre-conditions** - Run commands only when conditions are met
|
||||
- **Security hardening** - Automatic security settings (NoNewPrivileges, PrivateTmp)
|
||||
- **Log management** - Easy access to service logs via journalctl
|
||||
- **Status monitoring** - Check timer and service status
|
||||
|
||||
## Usage
|
||||
|
||||
### List timers
|
||||
|
||||
Show all active user timers:
|
||||
|
||||
```bash
|
||||
schedule list
|
||||
```
|
||||
|
||||
Show all timers including inactive ones:
|
||||
|
||||
```bash
|
||||
schedule list --all
|
||||
```
|
||||
|
||||
### Add a timer
|
||||
|
||||
Basic timer that runs every 5 minutes:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "echo 'Hello, World!'" \
|
||||
--schedule "5min"
|
||||
```
|
||||
|
||||
Daily backup at 2 AM:
|
||||
|
||||
```bash
|
||||
schedule add daily-backup \
|
||||
--command "backup run" \
|
||||
--schedule "daily" \
|
||||
--on-calendar "*-*-* 02:00:00" \
|
||||
--description "Daily backup job"
|
||||
```
|
||||
|
||||
ProtonMail sync with environment file and condition:
|
||||
|
||||
```bash
|
||||
schedule add protonmail-sync \
|
||||
--command "protonmail sync" \
|
||||
--schedule "5min" \
|
||||
--description "Sync ProtonMail emails" \
|
||||
--env-file ~/.config/protonmail/env \
|
||||
--condition-command "pgrep -f protonmail-bridge"
|
||||
```
|
||||
|
||||
With environment variables:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "my-script.sh" \
|
||||
--schedule "1hour" \
|
||||
--environment "API_KEY=secret123" \
|
||||
--environment "LOG_LEVEL=debug" \
|
||||
--working-directory "/home/user/projects"
|
||||
```
|
||||
|
||||
### Check status
|
||||
|
||||
View timer and service status:
|
||||
|
||||
```bash
|
||||
schedule status protonmail-sync
|
||||
```
|
||||
|
||||
This shows:
|
||||
- Timer status (active/inactive)
|
||||
- Service status (last run, next run)
|
||||
- Next scheduled execution time
|
||||
|
||||
### View logs
|
||||
|
||||
Follow logs in real-time:
|
||||
|
||||
```bash
|
||||
schedule logs protonmail-sync --follow
|
||||
```
|
||||
|
||||
Show last 50 lines:
|
||||
|
||||
```bash
|
||||
schedule logs protonmail-sync --lines 50
|
||||
```
|
||||
|
||||
Show logs since today:
|
||||
|
||||
```bash
|
||||
schedule logs protonmail-sync --since today
|
||||
```
|
||||
|
||||
### Start/Stop timers
|
||||
|
||||
Start a timer:
|
||||
|
||||
```bash
|
||||
schedule start protonmail-sync
|
||||
```
|
||||
|
||||
Stop a timer:
|
||||
|
||||
```bash
|
||||
schedule stop protonmail-sync
|
||||
```
|
||||
|
||||
### Enable/Disable timers
|
||||
|
||||
Enable timer (start on boot):
|
||||
|
||||
```bash
|
||||
schedule enable protonmail-sync
|
||||
```
|
||||
|
||||
Disable timer (don't start on boot):
|
||||
|
||||
```bash
|
||||
schedule disable protonmail-sync
|
||||
```
|
||||
|
||||
### Remove a timer
|
||||
|
||||
Remove timer and service files:
|
||||
|
||||
```bash
|
||||
schedule remove protonmail-sync
|
||||
```
|
||||
|
||||
Remove but keep logs:
|
||||
|
||||
```bash
|
||||
schedule remove protonmail-sync --keep-logs
|
||||
```
|
||||
|
||||
## Schedule Formats
|
||||
|
||||
### Interval-based schedules
|
||||
|
||||
Use simple time units:
|
||||
|
||||
- `5min` - Every 5 minutes
|
||||
- `1hour` or `1h` - Every hour
|
||||
- `30min` - Every 30 minutes
|
||||
- `1day` or `1d` - Every day
|
||||
- `1week` or `1w` - Every week
|
||||
- `hourly` - Every hour (alias for 1hour)
|
||||
- `daily` - Every day (alias for 1day)
|
||||
|
||||
### Calendar-based schedules
|
||||
|
||||
Use systemd calendar syntax with `--on-calendar`:
|
||||
|
||||
- `*-*-* 02:00:00` - Every day at 2 AM
|
||||
- `Mon *-*-* 09:00:00` - Every Monday at 9 AM
|
||||
- `*-*-01 00:00:00` - First day of each month at midnight
|
||||
- `*-*-* *:00:00` - Every hour on the hour
|
||||
|
||||
## Advanced Options
|
||||
|
||||
### Add command options
|
||||
|
||||
- `--name` - Name of the timer/service (required)
|
||||
- `--command` - Bash command to execute (required)
|
||||
- `--schedule` - Schedule format (required)
|
||||
- `--description` - Human-readable description
|
||||
- `--env-file` - Path to environment file to source
|
||||
- `--environment` / `-e` - Set environment variable (KEY=VALUE)
|
||||
- `--condition-command` - Command that must succeed before running
|
||||
- `--working-directory` - Working directory for the command
|
||||
- `--on-boot-sec` - Delay after boot (default: 1min)
|
||||
- `--on-calendar` - Override with calendar-based schedule
|
||||
- `--persistent` - Run missed timers if system was offline
|
||||
- `--force` - Overwrite existing timer/service
|
||||
- `--no-enable` - Don't enable the timer
|
||||
- `--no-start` - Don't start the timer
|
||||
|
||||
## Examples
|
||||
|
||||
### ProtonMail Sync
|
||||
|
||||
```bash
|
||||
schedule add protonmail-sync \
|
||||
--command "protonmail sync" \
|
||||
--schedule "5min" \
|
||||
--description "Sync ProtonMail emails every 5 minutes" \
|
||||
--env-file ~/.config/protonmail/env \
|
||||
--condition-command "pgrep -f protonmail-bridge"
|
||||
```
|
||||
|
||||
### Daily Backup
|
||||
|
||||
```bash
|
||||
schedule add daily-backup \
|
||||
--command "backup run --full" \
|
||||
--schedule "daily" \
|
||||
--on-calendar "*-*-* 02:00:00" \
|
||||
--description "Daily backup at 2 AM" \
|
||||
--environment "BACKUP_DIR=/mnt/backups"
|
||||
```
|
||||
|
||||
### Hourly Data Sync
|
||||
|
||||
```bash
|
||||
schedule add data-sync \
|
||||
--command "rsync -av /source/ /dest/" \
|
||||
--schedule "hourly" \
|
||||
--description "Sync data every hour" \
|
||||
--working-directory "/home/user/sync"
|
||||
```
|
||||
|
||||
### Git Auto-Commit
|
||||
|
||||
```bash
|
||||
schedule add git-autocommit \
|
||||
--command "git add -A && git commit -m 'Auto-commit' && git push" \
|
||||
--schedule "15min" \
|
||||
--description "Auto-commit and push changes" \
|
||||
--working-directory "/home/user/projects/myrepo"
|
||||
```
|
||||
|
||||
## File Locations
|
||||
|
||||
Timer and service files are stored in:
|
||||
|
||||
```
|
||||
~/.config/systemd/user/
|
||||
├── <name>.service
|
||||
└── <name>.timer
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
All services created by `schedule` include:
|
||||
|
||||
- `NoNewPrivileges=yes` - Cannot gain new privileges
|
||||
- `PrivateTmp=yes` - Isolated /tmp directory
|
||||
- Proper PATH configuration
|
||||
- Journal logging for stdout/stderr
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Timer not running
|
||||
|
||||
Check if the timer is enabled and active:
|
||||
|
||||
```bash
|
||||
schedule status <name>
|
||||
```
|
||||
|
||||
### Service failing
|
||||
|
||||
View the logs to see what went wrong:
|
||||
|
||||
```bash
|
||||
schedule logs <name> --lines 50
|
||||
```
|
||||
|
||||
### Command not found
|
||||
|
||||
Ensure the command is in the PATH or use an absolute path:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "/usr/local/bin/my-script" \
|
||||
--schedule "5min"
|
||||
```
|
||||
|
||||
### Environment variables not working
|
||||
|
||||
Use an environment file:
|
||||
|
||||
```bash
|
||||
# Create env file
|
||||
echo "API_KEY=secret" > ~/.config/myapp/env
|
||||
chmod 600 ~/.config/myapp/env
|
||||
|
||||
# Use it
|
||||
schedule add my-task \
|
||||
--command "my-script" \
|
||||
--schedule "5min" \
|
||||
--env-file ~/.config/myapp/env
|
||||
```
|
||||
|
||||
## Comparison with Manual Setup
|
||||
|
||||
Instead of manually creating:
|
||||
|
||||
1. `.service` file with proper configuration
|
||||
2. `.timer` file with scheduling
|
||||
3. Running `systemctl --user daemon-reload`
|
||||
4. Running `systemctl --user enable <name>.timer`
|
||||
5. Running `systemctl --user start <name>.timer`
|
||||
|
||||
You can do it all with one command:
|
||||
|
||||
```bash
|
||||
schedule add <name> --command "<cmd>" --schedule "<schedule>"
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- `man systemd.timer` - systemd timer documentation
|
||||
- `man systemd.service` - systemd service documentation
|
||||
- `man systemd.time` - systemd time specification
|
||||
- `journalctl` - Query systemd journal
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Environment Variables
|
||||
|
||||
**Critical**: Systemd user units do NOT inherit environment variables from your shell (e.g., ~/.bashrc). Variables set with `export` in your shell will not be available to services.
|
||||
|
||||
To make environment variables available to your scheduled commands, pass them inline when creating the timer:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "my-script" \
|
||||
--schedule "1hour" \
|
||||
--environment "API_KEY=secret123" \
|
||||
--environment "DATABASE_URL=postgres://localhost/mydb"
|
||||
```
|
||||
|
||||
You can also use the `-e` shorthand:
|
||||
|
||||
```bash
|
||||
schedule add my-task \
|
||||
--command "my-script" \
|
||||
--schedule "1hour" \
|
||||
-e "API_KEY=secret123" \
|
||||
-e "DATABASE_URL=postgres://localhost/mydb"
|
||||
```
|
||||
|
||||
### User vs System Units
|
||||
|
||||
This tool creates **user** systemd units (`systemctl --user`), not system units. User units:
|
||||
|
||||
- Run as your user (no root required)
|
||||
- Stored in `~/.config/systemd/user/`
|
||||
- Only run while you're logged in (unless linger is enabled)
|
||||
- Have access to your user's files and permissions
|
||||
|
||||
To enable timers to run 24/7 even when logged out:
|
||||
|
||||
```bash
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Same license as the toolkit project.
|
||||
552
tools/system/src/system/schedule.py
Normal file
552
tools/system/src/system/schedule.py
Normal file
@@ -0,0 +1,552 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Schedule - Systemd timer and service management tool.
|
||||
|
||||
A tool to easily create, manage, and monitor systemd user timers and services
|
||||
following best practices.
|
||||
|
||||
Usage:
|
||||
schedule list [--all]
|
||||
schedule add <name> --command <cmd> --schedule <schedule> [options]
|
||||
schedule remove <name> [--keep-logs]
|
||||
schedule status <name>
|
||||
schedule logs <name> [--follow] [--lines N]
|
||||
schedule enable <name>
|
||||
schedule disable <name>
|
||||
schedule start <name>
|
||||
schedule stop <name>
|
||||
|
||||
Examples:
|
||||
# List all user timers
|
||||
schedule list
|
||||
|
||||
# Create a timer for ProtonMail sync every 5 minutes
|
||||
schedule add protonmail-sync \\
|
||||
--command "protonmail sync" \\
|
||||
--schedule "5min" \\
|
||||
--description "Sync ProtonMail emails" \\
|
||||
--env-file ~/.config/protonmail/env \\
|
||||
--condition-command "pgrep -f protonmail-bridge"
|
||||
|
||||
# Create a daily backup timer at 2 AM
|
||||
schedule add daily-backup \\
|
||||
--command "backup run" \\
|
||||
--schedule "daily" \\
|
||||
--on-calendar "*-*-* 02:00:00"
|
||||
|
||||
# Check status of a timer
|
||||
schedule status protonmail-sync
|
||||
|
||||
# View logs in real-time
|
||||
schedule logs protonmail-sync --follow
|
||||
|
||||
# Remove a timer
|
||||
schedule remove protonmail-sync
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_systemd_user_dir() -> Path:
|
||||
"""Get the systemd user directory."""
|
||||
return Path.home() / ".config" / "systemd" / "user"
|
||||
|
||||
|
||||
def run_systemctl(
|
||||
args: list[str], check: bool = True
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run systemctl command with user flag."""
|
||||
cmd = ["systemctl", "--user"] + args
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check,
|
||||
)
|
||||
|
||||
|
||||
def list_timers(all_timers: bool = False) -> None:
|
||||
"""List all user timers."""
|
||||
args = ["list-timers"]
|
||||
if all_timers:
|
||||
args.append("--all")
|
||||
|
||||
result = run_systemctl(args, check=False)
|
||||
print(result.stdout)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Error: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def generate_service_content(
|
||||
name: str,
|
||||
command: str,
|
||||
description: str | None = None,
|
||||
env_file: str | None = None,
|
||||
condition_command: str | None = None,
|
||||
working_directory: str | None = None,
|
||||
environment: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Generate systemd service file content."""
|
||||
desc = description or f"{name} service"
|
||||
|
||||
content = f"""[Unit]
|
||||
Description={desc}
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
"""
|
||||
|
||||
# Add default PATH
|
||||
content += 'Environment="PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin"\n'
|
||||
|
||||
# Add additional environment variables
|
||||
if environment:
|
||||
for key, value in environment.items():
|
||||
content += f'Environment="{key}={value}"\n'
|
||||
|
||||
# Add environment file if specified
|
||||
if env_file:
|
||||
content += f"EnvironmentFile={env_file}\n"
|
||||
|
||||
content += "\n"
|
||||
|
||||
# Add condition command if specified
|
||||
if condition_command:
|
||||
content += "# Pre-condition check\n"
|
||||
# Use double quotes to avoid conflicts with single quotes in the command
|
||||
escaped_condition = condition_command.replace('"', '\\"')
|
||||
content += f'ExecStartPre=/bin/bash -c "{escaped_condition}"\n\n'
|
||||
|
||||
# Set working directory if specified
|
||||
if working_directory:
|
||||
content += f"WorkingDirectory={working_directory}\n"
|
||||
|
||||
# Add the main command
|
||||
content += "# Run the command\n"
|
||||
# Use double quotes to avoid conflicts with single quotes in the command
|
||||
escaped_command = command.replace('"', '\\"')
|
||||
content += f'ExecStart=/bin/bash -c "{escaped_command}"\n\n'
|
||||
|
||||
# Logging
|
||||
content += """# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier={name}
|
||||
|
||||
# Security settings
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
""".format(name=name)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def generate_timer_content(
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
on_boot_sec: str = "1min",
|
||||
on_unit_active_sec: str | None = None,
|
||||
on_calendar: str | None = None,
|
||||
persistent: bool = False,
|
||||
) -> str:
|
||||
"""Generate systemd timer file content."""
|
||||
desc = description or f"{name} timer"
|
||||
|
||||
content = f"""[Unit]
|
||||
Description={desc}
|
||||
Requires={name}.service
|
||||
|
||||
[Timer]
|
||||
"""
|
||||
|
||||
# Add boot delay
|
||||
if on_boot_sec:
|
||||
content += f"OnBootSec={on_boot_sec}\n"
|
||||
|
||||
# Add interval-based schedule
|
||||
if on_unit_active_sec:
|
||||
content += f"OnUnitActiveSec={on_unit_active_sec}\n"
|
||||
|
||||
# Add calendar-based schedule
|
||||
if on_calendar:
|
||||
content += f"OnCalendar={on_calendar}\n"
|
||||
|
||||
content += f"""
|
||||
# If the system was offline, {"run" if persistent else "don't run"} missed timers
|
||||
Persistent={"true" if persistent else "false"}
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
"""
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def parse_schedule(schedule: str) -> tuple[str | None, str | None]:
|
||||
"""Parse schedule string into timer parameters.
|
||||
|
||||
Returns:
|
||||
Tuple of (on_unit_active_sec, on_calendar)
|
||||
"""
|
||||
schedule_lower = schedule.lower()
|
||||
|
||||
# Simple interval schedules
|
||||
if any(
|
||||
schedule_lower.endswith(unit)
|
||||
for unit in ["min", "hour", "h", "day", "d", "week", "w"]
|
||||
):
|
||||
return schedule, None
|
||||
|
||||
# Daily at specific time
|
||||
if schedule_lower == "daily":
|
||||
return "1day", None
|
||||
|
||||
# Hourly
|
||||
if schedule_lower == "hourly":
|
||||
return "1hour", None
|
||||
|
||||
# Assume it's a calendar spec
|
||||
return None, schedule
|
||||
|
||||
|
||||
def add_timer(args: argparse.Namespace) -> None:
|
||||
"""Add a new systemd timer and service."""
|
||||
name = args.name
|
||||
command = args.command
|
||||
schedule = args.schedule
|
||||
|
||||
# Parse schedule
|
||||
on_unit_active_sec, on_calendar = parse_schedule(schedule)
|
||||
|
||||
if args.on_calendar:
|
||||
on_calendar = args.on_calendar
|
||||
on_unit_active_sec = None
|
||||
|
||||
# Create systemd directory if it doesn't exist
|
||||
systemd_dir = get_systemd_user_dir()
|
||||
systemd_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate service file
|
||||
environment = {}
|
||||
if args.environment:
|
||||
for env in args.environment:
|
||||
key, value = env.split("=", 1)
|
||||
environment[key] = value
|
||||
|
||||
service_content = generate_service_content(
|
||||
name=name,
|
||||
command=command,
|
||||
description=args.description,
|
||||
env_file=args.env_file,
|
||||
condition_command=args.condition_command,
|
||||
working_directory=args.working_directory,
|
||||
environment=environment,
|
||||
)
|
||||
|
||||
# Generate timer file
|
||||
timer_content = generate_timer_content(
|
||||
name=name,
|
||||
description=args.description,
|
||||
on_boot_sec=args.on_boot_sec,
|
||||
on_unit_active_sec=on_unit_active_sec,
|
||||
on_calendar=on_calendar,
|
||||
persistent=args.persistent,
|
||||
)
|
||||
|
||||
# Write service file
|
||||
service_path = systemd_dir / f"{name}.service"
|
||||
timer_path = systemd_dir / f"{name}.timer"
|
||||
|
||||
# Check if files already exist
|
||||
if service_path.exists() and not args.force:
|
||||
print(f"Error: Service file already exists: {service_path}", file=sys.stderr)
|
||||
print("Use --force to overwrite", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if timer_path.exists() and not args.force:
|
||||
print(f"Error: Timer file already exists: {timer_path}", file=sys.stderr)
|
||||
print("Use --force to overwrite", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Write files
|
||||
service_path.write_text(service_content)
|
||||
timer_path.write_text(timer_content)
|
||||
|
||||
print(f"Created service: {service_path}")
|
||||
print(f"Created timer: {timer_path}")
|
||||
|
||||
# Reload systemd daemon
|
||||
print("\nReloading systemd daemon...")
|
||||
run_systemctl(["daemon-reload"])
|
||||
|
||||
# Enable and start timer if requested
|
||||
if not args.no_enable:
|
||||
print(f"Enabling {name}.timer...")
|
||||
run_systemctl(["enable", f"{name}.timer"])
|
||||
|
||||
if not args.no_start:
|
||||
print(f"Starting {name}.timer...")
|
||||
run_systemctl(["start", f"{name}.timer"])
|
||||
|
||||
print(f"\n✓ Timer '{name}' created and started successfully!")
|
||||
|
||||
# Warn about environment variables if not explicitly set in service
|
||||
if not environment and not args.env_file:
|
||||
print(
|
||||
"\n⚠️ Note: Systemd user units don't inherit shell environment variables."
|
||||
)
|
||||
print(" If your command needs environment variables, pass them with:")
|
||||
print(f" schedule add {name} --command '...' -e VAR=value")
|
||||
|
||||
print("\nUseful commands:")
|
||||
print(f" Status: schedule status {name}")
|
||||
print(f" Logs: schedule logs {name} --follow")
|
||||
print(f" Disable: schedule disable {name}")
|
||||
print(f" Remove: schedule remove {name}")
|
||||
|
||||
|
||||
def remove_timer(args: argparse.Namespace) -> None:
|
||||
"""Remove a systemd timer and service."""
|
||||
name = args.name
|
||||
systemd_dir = get_systemd_user_dir()
|
||||
|
||||
service_path = systemd_dir / f"{name}.service"
|
||||
timer_path = systemd_dir / f"{name}.timer"
|
||||
|
||||
# Check if files exist
|
||||
if not service_path.exists() and not timer_path.exists():
|
||||
print(f"Error: Timer '{name}' not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Stop and disable timer
|
||||
print(f"Stopping {name}.timer...")
|
||||
run_systemctl(["stop", f"{name}.timer"], check=False)
|
||||
|
||||
print(f"Disabling {name}.timer...")
|
||||
run_systemctl(["disable", f"{name}.timer"], check=False)
|
||||
|
||||
# Remove files
|
||||
if timer_path.exists():
|
||||
timer_path.unlink()
|
||||
print(f"Removed: {timer_path}")
|
||||
|
||||
if service_path.exists():
|
||||
service_path.unlink()
|
||||
print(f"Removed: {service_path}")
|
||||
|
||||
# Reload systemd daemon
|
||||
print("\nReloading systemd daemon...")
|
||||
run_systemctl(["daemon-reload"])
|
||||
|
||||
# Clear logs if requested
|
||||
if not args.keep_logs:
|
||||
print(f"Clearing logs for {name}...")
|
||||
subprocess.run(
|
||||
["journalctl", "--user", "--vacuum-time=1s", "-u", name],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
print(f"\n✓ Timer '{name}' removed successfully!")
|
||||
|
||||
|
||||
def status_timer(args: argparse.Namespace) -> None:
|
||||
"""Show status of a timer and its service."""
|
||||
name = args.name
|
||||
|
||||
print("=== Timer Status ===")
|
||||
result = run_systemctl(["status", f"{name}.timer"], check=False)
|
||||
print(result.stdout)
|
||||
|
||||
print("\n=== Service Status ===")
|
||||
result = run_systemctl(["status", f"{name}.service"], check=False)
|
||||
print(result.stdout)
|
||||
|
||||
print("\n=== Next Scheduled Run ===")
|
||||
result = run_systemctl(["list-timers", f"{name}.timer"], check=False)
|
||||
print(result.stdout)
|
||||
|
||||
|
||||
def logs_timer(args: argparse.Namespace) -> None:
|
||||
"""Show logs for a service."""
|
||||
name = args.name
|
||||
cmd = ["journalctl", "--user", "-u", name]
|
||||
|
||||
if args.follow:
|
||||
cmd.append("-f")
|
||||
|
||||
if args.lines:
|
||||
cmd.extend(["-n", str(args.lines)])
|
||||
|
||||
if args.since:
|
||||
cmd.extend(["--since", args.since])
|
||||
|
||||
# Run journalctl directly (don't capture output)
|
||||
subprocess.run(cmd)
|
||||
|
||||
|
||||
def enable_timer(args: argparse.Namespace) -> None:
|
||||
"""Enable a timer."""
|
||||
name = args.name
|
||||
print(f"Enabling {name}.timer...")
|
||||
run_systemctl(["enable", f"{name}.timer"])
|
||||
print(f"✓ Timer '{name}' enabled")
|
||||
|
||||
|
||||
def disable_timer(args: argparse.Namespace) -> None:
|
||||
"""Disable a timer."""
|
||||
name = args.name
|
||||
print(f"Disabling {name}.timer...")
|
||||
run_systemctl(["disable", f"{name}.timer"])
|
||||
print(f"✓ Timer '{name}' disabled")
|
||||
|
||||
|
||||
def start_timer(args: argparse.Namespace) -> None:
|
||||
"""Start a timer."""
|
||||
name = args.name
|
||||
print(f"Starting {name}.timer...")
|
||||
run_systemctl(["start", f"{name}.timer"])
|
||||
print(f"✓ Timer '{name}' started")
|
||||
|
||||
|
||||
def stop_timer(args: argparse.Namespace) -> None:
|
||||
"""Stop a timer."""
|
||||
name = args.name
|
||||
print(f"Stopping {name}.timer...")
|
||||
run_systemctl(["stop", f"{name}.timer"])
|
||||
print(f"✓ Timer '{name}' stopped")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage systemd user timers and services",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="subcommand", help="Command to execute")
|
||||
|
||||
# List command
|
||||
list_parser = subparsers.add_parser("list", help="List all user timers")
|
||||
list_parser.add_argument(
|
||||
"--all", action="store_true", help="Show all timers including inactive"
|
||||
)
|
||||
|
||||
# Add command
|
||||
add_parser = subparsers.add_parser("add", help="Add a new timer")
|
||||
add_parser.add_argument("name", help="Name of the timer/service")
|
||||
add_parser.add_argument("--command", required=True, help="Command to execute")
|
||||
add_parser.add_argument(
|
||||
"--schedule",
|
||||
required=True,
|
||||
help="Schedule (e.g., '5min', 'hourly', 'daily', '1h')",
|
||||
)
|
||||
add_parser.add_argument("--description", help="Description of the timer/service")
|
||||
add_parser.add_argument(
|
||||
"--env-file", help="Path to environment file (e.g., ~/.config/app/env)"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--environment",
|
||||
"-e",
|
||||
action="append",
|
||||
help="Environment variable (KEY=VALUE)",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--condition-command",
|
||||
help="Command that must succeed before running (e.g., 'pgrep myapp')",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--working-directory", help="Working directory for the command"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--on-boot-sec", default="1min", help="Delay after boot (default: 1min)"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--on-calendar",
|
||||
help="Calendar-based schedule (e.g., '*-*-* 02:00:00' for 2 AM daily)",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--persistent",
|
||||
action="store_true",
|
||||
help="Run missed timers if system was offline",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--force", action="store_true", help="Overwrite existing timer/service"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--no-enable", action="store_true", help="Don't enable the timer"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--no-start", action="store_true", help="Don't start the timer"
|
||||
)
|
||||
|
||||
# Remove command
|
||||
remove_parser = subparsers.add_parser("remove", help="Remove a timer")
|
||||
remove_parser.add_argument("name", help="Name of the timer/service")
|
||||
remove_parser.add_argument(
|
||||
"--keep-logs", action="store_true", help="Keep service logs"
|
||||
)
|
||||
|
||||
# Status command
|
||||
status_parser = subparsers.add_parser("status", help="Show timer status")
|
||||
status_parser.add_argument("name", help="Name of the timer/service")
|
||||
|
||||
# Logs command
|
||||
logs_parser = subparsers.add_parser("logs", help="Show service logs")
|
||||
logs_parser.add_argument("name", help="Name of the timer/service")
|
||||
logs_parser.add_argument(
|
||||
"--follow", "-f", action="store_true", help="Follow logs in real-time"
|
||||
)
|
||||
logs_parser.add_argument("--lines", "-n", type=int, help="Number of lines to show")
|
||||
logs_parser.add_argument("--since", help="Show logs since (e.g., 'today', '1h')")
|
||||
|
||||
# Enable command
|
||||
enable_parser = subparsers.add_parser("enable", help="Enable a timer")
|
||||
enable_parser.add_argument("name", help="Name of the timer")
|
||||
|
||||
# Disable command
|
||||
disable_parser = subparsers.add_parser("disable", help="Disable a timer")
|
||||
disable_parser.add_argument("name", help="Name of the timer")
|
||||
|
||||
# Start command
|
||||
start_parser = subparsers.add_parser("start", help="Start a timer")
|
||||
start_parser.add_argument("name", help="Name of the timer")
|
||||
|
||||
# Stop command
|
||||
stop_parser = subparsers.add_parser("stop", help="Stop a timer")
|
||||
stop_parser.add_argument("name", help="Name of the timer")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.subcommand:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
# Route to appropriate function
|
||||
commands = {
|
||||
"list": list_timers,
|
||||
"add": add_timer,
|
||||
"remove": remove_timer,
|
||||
"status": status_timer,
|
||||
"logs": logs_timer,
|
||||
"enable": enable_timer,
|
||||
"disable": disable_timer,
|
||||
"start": start_timer,
|
||||
"stop": stop_timer,
|
||||
}
|
||||
|
||||
commands[args.subcommand](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user