Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473use clap::{Parser, Subcommand};use clap_complete::Shell;use qrcode::render::unicode::Dense1x2;
use std::{ fs, io::{self, IsTerminal, Read}, path::{Path, PathBuf},};
use crate::app::AppaService;use crate::config::DEFAULT_CONFIG_PATH;use crate::storage::AppPaths;
mod setup;mod tooling;
const DEFAULT_LOG_FILTER: &str = "info,noq_proto=error";
struct InviteOutput { copy_to_clipboard: bool, render_qr: bool, output_path: Option<PathBuf>,}
enum FolderRemoval { Forget,}
impl FolderRemoval { fn prompt(&self) -> &'static str { match self { Self::Forget => "Forget this folder's Appa sync state? Files will stay in place", } }
fn print_confirmation(&self, folder: &Path) { match self { Self::Forget => println!( "Forgot Appa sync state for {}. Files were not changed.", folder.display() ), } }}
#[derive(Debug, Parser)]#[command( name = "appa", about = "Local-first peer-to-peer folder sync", after_help = "Run `appa <command> --help` for command-specific usage. Start the daemon with `appa daemon serve` or `appa service install`.")]struct CommandLine { #[command(subcommand)] command: Command,}
#[derive(Debug, Subcommand)]enum Command { #[command(about = "Run the local synchronization daemon directly")] Daemon { #[command(subcommand)] command: DaemonCommand, }, #[command(about = "Show, export, or import this device's identity")] Identity { #[command(subcommand)] command: Option<IdentityCommand>, }, #[command(about = "Generate shell-completion code")] Completions { #[arg( value_name = "SHELL", help = "Shell: bash, elvish, fish, powershell, or zsh" )] shell: Shell, }, #[command(about = "Manage the background Appa user service")] Service { #[command(subcommand)] command: ServiceCommand, }, #[command(about = "Export, validate, or import a local folder inventory")] Folder { #[command(subcommand)] command: FolderCommand, }, #[command(about = "Check Appa state for common problems")] Doctor { #[arg(long, help = "Emit machine-readable JSON")] json: bool, #[arg( long, help = "Drop persistent legacy blob tags; periodic GC reclaims orphaned bytes" )] drop_legacy_tags: bool, },}
#[derive(Debug, Subcommand)]enum FolderCommand { Add { folder: String, }, Join { folder: String, ticket: Option<String>, #[arg(long)] stdin: bool, #[arg(long)] yes: bool, }, Remove { folder: String, #[arg(long)] yes: bool, }, Invite { folder: String, #[arg(long)] copy: bool, #[arg(long)] qr: bool, #[arg(long)] file: Option<PathBuf>, }, Status { folder: Option<String>, #[arg(long)] json: bool, #[arg(long, conflicts_with = "json")] watch: bool, }, Peers { folder: String, #[arg(long)] json: bool, }, Members { folder: String, }, History { folder: String, }, Restore { folder: String, #[arg(allow_negative_numbers = true)] revision: i64, }, Conflicts { folder: String, }, Audit { folder: String, #[arg(long)] verify: bool, }, Revoke { folder: String, device_id: String, }, #[command(about = "Write a local folder inventory; fails if the file exists")] Template { #[arg(default_value = DEFAULT_CONFIG_PATH, value_name = "PATH", help = "Inventory path")] path: PathBuf, }, #[command(about = "Validate a local folder inventory and its paths")] Validate { #[arg(default_value = DEFAULT_CONFIG_PATH, value_name = "PATH", help = "Inventory path")] path: PathBuf, }, #[command(about = "Register folders and update local modes from an inventory")] Import { #[arg(default_value = DEFAULT_CONFIG_PATH, value_name = "PATH", help = "Inventory path")] path: PathBuf, },}
#[derive(Debug, Subcommand)]enum IdentityCommand { #[command(about = "Write a sensitive backup of this device's identity")] Export { #[arg(value_name = "PATH", help = "Destination backup path")] path: PathBuf, }, #[command(about = "Replace this device's identity from a backup")] Import { #[arg(value_name = "PATH", help = "Identity backup to import")] path: PathBuf, #[arg(long, help = "Skip the replacement confirmation")] yes: bool, },}
#[derive(Debug, Subcommand)]enum ServiceCommand { #[command(about = "Install and start the Appa user service")] Install, #[command(about = "Show the Appa user service status")] Status, #[command(about = "Restart the Appa user service")] Restart, #[command(about = "Show recent Appa user service logs")] Logs, #[command(about = "Stop and remove the Appa user service")] Uninstall,}
#[derive(Debug, Subcommand)]enum DaemonCommand { #[command(about = "Serve the local Appa daemon")] Serve,}
pub async fn run() -> anyhow::Result<()> { initialize_logging();
let CommandLine { command } = CommandLine::parse(); tracing::debug!(command = ?command, "dispatched Appa command"); match command { Command::Daemon { command: DaemonCommand::Serve, } => crate::daemon::serve(None).await?, Command::Service { command } => tooling::run_service_command(command)?, Command::Completions { shell } => tooling::print_completions(shell), command => run_app_command(command).await?, } Ok(())}
async fn run_app_command(command: Command) -> anyhow::Result<()> { if matches!(command, Command::Identity { .. }) { return run_local_command(command).await; } if let Command::Folder { command } = command { return run_folder_command(command).await; } match command { Command::Doctor { json, drop_legacy_tags, } => { if drop_legacy_tags { println!( "{}", crate::ipc::DaemonClient::connect()? .drop_legacy_tags(json) .await? ); } else { println!( "{}", crate::ipc::DaemonClient::connect()?.doctor(json).await? ); } } Command::Completions { .. } => unreachable!("handled before opening Appa"), Command::Service { .. } => unreachable!("service commands are handled before opening Appa"), Command::Daemon { .. } => unreachable!("daemon commands are handled before opening Appa"), Command::Identity { .. } | Command::Folder { .. } => { unreachable!("handled before opening Appa") } } Ok(())}
async fn run_local_command(command: Command) -> anyhow::Result<()> { let paths = AppPaths::discover()?; if paths.daemon_socket_path().exists() { anyhow::bail!("stop the Appa daemon before replacing or exporting its identity"); } let appa = AppaService::open()?; match command { Command::Identity { command } => setup::run_identity_command(&appa, command)?, _ => unreachable!("only identity commands reach this handler"), } Ok(())}
async fn run_folder_command(command: FolderCommand) -> anyhow::Result<()> { let client = crate::ipc::DaemonClient::connect()?; match command { FolderCommand::Add { folder } => client.register_folder(PathBuf::from(folder)).await?, FolderCommand::Join { folder, ticket, stdin, yes, } => join_folder_remote(&client, PathBuf::from(folder), ticket, stdin, yes).await?, FolderCommand::Remove { folder, yes } => { remove_folder_state_remote(&client, PathBuf::from(folder), yes, FolderRemoval::Forget) .await? } FolderCommand::Invite { folder, copy, qr, file, } => { print_remote_invite( &client, PathBuf::from(folder), InviteOutput { copy_to_clipboard: copy, render_qr: qr, output_path: file, }, ) .await? } FolderCommand::Status { folder, json, watch, } => { let folder = folder.map(PathBuf::from); if watch { watch_status_remote(&client, folder).await? } else { println!("{}", client.status(folder, json).await?) } } FolderCommand::Peers { folder, json } => { println!("{}", client.peers(PathBuf::from(folder), json).await?) } FolderCommand::Members { folder } => { println!("{}", client.members(PathBuf::from(folder)).await?) } FolderCommand::History { folder } => { println!("{}", client.history(PathBuf::from(folder)).await?) } FolderCommand::Restore { folder, revision } => println!( "Restored revision {revision} ({} file writes).", client .restore_revision(PathBuf::from(folder), revision) .await? ), FolderCommand::Conflicts { folder } => { println!("{}", client.conflicts(PathBuf::from(folder)).await?) } FolderCommand::Audit { folder, verify } => { println!("{}", client.audit(PathBuf::from(folder), verify).await?) } FolderCommand::Revoke { folder, device_id } => { client .revoke_member(PathBuf::from(folder), device_id) .await? } FolderCommand::Import { path } => { let client = crate::ipc::DaemonClient::connect()?; let count = client.import_folder_inventory(path.clone()).await?; println!("Imported {count} folder(s) from {}.", path.display()); } command => setup::run_folder_command(&AppaService::open()?, command)?, } Ok(())}
async fn print_remote_invite( client: &crate::ipc::DaemonClient, folder: PathBuf, output: InviteOutput,) -> anyhow::Result<()> { let ticket = client.create_invite(folder).await?; println!( "Share this reusable folder capability only with trusted devices. It expires in 24 hours and has no use limit:\n{ticket}" ); if let Some(path) = output.output_path { fs::write(&path, format!("{ticket}\n"))?; println!("Wrote invitation to {}.", path.display()); } if output.copy_to_clipboard { arboard::Clipboard::new()?.set_text(ticket.clone())?; println!("Copied invitation to the clipboard."); } if output.render_qr { let code = qrcode::QrCode::new(ticket.as_bytes())?; println!("{}", code.render::<Dense1x2>().build()); } Ok(())}
async fn join_folder_remote( client: &crate::ipc::DaemonClient, folder: PathBuf, ticket: Option<String>, stdin: bool, yes: bool,) -> anyhow::Result<()> { if !confirm(yes, "Join this Appa folder?")? { return Ok(()); } let ticket = resolve_ticket(ticket, stdin)?; client.join_folder(folder.clone(), ticket).await?; println!("Joined Appa folder at {}.", folder.display()); Ok(())}
async fn remove_folder_state_remote( client: &crate::ipc::DaemonClient, folder: PathBuf, yes: bool, removal: FolderRemoval,) -> anyhow::Result<()> { if !confirm(yes, removal.prompt())? { return Ok(()); } client.forget_folder(folder.clone()).await?; removal.print_confirmation(&folder); Ok(())}
async fn watch_status_remote( client: &crate::ipc::DaemonClient, folder: Option<PathBuf>,) -> anyhow::Result<()> { loop { if std::io::stdout().is_terminal() { print!("\x1b[2J\x1b[H"); } println!("Appa status - refreshing every second; press Ctrl-C to stop\n"); match client.status(folder.clone(), false).await { Ok(status) => println!("{status}"), Err(error) => tracing::warn!(%error, "Could not refresh Appa status"), } tokio::select! { _ = tokio::signal::ctrl_c() => return Ok(()), _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}, } }}
fn confirm(yes: bool, prompt: &str) -> anyhow::Result<bool> { Ok(yes || dialoguer::Confirm::new().with_prompt(prompt).interact()?)}
fn resolve_ticket(ticket: Option<String>, stdin: bool) -> anyhow::Result<String> { resolve_ticket_from(ticket, stdin, &mut io::stdin())}
fn resolve_ticket_from( ticket: Option<String>, stdin: bool, reader: &mut impl Read,) -> anyhow::Result<String> { if let Some(ticket) = ticket { if stdin { anyhow::bail!("pass either an invitation argument or --stdin, not both"); } return Ok(ticket); } if !stdin { anyhow::bail!("pass an invitation argument or use --stdin"); } let mut ticket = String::new(); reader.read_to_string(&mut ticket)?; Ok(ticket.trim().to_owned())}
fn initialize_logging() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER)), ) .with_target(false) .init();}
#[cfg(test)]#[path = "cli/tests.rs"]mod tests;