use 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, } 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 --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, }, #[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, #[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, }, Status { folder: Option, #[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::().build()); } Ok(()) } async fn join_folder_remote( client: &crate::ipc::DaemonClient, folder: PathBuf, ticket: Option, 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, ) -> 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 { Ok(yes || dialoguer::Confirm::new().with_prompt(prompt).interact()?) } fn resolve_ticket(ticket: Option, stdin: bool) -> anyhow::Result { resolve_ticket_from(ticket, stdin, &mut io::stdin()) } fn resolve_ticket_from( ticket: Option, stdin: bool, reader: &mut impl Read, ) -> anyhow::Result { 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;