Skip to content

Commit 3a48b82

Browse files
authored
Merge pull request #7 from DrDroidLab/auto_sync_flag
Add auto-sync feature for periodic background syncing
2 parents a8d6c43 + 8f62089 commit 3a48b82

6 files changed

Lines changed: 712 additions & 1 deletion

File tree

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,28 @@ droidctx list-connectors
150150
droidctx list-connectors --type GRAFANA
151151
```
152152

153+
### `droidctx auto-sync`
154+
155+
Keep your context files fresh automatically. Uses launchd on macOS and cron on Linux.
156+
157+
```bash
158+
# Enable auto-sync (runs every 30 minutes by default)
159+
droidctx auto-sync enable --keyfile ./droidctx-context/credentials.yaml
160+
161+
# Custom interval (in minutes)
162+
droidctx auto-sync enable --keyfile ./droidctx-context/credentials.yaml --interval 60
163+
164+
# Check status
165+
droidctx auto-sync status
166+
167+
# Disable
168+
droidctx auto-sync disable
169+
```
170+
171+
Logs are written to `~/.config/droidctx/auto-sync.log`.
172+
173+
> **macOS note:** You may need to allow droidctx in System Settings → Privacy & Security the first time.
174+
153175
## Credentials Format
154176

155177
Create a YAML file with your connector credentials. Run `droidctx init` to generate a template with all supported types, or `droidctx detect` to auto-populate from your CLI tools.

droidctx/auto_sync.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Auto-sync configuration and orchestration."""
2+
3+
import shutil
4+
import sys
5+
from datetime import datetime, timezone
6+
from pathlib import Path
7+
from typing import Any, Optional
8+
9+
import yaml
10+
11+
CONFIG_DIR = Path.home() / ".config" / "droidctx"
12+
CONFIG_FILE = CONFIG_DIR / "auto-sync.yaml"
13+
LOG_FILE = CONFIG_DIR / "auto-sync.log"
14+
15+
16+
def load_config() -> dict[str, Any]:
17+
"""Load auto-sync config from disk. Returns empty dict if missing."""
18+
if not CONFIG_FILE.exists():
19+
return {}
20+
return yaml.safe_load(CONFIG_FILE.read_text()) or {}
21+
22+
23+
def save_config(config: dict[str, Any]) -> None:
24+
"""Write auto-sync config to disk, creating directory if needed."""
25+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
26+
CONFIG_FILE.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
27+
28+
29+
def resolve_paths(keyfile: Path, output_dir: Optional[Path]) -> tuple[Path, Path]:
30+
"""Return (keyfile, output_dir) as absolute paths.
31+
32+
If output_dir is None, defaults to the keyfile's parent directory.
33+
"""
34+
keyfile = keyfile.resolve()
35+
if output_dir is None:
36+
output_dir = keyfile.parent.resolve()
37+
else:
38+
output_dir = output_dir.resolve()
39+
return keyfile, output_dir
40+
41+
42+
def find_droidctx_binary() -> Optional[str]:
43+
"""Locate the droidctx executable on $PATH."""
44+
return shutil.which("droidctx")
45+
46+
47+
def get_last_run_time() -> Optional[str]:
48+
"""Parse the log file and return the timestamp of the last run, or None."""
49+
if not LOG_FILE.exists():
50+
return None
51+
try:
52+
text = LOG_FILE.read_text().strip()
53+
if not text:
54+
return None
55+
# Return the last non-empty line (most recent log entry)
56+
for line in reversed(text.splitlines()):
57+
line = line.strip()
58+
if line:
59+
return line
60+
return None
61+
except OSError:
62+
return None
63+
64+
65+
def build_config(
66+
*,
67+
keyfile: Path,
68+
output_dir: Path,
69+
interval_minutes: int,
70+
droidctx_bin: str,
71+
) -> dict[str, Any]:
72+
"""Build a config dict ready to be saved."""
73+
return {
74+
"enabled": True,
75+
"interval_minutes": interval_minutes,
76+
"keyfile": str(keyfile),
77+
"output_dir": str(output_dir),
78+
"droidctx_bin": droidctx_bin,
79+
"platform": sys.platform,
80+
"created_at": datetime.now(timezone.utc).isoformat(),
81+
}

droidctx/main.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
add_completion=False,
1717
no_args_is_help=True,
1818
)
19+
auto_sync_app = typer.Typer(
20+
name="auto-sync",
21+
help="Manage automatic periodic syncing.",
22+
no_args_is_help=True,
23+
)
24+
app.add_typer(auto_sync_app, name="auto-sync")
1925
console = Console()
2026

2127

@@ -286,6 +292,101 @@ def list_connectors(
286292
console.print(f"\n[dim]{len(CONNECTOR_CREDENTIALS)} connectors supported. Use --type <TYPE> for details.[/]\n")
287293

288294

295+
@auto_sync_app.command()
296+
def enable(
297+
keyfile: Path = typer.Option(..., "--keyfile", "-k", help="Path to credentials YAML file"),
298+
path: Optional[Path] = typer.Option(None, "--path", "-p", help="Output directory (default: same as keyfile dir)"),
299+
interval: int = typer.Option(30, "--interval", "-i", help="Sync interval in minutes"),
300+
):
301+
"""Enable automatic periodic syncing."""
302+
from droidctx.auto_sync import resolve_paths, find_droidctx_binary, save_config, build_config
303+
from droidctx.scheduler import get_scheduler
304+
305+
keyfile_abs, output_dir = resolve_paths(keyfile, path)
306+
307+
if not keyfile_abs.exists():
308+
console.print(f"[red]Keyfile not found: {keyfile_abs}[/]")
309+
raise typer.Exit(1)
310+
311+
droidctx_bin = find_droidctx_binary()
312+
if not droidctx_bin:
313+
console.print("[red]Could not find 'droidctx' on PATH. Is it installed?[/]")
314+
raise typer.Exit(1)
315+
316+
config = build_config(
317+
keyfile=keyfile_abs,
318+
output_dir=output_dir,
319+
interval_minutes=interval,
320+
droidctx_bin=droidctx_bin,
321+
)
322+
323+
scheduler = get_scheduler()
324+
# Re-enable: uninstall old job first
325+
if scheduler.is_active():
326+
scheduler.uninstall()
327+
scheduler.install(config)
328+
save_config(config)
329+
330+
console.print(f"[bold green]Auto-sync enabled[/] (every {interval} min)")
331+
console.print(f" keyfile: {keyfile_abs}")
332+
console.print(f" output_dir: {output_dir}")
333+
console.print(f" binary: {droidctx_bin}")
334+
335+
336+
@auto_sync_app.command()
337+
def disable():
338+
"""Disable automatic periodic syncing."""
339+
from droidctx.auto_sync import load_config, save_config
340+
from droidctx.scheduler import get_scheduler
341+
342+
config = load_config()
343+
scheduler = get_scheduler()
344+
345+
if not config.get("enabled") and not scheduler.is_active():
346+
console.print("[yellow]Auto-sync is not currently enabled.[/]")
347+
raise typer.Exit(0)
348+
349+
if scheduler.is_active():
350+
scheduler.uninstall()
351+
352+
config["enabled"] = False
353+
save_config(config)
354+
console.print("[bold green]Auto-sync disabled.[/]")
355+
356+
357+
@auto_sync_app.command()
358+
def status():
359+
"""Show current auto-sync status."""
360+
from droidctx.auto_sync import load_config, get_last_run_time
361+
from droidctx.scheduler import get_scheduler
362+
363+
config = load_config()
364+
if not config:
365+
console.print("[yellow]Auto-sync has not been configured.[/]")
366+
raise typer.Exit(0)
367+
368+
scheduler = get_scheduler()
369+
active = scheduler.is_active()
370+
371+
table = Table(title="Auto-Sync Status")
372+
table.add_column("Setting", style="bold")
373+
table.add_column("Value")
374+
375+
table.add_row("Enabled", "[green]yes[/]" if config.get("enabled") and active else "[red]no[/]")
376+
table.add_row("Interval", f"{config.get('interval_minutes', '?')} minutes")
377+
table.add_row("Keyfile", str(config.get("keyfile", "?")))
378+
table.add_row("Output dir", str(config.get("output_dir", "?")))
379+
table.add_row("Binary", str(config.get("droidctx_bin", "?")))
380+
table.add_row("Platform", str(config.get("platform", "?")))
381+
382+
last_run = get_last_run_time()
383+
table.add_row("Last log entry", last_run or "[dim]none[/]")
384+
385+
console.print()
386+
console.print(table)
387+
console.print()
388+
389+
289390
def _get_commented_reference(exclude: set[str] | None = None) -> str:
290391
"""Return all connector templates as commented YAML for reference.
291392

droidctx/scheduler.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Platform-specific scheduler backends for auto-sync."""
2+
3+
import abc
4+
import subprocess
5+
import sys
6+
import textwrap
7+
from pathlib import Path
8+
from typing import Any
9+
10+
11+
class Scheduler(abc.ABC):
12+
"""Abstract base for OS-level periodic job schedulers."""
13+
14+
@abc.abstractmethod
15+
def install(self, config: dict[str, Any]) -> None:
16+
"""Register the periodic sync job."""
17+
18+
@abc.abstractmethod
19+
def uninstall(self) -> None:
20+
"""Remove the periodic sync job."""
21+
22+
@abc.abstractmethod
23+
def is_active(self) -> bool:
24+
"""Return True if the job is currently registered."""
25+
26+
27+
# ---------------------------------------------------------------------------
28+
# macOS launchd
29+
# ---------------------------------------------------------------------------
30+
31+
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / "io.drdroid.droidctx.auto-sync.plist"
32+
33+
_PLIST_TEMPLATE = textwrap.dedent("""\
34+
<?xml version="1.0" encoding="UTF-8"?>
35+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
36+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37+
<plist version="1.0">
38+
<dict>
39+
<key>Label</key>
40+
<string>io.drdroid.droidctx.auto-sync</string>
41+
<key>ProgramArguments</key>
42+
<array>
43+
<string>{droidctx_bin}</string>
44+
<string>sync</string>
45+
<string>--keyfile</string>
46+
<string>{keyfile}</string>
47+
<string>--path</string>
48+
<string>{output_dir}</string>
49+
</array>
50+
<key>StartInterval</key>
51+
<integer>{interval_seconds}</integer>
52+
<key>StandardOutPath</key>
53+
<string>{log_file}</string>
54+
<key>StandardErrorPath</key>
55+
<string>{log_file}</string>
56+
</dict>
57+
</plist>
58+
""")
59+
60+
61+
class LaunchdScheduler(Scheduler):
62+
"""macOS launchd backend."""
63+
64+
def install(self, config: dict[str, Any]) -> None:
65+
from droidctx.auto_sync import LOG_FILE
66+
67+
plist_content = _PLIST_TEMPLATE.format(
68+
droidctx_bin=config["droidctx_bin"],
69+
keyfile=config["keyfile"],
70+
output_dir=config["output_dir"],
71+
interval_seconds=config["interval_minutes"] * 60,
72+
log_file=str(LOG_FILE),
73+
)
74+
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
75+
PLIST_PATH.write_text(plist_content)
76+
subprocess.run(
77+
["launchctl", "load", str(PLIST_PATH)],
78+
check=True,
79+
capture_output=True,
80+
)
81+
82+
def uninstall(self) -> None:
83+
if PLIST_PATH.exists():
84+
subprocess.run(
85+
["launchctl", "unload", str(PLIST_PATH)],
86+
check=True,
87+
capture_output=True,
88+
)
89+
PLIST_PATH.unlink()
90+
91+
def is_active(self) -> bool:
92+
return PLIST_PATH.exists()
93+
94+
95+
# ---------------------------------------------------------------------------
96+
# Linux cron
97+
# ---------------------------------------------------------------------------
98+
99+
CRON_MARKER = "# droidctx-auto-sync"
100+
101+
102+
class CronScheduler(Scheduler):
103+
"""Linux crontab backend."""
104+
105+
def _read_crontab(self) -> str:
106+
result = subprocess.run(
107+
["crontab", "-l"],
108+
capture_output=True,
109+
text=True,
110+
)
111+
if result.returncode != 0:
112+
return ""
113+
return result.stdout
114+
115+
def _write_crontab(self, content: str) -> None:
116+
subprocess.run(
117+
["crontab", "-"],
118+
input=content,
119+
check=True,
120+
text=True,
121+
capture_output=True,
122+
)
123+
124+
def install(self, config: dict[str, Any]) -> None:
125+
from droidctx.auto_sync import LOG_FILE
126+
127+
# Remove old entry first
128+
existing = self._read_crontab()
129+
lines = [l for l in existing.splitlines() if CRON_MARKER not in l]
130+
131+
cmd = (
132+
f"{config['droidctx_bin']} sync "
133+
f"--keyfile {config['keyfile']} "
134+
f"--path {config['output_dir']}"
135+
)
136+
cron_line = f"*/{config['interval_minutes']} * * * * {cmd} >> {LOG_FILE} 2>&1 {CRON_MARKER}"
137+
lines.append(cron_line)
138+
139+
self._write_crontab("\n".join(lines) + "\n")
140+
141+
def uninstall(self) -> None:
142+
existing = self._read_crontab()
143+
lines = [l for l in existing.splitlines() if CRON_MARKER not in l]
144+
self._write_crontab("\n".join(lines) + "\n" if lines else "")
145+
146+
def is_active(self) -> bool:
147+
return CRON_MARKER in self._read_crontab()
148+
149+
150+
# ---------------------------------------------------------------------------
151+
# Factory
152+
# ---------------------------------------------------------------------------
153+
154+
def get_scheduler() -> Scheduler:
155+
"""Return the appropriate scheduler for the current platform."""
156+
if sys.platform == "darwin":
157+
return LaunchdScheduler()
158+
elif sys.platform.startswith("linux"):
159+
return CronScheduler()
160+
else:
161+
raise NotImplementedError(f"Auto-sync is not supported on {sys.platform}")

0 commit comments

Comments
 (0)