this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Wired command structure & set up login executor

+369 -82
+15
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + All notable changes to this project will be documented in this file. 4 + 5 + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 + 7 + ## [Unreleased] 8 + 9 + ### Added 10 + - Update login command to read password from stdin (#23) 11 + 12 + ### Fixed 13 + 14 + ### Changed 15 + - Add PDS authentication via com.atproto.server.createSession (#2)
+24
crates/opake-cli/src/commands/download.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use anyhow::Result; 4 + use clap::Args; 5 + 6 + use crate::commands::Execute; 7 + 8 + #[derive(Args)] 9 + /// Download and decrypt a file 10 + pub struct DownloadCommand { 11 + /// AT URI of the document record 12 + uri: String, 13 + 14 + /// Output path (defaults to the original filename) 15 + #[arg(short, long)] 16 + output: Option<PathBuf>, 17 + } 18 + 19 + impl Execute for DownloadCommand { 20 + async fn execute(self) -> Result<()> { 21 + let _client = crate::config::load_client()?; 22 + anyhow::bail!("download not yet implemented (tracking: chainlink #6)") 23 + } 24 + }
+179
crates/opake-cli/src/commands/login.rs
··· 1 + use anyhow::Result; 2 + use clap::Args; 3 + use log::debug; 4 + use opake_core::client::XrpcClient; 5 + 6 + use crate::commands::Execute; 7 + use crate::config; 8 + use crate::transport::ReqwestTransport; 9 + use crate::utils::prefixed_get_env; 10 + 11 + /// Resolve password from env var or a fallback function (e.g. stdin prompt). 12 + pub fn resolve_password( 13 + env_value: Option<String>, 14 + prompt_fn: impl FnOnce() -> Result<String>, 15 + ) -> Result<String> { 16 + let password = match env_value { 17 + Some(p) => p, 18 + None => prompt_fn()?, 19 + }; 20 + anyhow::ensure!(!password.is_empty(), "password cannot be empty"); 21 + Ok(password) 22 + } 23 + 24 + fn prompt_password(identifier: &str, pds: &str) -> Result<String> { 25 + print!("Password for user {} on {}: ", identifier, pds); 26 + std::io::Write::flush(&mut std::io::stdout())?; 27 + let mut input = String::new(); 28 + std::io::stdin().read_line(&mut input)?; 29 + Ok(input.trim().to_string()) 30 + } 31 + 32 + #[derive(Args)] 33 + /// Authenticate with your PDS 34 + pub struct LoginCommand { 35 + /// PDS URL (e.g. https://pds.example.com) 36 + #[arg(long)] 37 + pds: String, 38 + 39 + /// Handle or DID 40 + #[arg(long)] 41 + identifier: String, 42 + } 43 + 44 + impl Execute for LoginCommand { 45 + async fn execute(self) -> Result<()> { 46 + debug!("Starting login command"); 47 + 48 + let password = resolve_password(prefixed_get_env("PASSWORD"), || { 49 + prompt_password(&self.identifier, &self.pds) 50 + })?; 51 + 52 + let transport = ReqwestTransport::new(); 53 + let mut client = XrpcClient::new(transport, self.pds); 54 + 55 + let session = client.login(self.identifier.trim(), &password).await?; 56 + 57 + config::save_session(session)?; 58 + 59 + println!("Logged in as {}", session.handle); 60 + 61 + Ok(()) 62 + } 63 + } 64 + 65 + #[cfg(test)] 66 + mod tests { 67 + use super::*; 68 + use opake_core::client::{HttpRequest, HttpResponse, Transport}; 69 + use opake_core::error::Error; 70 + 71 + /// Mock transport that returns a preconfigured response. 72 + struct MockTransport { 73 + response: HttpResponse, 74 + } 75 + 76 + impl MockTransport { 77 + fn success_session() -> Self { 78 + let body = serde_json::json!({ 79 + "did": "did:plc:test123", 80 + "handle": "alice.test", 81 + "accessJwt": "eyJ.access.token", 82 + "refreshJwt": "eyJ.refresh.token", 83 + }); 84 + Self { 85 + response: HttpResponse { 86 + status: 200, 87 + body: serde_json::to_vec(&body).unwrap(), 88 + }, 89 + } 90 + } 91 + 92 + fn auth_failure() -> Self { 93 + let body = serde_json::json!({ 94 + "error": "AuthenticationRequired", 95 + "message": "Invalid identifier or password", 96 + }); 97 + Self { 98 + response: HttpResponse { 99 + status: 401, 100 + body: serde_json::to_vec(&body).unwrap(), 101 + }, 102 + } 103 + } 104 + } 105 + 106 + impl Transport for MockTransport { 107 + async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Error> { 108 + Ok(self.response.clone()) 109 + } 110 + } 111 + 112 + #[tokio::test] 113 + async fn test_login_successful_session() { 114 + let transport = MockTransport::success_session(); 115 + let mut client = XrpcClient::new(transport, "https://pds.test".into()); 116 + 117 + let session = client.login("alice.test", "s3cret").await.unwrap(); 118 + 119 + assert_eq!(session.did, "did:plc:test123"); 120 + assert_eq!(session.handle, "alice.test"); 121 + assert!(!session.access_jwt.is_empty()); 122 + assert!(!session.refresh_jwt.is_empty()); 123 + } 124 + 125 + #[tokio::test] 126 + async fn test_login_bad_credentials_returns_error() { 127 + let transport = MockTransport::auth_failure(); 128 + let mut client = XrpcClient::new(transport, "https://pds.test".into()); 129 + 130 + let result = client.login("alice.test", "wrong").await; 131 + 132 + assert!(result.is_err()); 133 + let err = result.unwrap_err().to_string(); 134 + assert!(err.contains("401"), "expected 401 in error: {err}"); 135 + } 136 + 137 + #[test] 138 + fn test_resolve_password_from_env() { 139 + let result = resolve_password(Some("s3cret".into()), || { 140 + panic!("prompt should not be called when env is set") 141 + }); 142 + assert_eq!(result.unwrap(), "s3cret"); 143 + } 144 + 145 + #[test] 146 + fn test_resolve_password_falls_back_to_prompt() { 147 + let result = resolve_password(None, || Ok("from_prompt".into())); 148 + assert_eq!(result.unwrap(), "from_prompt"); 149 + } 150 + 151 + #[test] 152 + fn test_resolve_password_propagates_prompt_error() { 153 + let result = resolve_password(None, || anyhow::bail!("stdin broke")); 154 + assert!(result.is_err()); 155 + assert_eq!(result.unwrap_err().to_string(), "stdin broke"); 156 + } 157 + 158 + #[test] 159 + fn test_resolve_password_env_preserves_whitespace() { 160 + // env vars aren't trimmed — spaces in passwords are valid 161 + let result = resolve_password(Some(" spaced ".into()), || { 162 + panic!("prompt should not be called") 163 + }); 164 + assert_eq!(result.unwrap(), " spaced "); 165 + } 166 + 167 + #[test] 168 + fn test_resolve_password_rejects_empty_from_env() { 169 + let result = resolve_password(Some("".into()), || panic!("prompt should not be called")); 170 + assert!(result.is_err()); 171 + } 172 + 173 + #[test] 174 + fn test_resolve_password_rejects_empty_from_prompt() { 175 + // user just hits enter — trims to empty 176 + let result = resolve_password(None, || Ok("".into())); 177 + assert!(result.is_err()); 178 + } 179 + }
+19
crates/opake-cli/src/commands/ls.rs
··· 1 + use anyhow::Result; 2 + use clap::Args; 3 + 4 + use crate::commands::Execute; 5 + 6 + #[derive(Args)] 7 + /// List your documents 8 + pub struct LsCommand { 9 + /// Filter by tag 10 + #[arg(long)] 11 + tag: Option<String>, 12 + } 13 + 14 + impl Execute for LsCommand { 15 + async fn execute(self) -> Result<()> { 16 + let _client = crate::config::load_client()?; 17 + anyhow::bail!("ls not yet implemented (tracking: chainlink #7)") 18 + } 19 + }
+11
crates/opake-cli/src/commands/mod.rs
··· 1 + pub mod download; 2 + pub mod login; 3 + pub mod ls; 4 + pub mod rm; 5 + pub mod upload; 6 + 7 + use anyhow::Result; 8 + 9 + pub trait Execute { 10 + fn execute(self) -> impl std::future::Future<Output = Result<()>>; 11 + }
+18
crates/opake-cli/src/commands/rm.rs
··· 1 + use anyhow::Result; 2 + use clap::Args; 3 + 4 + use crate::commands::Execute; 5 + 6 + #[derive(Args)] 7 + /// Delete a document 8 + pub struct RmCommand { 9 + /// AT URI of the document record 10 + uri: String, 11 + } 12 + 13 + impl Execute for RmCommand { 14 + async fn execute(self) -> Result<()> { 15 + let _client = crate::config::load_client()?; 16 + anyhow::bail!("rm not yet implemented (tracking: chainlink #8)") 17 + } 18 + }
+28
crates/opake-cli/src/commands/upload.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use anyhow::Result; 4 + use clap::Args; 5 + 6 + use crate::commands::Execute; 7 + 8 + #[derive(Args)] 9 + /// Upload and encrypt a file 10 + pub struct UploadCommand { 11 + /// Path to the file to encrypt and upload 12 + path: PathBuf, 13 + 14 + /// Encrypt under a keyring instead of direct keys 15 + #[arg(long)] 16 + keyring: Option<String>, 17 + 18 + /// Comma-separated tags for categorization 19 + #[arg(long, value_delimiter = ',')] 20 + tags: Vec<String>, 21 + } 22 + 23 + impl Execute for UploadCommand { 24 + async fn execute(self) -> Result<()> { 25 + let _client = crate::config::load_client()?; 26 + anyhow::bail!("upload not yet implemented (tracking: chainlink #5)") 27 + } 28 + }
+28
crates/opake-cli/src/config.rs
··· 36 36 pub fn save_session(session: &Session) -> anyhow::Result<()> { 37 37 todo!("write session to {}", session_path().display()) 38 38 } 39 + 40 + #[cfg(test)] 41 + mod tests { 42 + use super::*; 43 + 44 + fn fake_session() -> Session { 45 + Session { 46 + did: "did:plc:test123".into(), 47 + handle: "alice.test".into(), 48 + access_jwt: "eyJ.access.token".into(), 49 + refresh_jwt: "eyJ.refresh.token".into(), 50 + } 51 + } 52 + 53 + #[test] 54 + #[should_panic(expected = "not yet implemented")] 55 + fn test_save_session_writes_to_disk() { 56 + // will pass once #9 replaces the todo!() with real persistence 57 + save_session(&fake_session()).unwrap(); 58 + } 59 + 60 + #[test] 61 + #[should_panic(expected = "not yet implemented")] 62 + fn test_load_client_restores_session() { 63 + // will pass once #9 implements session loading 64 + load_client().unwrap(); 65 + } 66 + }
+16 -82
crates/opake-cli/src/main.rs
··· 1 + mod commands; 1 2 mod config; 2 3 mod transport; 4 + pub mod utils; 3 5 6 + use anyhow::Context; 4 7 use clap::{Parser, Subcommand}; 5 - use opake_core::client::XrpcClient; 6 - use std::path::PathBuf; 7 - use transport::ReqwestTransport; 8 + use commands::Execute; 9 + use log::info; 8 10 9 11 #[derive(Parser)] 10 12 #[command(name = "opake", about = "Encrypted personal cloud on AT Protocol")] ··· 15 17 16 18 #[derive(Subcommand)] 17 19 enum Command { 18 - /// Authenticate with your PDS 19 - Login { 20 - /// PDS URL (e.g. https://pds.example.com) 21 - #[arg(long)] 22 - pds: String, 23 - 24 - /// Handle or DID 25 - #[arg(long)] 26 - identifier: String, 27 - 28 - /// App password 29 - #[arg(long)] 30 - password: String, 31 - }, 32 - 33 - /// Upload and encrypt a file 34 - Upload { 35 - /// Path to the file to encrypt and upload 36 - path: PathBuf, 37 - 38 - /// Encrypt under a keyring instead of direct keys 39 - #[arg(long)] 40 - keyring: Option<String>, 41 - 42 - /// Comma-separated tags for categorization 43 - #[arg(long, value_delimiter = ',')] 44 - tags: Vec<String>, 45 - }, 46 - 47 - /// Download and decrypt a file 48 - Download { 49 - /// AT URI of the document record 50 - uri: String, 51 - 52 - /// Output path (defaults to the original filename) 53 - #[arg(short, long)] 54 - output: Option<PathBuf>, 55 - }, 56 - 57 - /// List your documents 58 - Ls { 59 - /// Filter by tag 60 - #[arg(long)] 61 - tag: Option<String>, 62 - }, 63 - 64 - /// Delete a document 65 - Rm { 66 - /// AT URI of the document record 67 - uri: String, 68 - }, 20 + Login(commands::login::LoginCommand), 21 + Upload(commands::upload::UploadCommand), 22 + Download(commands::download::DownloadCommand), 23 + Ls(commands::ls::LsCommand), 24 + Rm(commands::rm::RmCommand), 69 25 } 70 26 71 27 #[tokio::main] 72 28 async fn main() -> anyhow::Result<()> { 73 29 env_logger::init(); 30 + info!("Starting Opake CLI. Hello!"); 74 31 let cli = Cli::parse(); 75 32 76 33 match cli.command { 77 - Command::Login { pds, identifier, password } => { 78 - let transport = ReqwestTransport::new(); 79 - let mut client = XrpcClient::new(transport, pds); 80 - let session = client.login(&identifier, &password).await?; 81 - config::save_session(session)?; 82 - println!("logged in as {}", session.handle); 83 - Ok(()) 84 - } 85 - Command::Upload { path, keyring, tags } => { 86 - let _client = config::load_client()?; 87 - let _ = (path, keyring, tags); 88 - todo!("read file, generate content key, encrypt, upload blob, create document record") 89 - } 90 - Command::Download { uri, output } => { 91 - let _client = config::load_client()?; 92 - let _ = (uri, output); 93 - todo!("fetch document record, resolve encryption, fetch blob, decrypt, write to disk") 94 - } 95 - Command::Ls { tag } => { 96 - let _client = config::load_client()?; 97 - let _ = tag; 98 - todo!("list document records via com.atproto.repo.listRecords") 99 - } 100 - Command::Rm { uri } => { 101 - let _client = config::load_client()?; 102 - let _ = uri; 103 - todo!("delete document record via com.atproto.repo.deleteRecord") 104 - } 34 + Command::Login(cmd) => cmd.execute().await.context("Failed to log into your PDS"), 35 + Command::Upload(cmd) => cmd.execute().await, 36 + Command::Download(cmd) => cmd.execute().await, 37 + Command::Ls(cmd) => cmd.execute().await, 38 + Command::Rm(cmd) => cmd.execute().await, 105 39 } 106 40 }
+31
crates/opake-cli/src/utils.rs
··· 1 + pub const ENV_PREFIX: &str = "OPAKE_CLI_"; 2 + 3 + pub fn format_env_key(key: &str) -> String { 4 + format!("{ENV_PREFIX}{key}") 5 + } 6 + 7 + pub fn prefixed_get_env(key: &str) -> Option<String> { 8 + std::env::var(format_env_key(key)).ok() 9 + } 10 + 11 + #[cfg(test)] 12 + mod tests { 13 + use super::*; 14 + 15 + #[test] 16 + fn test_format_env_key() { 17 + assert!(format_env_key("TEST") == "OPAKE_CLI_TEST"); 18 + } 19 + 20 + #[test] 21 + fn test_getenv_missing() { 22 + assert!(prefixed_get_env("NONEXISTENT_TEST_KEY_12345").is_none()); 23 + } 24 + 25 + #[test] 26 + fn test_getenv_present() { 27 + unsafe { std::env::set_var("OPAKE_CLI_TEST_GETENV", "hello") }; 28 + assert_eq!(prefixed_get_env("TEST_GETENV").unwrap(), "hello"); 29 + unsafe { std::env::remove_var("OPAKE_CLI_TEST_GETENV") }; 30 + } 31 + }