diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -26,15 +26,15 @@ On the first device: ```sh -appa init ~/notes -appa invite ~/notes +appa folder add ~/notes +appa folder invite ~/notes ``` On another device, create the destination directory and join with the invitation: ```sh -appa join ~/notes --stdin +appa folder join ~/notes --stdin ``` An invitation grants access to one folder. Treat it like a password. @@ -42,32 +42,32 @@ Check a folder at any time: ```sh -appa status ~/notes -appa peers ~/notes -appa members ~/notes +appa folder status ~/notes +appa folder peers ~/notes +appa folder members ~/notes ``` -`appa status --watch` refreshes the display once a second. +`appa folder status --watch` refreshes the display once a second. ## Everyday commands | Task | Command | | --- | --- | -| Add a local folder | `appa init ` | -| Invite a device | `appa invite --copy` | -| Join a shared folder | `appa join --stdin` | -| View health | `appa status [folder]` | -| View conflicts | `appa conflicts ` | -| View saved revisions | `appa history ` | -| Restore a revision | `appa restore ` | -| Verify the audit log | `appa audit --verify` | -| Remove local Appa state | `appa forget ` | +| Add a local folder | `appa folder add ` | +| Invite a device | `appa folder invite ` | +| Join a shared folder | `appa folder join --stdin` | +| View health | `appa folder status [folder]` | +| View conflicts | `appa folder conflicts ` | +| View saved revisions | `appa folder history ` | +| Restore a revision | `appa folder restore ` | +| Verify the audit log | `appa folder audit --verify` | +| Remove local Appa state | `appa folder remove ` | -`forget` leaves files alone. It does not revoke the device from the shared +`remove` leaves files alone. It does not revoke the device from the shared folder. Only the folder owner can revoke a member: ```sh -appa revoke ~/notes +appa folder revoke ~/notes ``` Revocation rotates the folder capability. Create new invitations for members @@ -95,7 +95,7 @@ gitignore syntax. Symlinks are ignored and non-Unicode filenames are rejected. When devices edit the same file while disconnected, Appa keeps both versions. -Use `appa conflicts ` to find the additional copies, then choose the +Use `appa folder conflicts ` to find the additional copies, then choose the content you want. Appa keeps 100 local revisions by default. Set `APPA_HISTORY_REVISIONS` to a diff --git a/docs/architecture.md b/docs/architecture.md --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,7 +50,7 @@ daemon socket. `APPA_HOME` changes its location. It is separate from synced folders. -`appa forget ` removes local Appa state without touching files or +`appa folder remove ` removes local Appa state without touching files or remote membership. Folder inventories are optional local input files for bulk registration and mode changes. Invitations remain the only way to join an existing shared folder. diff --git a/src/cli.rs b/src/cli.rs --- a/src/cli.rs +++ b/src/cli.rs @@ -25,14 +25,12 @@ enum FolderRemoval { Forget, - Leave, } impl FolderRemoval { fn prompt(&self) -> &'static str { match self { Self::Forget => "Forget this folder's Appa sync state? Files will stay in place", - Self::Leave => "Leave this Appa folder? Your files will stay in place", } } @@ -42,7 +40,6 @@ "Forgot Appa sync state for {}. Files were not changed.", folder.display() ), - Self::Leave => println!("Left {}. Your files were not changed.", folder.display()), } } } @@ -51,7 +48,7 @@ #[command( name = "appa", about = "Local-first peer-to-peer folder sync", - after_help = "Run `appa --help` for command-specific usage. Most commands connect to the local daemon; start it with `appa run` or `appa service install`." + after_help = "Run `appa --help` for command-specific usage. Start the daemon with `appa daemon serve` or `appa service install`." )] struct CommandLine { #[command(subcommand)] @@ -65,128 +62,10 @@ #[command(subcommand)] command: DaemonCommand, }, - #[command(about = "Add an existing directory as a new synced folder")] - Init { - #[arg(value_name = "FOLDER", help = "Existing directory to synchronize")] - folder: String, - }, #[command(about = "Show, export, or import this device's identity")] Identity { #[command(subcommand)] command: Option, - }, - #[command(about = "Create a reusable invitation for a synced folder")] - Invite { - #[arg(value_name = "FOLDER", help = "Folder to invite another device to")] - folder: String, - #[arg(long, help = "Copy the invitation to the system clipboard")] - copy: bool, - #[arg(long, help = "Print the invitation as a terminal QR code")] - qr: bool, - #[arg(long, value_name = "PATH", help = "Write the invitation to a file")] - file: Option, - }, - #[command(about = "Join a folder with an invitation")] - Join { - #[arg( - value_name = "FOLDER", - help = "Existing directory to join to the invitation" - )] - folder: String, - #[arg( - value_name = "TICKET", - help = "Invitation text; omit when using --stdin" - )] - ticket: Option, - #[arg(long, help = "Read the invitation from standard input")] - stdin: bool, - #[arg(long, help = "Skip the confirmation prompt")] - yes: bool, - }, - #[command(about = "Remove a folder's local Appa state without changing its files")] - Forget { - #[arg(value_name = "FOLDER", help = "Folder to forget")] - folder: String, - #[arg(long, help = "Skip the confirmation prompt")] - yes: bool, - }, - #[command(about = "Stop syncing a local folder without changing its files")] - Leave { - #[arg(value_name = "FOLDER", help = "Folder to leave")] - folder: String, - #[arg(long, help = "Skip the confirmation prompt")] - yes: bool, - }, - #[command(about = "Run the daemon and synchronize folders")] - Run { - #[arg( - value_name = "FOLDER", - help = "Synchronize only this folder; defaults to all folders" - )] - folder: Option, - }, - #[command(about = "Show synchronization health")] - Status { - #[arg( - value_name = "FOLDER", - help = "Show one folder; defaults to all folders" - )] - folder: Option, - #[arg(long, help = "Emit machine-readable JSON")] - json: bool, - #[arg( - long, - conflicts_with = "json", - help = "Refresh the human-readable view every second" - )] - watch: bool, - }, - #[command(about = "List peer routes for a folder")] - Peers { - #[arg(value_name = "FOLDER", help = "Folder whose peers to list")] - folder: String, - #[arg(long, help = "Emit machine-readable JSON")] - json: bool, - }, - #[command(about = "List saved revisions for a folder")] - History { - #[arg(value_name = "FOLDER", help = "Folder whose history to show")] - folder: String, - }, - #[command(about = "Show or verify the signed folder audit log")] - Audit { - #[arg(value_name = "FOLDER", help = "Folder whose audit log to inspect")] - folder: String, - #[arg(long, help = "Verify signatures and chain links")] - verify: bool, - }, - #[command(about = "Restore a saved folder revision")] - Restore { - #[arg(value_name = "FOLDER", help = "Folder to restore")] - folder: String, - #[arg( - allow_negative_numbers = true, - value_name = "REVISION", - help = "Revision to restore; negative numbers are allowed" - )] - revision: i64, - }, - #[command(about = "List conflict copies in a folder")] - Conflicts { - #[arg(value_name = "FOLDER", help = "Folder whose conflicts to list")] - folder: String, - }, - #[command(about = "List devices that can access a folder")] - Members { - #[arg(value_name = "FOLDER", help = "Folder whose members to list")] - folder: String, - }, - #[command(about = "Remove a device from a folder and rotate its capability")] - Revoke { - #[arg(value_name = "FOLDER", help = "Folder to update")] - folder: String, - #[arg(value_name = "DEVICE_ID", help = "Device identity to revoke")] - device_id: String, }, #[command(about = "Generate shell-completion code")] Completions { @@ -215,6 +94,66 @@ #[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")] @@ -279,7 +218,6 @@ } => crate::daemon::serve(None).await?, Command::Service { command } => tooling::run_service_command(command)?, Command::Completions { shell } => tooling::print_completions(shell), - Command::Run { folder } => crate::daemon::serve(folder.as_deref().map(Path::new)).await?, command => run_app_command(command).await?, } Ok(()) @@ -292,88 +230,17 @@ if let Command::Folder { command } = command { return run_folder_command(command).await; } - let client = crate::ipc::DaemonClient::connect()?; match command { - Command::Init { folder } => { - client.register_folder(PathBuf::from(&folder)).await?; - println!("Appa is ready to sync {folder}."); - } - Command::Invite { - folder, - copy, - qr, - file, - } => { - print_remote_invite( - &client, - PathBuf::from(folder), - InviteOutput { - copy_to_clipboard: copy, - render_qr: qr, - output_path: file, - }, - ) - .await? - } - Command::Join { - folder, - ticket, - stdin, - yes, - } => join_folder_remote(&client, PathBuf::from(folder), ticket, stdin, yes).await?, - Command::Forget { folder, yes } => { - remove_folder_state_remote(&client, PathBuf::from(folder), yes, FolderRemoval::Forget) - .await? - } - Command::Leave { folder, yes } => { - remove_folder_state_remote(&client, PathBuf::from(folder), yes, FolderRemoval::Leave) - .await? - } - Command::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?); - } - } - Command::Peers { folder, json } => { - println!("{}", client.peers(PathBuf::from(folder), json).await?) - } - Command::History { folder } => println!("{}", client.history(PathBuf::from(folder)).await?), - Command::Audit { folder, verify } => { - println!("{}", client.audit(PathBuf::from(folder), verify).await?) - } - Command::Restore { folder, revision } => { - let count = client - .restore_revision(PathBuf::from(folder), revision) - .await?; - println!("Restored revision {revision} ({count} file writes)."); - } - Command::Conflicts { folder } => { - println!("{}", client.conflicts(PathBuf::from(folder)).await?) - } - Command::Members { folder } => println!("{}", client.members(PathBuf::from(folder)).await?), - Command::Revoke { folder, device_id } => { - client - .revoke_member(PathBuf::from(folder), device_id.clone()) - .await?; - println!( - "Revoked {device_id}; the folder capability has rotated. Issue fresh invitations to remaining members." - ); - } - Command::Completions { .. } => unreachable!("completions are handled before opening Appa"), - Command::Doctor { json } => println!("{}", client.doctor(json).await?), + Command::Doctor { json } => 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") } - Command::Run { .. } => unreachable!("run is handled before opening Appa"), } Ok(()) } @@ -392,7 +259,74 @@ } 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?; diff --git a/src/cli/setup.rs b/src/cli/setup.rs --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -51,6 +51,7 @@ path.display() ); } + _ => unreachable!("only folder inventory commands reach local setup"), } Ok(()) }