feat(cron): add lore cron command for automated sync scheduling
Add lore cron {install,uninstall,status} to manage a crontab entry that
runs lore sync on a configurable interval. Supports both human and robot
output modes.
Core implementation (src/core/cron.rs):
- install_cron: appends a tagged crontab entry, detects existing entries
- uninstall_cron: removes the tagged entry
- cron_status: reads crontab + checks last-sync time from the database
- Unix-only (#[cfg(unix)]) — compiles out on Windows
CLI wiring:
- CronAction enum and CronArgs in cli/mod.rs with after_help examples
- Robot JSON envelope with RobotMeta timing for all 3 sub-actions
- Dispatch in main.rs
Also in this commit:
- Add after_help example blocks to Status, Auth, Doctor, Init, Migrate,
Health commands for better discoverability
- Add LORE_ICONS env var documentation to CLI help text
- Simplify notes format dispatch in main.rs (removed csv/jsonl paths)
- Update commands/mod.rs re-exports for cron + notes cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
292
src/cli/commands/cron.rs
Normal file
292
src/cli/commands/cron.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
use crate::cli::render::Theme;
|
||||
use crate::cli::robot::RobotMeta;
|
||||
use crate::core::cron::{
|
||||
CronInstallResult, CronStatusResult, CronUninstallResult, cron_status, install_cron,
|
||||
uninstall_cron,
|
||||
};
|
||||
use crate::core::db::create_connection;
|
||||
use crate::core::error::Result;
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::time::ms_to_iso;
|
||||
|
||||
// ── install ──
|
||||
|
||||
pub fn run_cron_install(interval_minutes: u32) -> Result<CronInstallResult> {
|
||||
install_cron(interval_minutes)
|
||||
}
|
||||
|
||||
pub fn print_cron_install(result: &CronInstallResult) {
|
||||
if result.replaced {
|
||||
println!(
|
||||
" {} cron entry updated (was already installed)",
|
||||
Theme::success().render("Updated")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} cron entry installed",
|
||||
Theme::success().render("Installed")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(" {} {}", Theme::dim().render("entry:"), result.entry);
|
||||
println!(
|
||||
" {} every {} minutes",
|
||||
Theme::dim().render("interval:"),
|
||||
result.interval_minutes
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
Theme::dim().render("log:"),
|
||||
result.log_path.display()
|
||||
);
|
||||
|
||||
if cfg!(target_os = "macos") {
|
||||
println!();
|
||||
println!(
|
||||
" {} On macOS, the terminal running cron may need",
|
||||
Theme::warning().render("Note:")
|
||||
);
|
||||
println!(" Full Disk Access in System Settings > Privacy & Security.");
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronInstallJson {
|
||||
ok: bool,
|
||||
data: CronInstallData,
|
||||
meta: RobotMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronInstallData {
|
||||
action: &'static str,
|
||||
entry: String,
|
||||
interval_minutes: u32,
|
||||
log_path: String,
|
||||
replaced: bool,
|
||||
}
|
||||
|
||||
pub fn print_cron_install_json(result: &CronInstallResult, elapsed_ms: u64) {
|
||||
let output = CronInstallJson {
|
||||
ok: true,
|
||||
data: CronInstallData {
|
||||
action: "install",
|
||||
entry: result.entry.clone(),
|
||||
interval_minutes: result.interval_minutes,
|
||||
log_path: result.log_path.display().to_string(),
|
||||
replaced: result.replaced,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── uninstall ──
|
||||
|
||||
pub fn run_cron_uninstall() -> Result<CronUninstallResult> {
|
||||
uninstall_cron()
|
||||
}
|
||||
|
||||
pub fn print_cron_uninstall(result: &CronUninstallResult) {
|
||||
if result.was_installed {
|
||||
println!(
|
||||
" {} cron entry removed",
|
||||
Theme::success().render("Removed")
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} no lore-sync cron entry found",
|
||||
Theme::dim().render("Nothing to remove:")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronUninstallJson {
|
||||
ok: bool,
|
||||
data: CronUninstallData,
|
||||
meta: RobotMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronUninstallData {
|
||||
action: &'static str,
|
||||
was_installed: bool,
|
||||
}
|
||||
|
||||
pub fn print_cron_uninstall_json(result: &CronUninstallResult, elapsed_ms: u64) {
|
||||
let output = CronUninstallJson {
|
||||
ok: true,
|
||||
data: CronUninstallData {
|
||||
action: "uninstall",
|
||||
was_installed: result.was_installed,
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── status ──
|
||||
|
||||
pub fn run_cron_status(config: &Config) -> Result<CronStatusInfo> {
|
||||
let status = cron_status()?;
|
||||
|
||||
// Query last sync run from DB
|
||||
let last_sync = get_last_sync_time(config).unwrap_or_default();
|
||||
|
||||
Ok(CronStatusInfo { status, last_sync })
|
||||
}
|
||||
|
||||
pub struct CronStatusInfo {
|
||||
pub status: CronStatusResult,
|
||||
pub last_sync: Option<LastSyncInfo>,
|
||||
}
|
||||
|
||||
pub struct LastSyncInfo {
|
||||
pub started_at_iso: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
fn get_last_sync_time(config: &Config) -> Result<Option<LastSyncInfo>> {
|
||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||
if !db_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let conn = create_connection(&db_path)?;
|
||||
let result = conn.query_row(
|
||||
"SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1",
|
||||
[],
|
||||
|row| {
|
||||
let started_at: i64 = row.get(0)?;
|
||||
let status: String = row.get(1)?;
|
||||
Ok(LastSyncInfo {
|
||||
started_at_iso: ms_to_iso(started_at),
|
||||
status,
|
||||
})
|
||||
},
|
||||
);
|
||||
match result {
|
||||
Ok(info) => Ok(Some(info)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
// Table may not exist if migrations haven't run yet
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(ref msg))) if msg.contains("no such table") => {
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_cron_status(info: &CronStatusInfo) {
|
||||
if info.status.installed {
|
||||
println!(
|
||||
" {} lore-sync is installed in crontab",
|
||||
Theme::success().render("Installed")
|
||||
);
|
||||
if let Some(interval) = info.status.interval_minutes {
|
||||
println!(
|
||||
" {} every {} minutes",
|
||||
Theme::dim().render("interval:"),
|
||||
interval
|
||||
);
|
||||
}
|
||||
if let Some(ref binary) = info.status.binary_path {
|
||||
let label = if info.status.binary_mismatch {
|
||||
Theme::warning().render("binary:")
|
||||
} else {
|
||||
Theme::dim().render("binary:")
|
||||
};
|
||||
println!(" {label} {binary}");
|
||||
if info.status.binary_mismatch
|
||||
&& let Some(ref current) = info.status.current_binary
|
||||
{
|
||||
println!(
|
||||
" {}",
|
||||
Theme::warning().render(&format!(" current binary is {current} (mismatch!)"))
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ref log) = info.status.log_path {
|
||||
println!(" {} {}", Theme::dim().render("log:"), log.display());
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
" {} lore-sync is not installed in crontab",
|
||||
Theme::dim().render("Not installed:")
|
||||
);
|
||||
println!(
|
||||
" {} lore cron install",
|
||||
Theme::dim().render("install with:")
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref last) = info.last_sync {
|
||||
println!(
|
||||
" {} {} ({})",
|
||||
Theme::dim().render("last sync:"),
|
||||
last.started_at_iso,
|
||||
last.status
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronStatusJson {
|
||||
ok: bool,
|
||||
data: CronStatusData,
|
||||
meta: RobotMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CronStatusData {
|
||||
installed: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
interval_minutes: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
binary_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
current_binary: Option<String>,
|
||||
binary_mismatch: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
log_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
cron_entry: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
last_sync_at: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
last_sync_status: Option<String>,
|
||||
}
|
||||
|
||||
pub fn print_cron_status_json(info: &CronStatusInfo, elapsed_ms: u64) {
|
||||
let output = CronStatusJson {
|
||||
ok: true,
|
||||
data: CronStatusData {
|
||||
installed: info.status.installed,
|
||||
interval_minutes: info.status.interval_minutes,
|
||||
binary_path: info.status.binary_path.clone(),
|
||||
current_binary: info.status.current_binary.clone(),
|
||||
binary_mismatch: info.status.binary_mismatch,
|
||||
log_path: info
|
||||
.status
|
||||
.log_path
|
||||
.as_ref()
|
||||
.map(|p| p.display().to_string()),
|
||||
cron_entry: info.status.cron_entry.clone(),
|
||||
last_sync_at: info.last_sync.as_ref().map(|s| s.started_at_iso.clone()),
|
||||
last_sync_status: info.last_sync.as_ref().map(|s| s.status.clone()),
|
||||
},
|
||||
meta: RobotMeta { elapsed_ms },
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&output) {
|
||||
println!("{json}");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod auth_test;
|
||||
pub mod count;
|
||||
#[cfg(unix)]
|
||||
pub mod cron;
|
||||
pub mod doctor;
|
||||
pub mod drift;
|
||||
pub mod embed;
|
||||
@@ -22,6 +24,12 @@ pub use count::{
|
||||
print_count, print_count_json, print_event_count, print_event_count_json, run_count,
|
||||
run_count_events,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
pub use cron::{
|
||||
print_cron_install, print_cron_install_json, print_cron_status, print_cron_status_json,
|
||||
print_cron_uninstall, print_cron_uninstall_json, run_cron_install, run_cron_status,
|
||||
run_cron_uninstall,
|
||||
};
|
||||
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
|
||||
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
|
||||
pub use embed::{print_embed, print_embed_json, run_embed};
|
||||
@@ -35,8 +43,7 @@ pub use init::{InitInputs, InitOptions, InitResult, run_init};
|
||||
pub use list::{
|
||||
ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser,
|
||||
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
|
||||
print_list_notes, print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl,
|
||||
query_notes, run_list_issues, run_list_mrs,
|
||||
print_list_notes, print_list_notes_json, query_notes, run_list_issues, run_list_mrs,
|
||||
};
|
||||
pub use search::{
|
||||
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,
|
||||
|
||||
Reference in New Issue
Block a user