···11+# Changelog
22+33+All notable changes to this project will be documented in this file.
44+55+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
66+77+## [Unreleased]
88+99+### Added
1010+- Update login command to read password from stdin (#23)
1111+1212+### Fixed
1313+1414+### Changed
1515+- Add PDS authentication via com.atproto.server.createSession (#2)
+24
crates/opake-cli/src/commands/download.rs
···11+use std::path::PathBuf;
22+33+use anyhow::Result;
44+use clap::Args;
55+66+use crate::commands::Execute;
77+88+#[derive(Args)]
99+/// Download and decrypt a file
1010+pub struct DownloadCommand {
1111+ /// AT URI of the document record
1212+ uri: String,
1313+1414+ /// Output path (defaults to the original filename)
1515+ #[arg(short, long)]
1616+ output: Option<PathBuf>,
1717+}
1818+1919+impl Execute for DownloadCommand {
2020+ async fn execute(self) -> Result<()> {
2121+ let _client = crate::config::load_client()?;
2222+ anyhow::bail!("download not yet implemented (tracking: chainlink #6)")
2323+ }
2424+}
+179
crates/opake-cli/src/commands/login.rs
···11+use anyhow::Result;
22+use clap::Args;
33+use log::debug;
44+use opake_core::client::XrpcClient;
55+66+use crate::commands::Execute;
77+use crate::config;
88+use crate::transport::ReqwestTransport;
99+use crate::utils::prefixed_get_env;
1010+1111+/// Resolve password from env var or a fallback function (e.g. stdin prompt).
1212+pub fn resolve_password(
1313+ env_value: Option<String>,
1414+ prompt_fn: impl FnOnce() -> Result<String>,
1515+) -> Result<String> {
1616+ let password = match env_value {
1717+ Some(p) => p,
1818+ None => prompt_fn()?,
1919+ };
2020+ anyhow::ensure!(!password.is_empty(), "password cannot be empty");
2121+ Ok(password)
2222+}
2323+2424+fn prompt_password(identifier: &str, pds: &str) -> Result<String> {
2525+ print!("Password for user {} on {}: ", identifier, pds);
2626+ std::io::Write::flush(&mut std::io::stdout())?;
2727+ let mut input = String::new();
2828+ std::io::stdin().read_line(&mut input)?;
2929+ Ok(input.trim().to_string())
3030+}
3131+3232+#[derive(Args)]
3333+/// Authenticate with your PDS
3434+pub struct LoginCommand {
3535+ /// PDS URL (e.g. https://pds.example.com)
3636+ #[arg(long)]
3737+ pds: String,
3838+3939+ /// Handle or DID
4040+ #[arg(long)]
4141+ identifier: String,
4242+}
4343+4444+impl Execute for LoginCommand {
4545+ async fn execute(self) -> Result<()> {
4646+ debug!("Starting login command");
4747+4848+ let password = resolve_password(prefixed_get_env("PASSWORD"), || {
4949+ prompt_password(&self.identifier, &self.pds)
5050+ })?;
5151+5252+ let transport = ReqwestTransport::new();
5353+ let mut client = XrpcClient::new(transport, self.pds);
5454+5555+ let session = client.login(self.identifier.trim(), &password).await?;
5656+5757+ config::save_session(session)?;
5858+5959+ println!("Logged in as {}", session.handle);
6060+6161+ Ok(())
6262+ }
6363+}
6464+6565+#[cfg(test)]
6666+mod tests {
6767+ use super::*;
6868+ use opake_core::client::{HttpRequest, HttpResponse, Transport};
6969+ use opake_core::error::Error;
7070+7171+ /// Mock transport that returns a preconfigured response.
7272+ struct MockTransport {
7373+ response: HttpResponse,
7474+ }
7575+7676+ impl MockTransport {
7777+ fn success_session() -> Self {
7878+ let body = serde_json::json!({
7979+ "did": "did:plc:test123",
8080+ "handle": "alice.test",
8181+ "accessJwt": "eyJ.access.token",
8282+ "refreshJwt": "eyJ.refresh.token",
8383+ });
8484+ Self {
8585+ response: HttpResponse {
8686+ status: 200,
8787+ body: serde_json::to_vec(&body).unwrap(),
8888+ },
8989+ }
9090+ }
9191+9292+ fn auth_failure() -> Self {
9393+ let body = serde_json::json!({
9494+ "error": "AuthenticationRequired",
9595+ "message": "Invalid identifier or password",
9696+ });
9797+ Self {
9898+ response: HttpResponse {
9999+ status: 401,
100100+ body: serde_json::to_vec(&body).unwrap(),
101101+ },
102102+ }
103103+ }
104104+ }
105105+106106+ impl Transport for MockTransport {
107107+ async fn send(&self, _request: HttpRequest) -> Result<HttpResponse, Error> {
108108+ Ok(self.response.clone())
109109+ }
110110+ }
111111+112112+ #[tokio::test]
113113+ async fn test_login_successful_session() {
114114+ let transport = MockTransport::success_session();
115115+ let mut client = XrpcClient::new(transport, "https://pds.test".into());
116116+117117+ let session = client.login("alice.test", "s3cret").await.unwrap();
118118+119119+ assert_eq!(session.did, "did:plc:test123");
120120+ assert_eq!(session.handle, "alice.test");
121121+ assert!(!session.access_jwt.is_empty());
122122+ assert!(!session.refresh_jwt.is_empty());
123123+ }
124124+125125+ #[tokio::test]
126126+ async fn test_login_bad_credentials_returns_error() {
127127+ let transport = MockTransport::auth_failure();
128128+ let mut client = XrpcClient::new(transport, "https://pds.test".into());
129129+130130+ let result = client.login("alice.test", "wrong").await;
131131+132132+ assert!(result.is_err());
133133+ let err = result.unwrap_err().to_string();
134134+ assert!(err.contains("401"), "expected 401 in error: {err}");
135135+ }
136136+137137+ #[test]
138138+ fn test_resolve_password_from_env() {
139139+ let result = resolve_password(Some("s3cret".into()), || {
140140+ panic!("prompt should not be called when env is set")
141141+ });
142142+ assert_eq!(result.unwrap(), "s3cret");
143143+ }
144144+145145+ #[test]
146146+ fn test_resolve_password_falls_back_to_prompt() {
147147+ let result = resolve_password(None, || Ok("from_prompt".into()));
148148+ assert_eq!(result.unwrap(), "from_prompt");
149149+ }
150150+151151+ #[test]
152152+ fn test_resolve_password_propagates_prompt_error() {
153153+ let result = resolve_password(None, || anyhow::bail!("stdin broke"));
154154+ assert!(result.is_err());
155155+ assert_eq!(result.unwrap_err().to_string(), "stdin broke");
156156+ }
157157+158158+ #[test]
159159+ fn test_resolve_password_env_preserves_whitespace() {
160160+ // env vars aren't trimmed — spaces in passwords are valid
161161+ let result = resolve_password(Some(" spaced ".into()), || {
162162+ panic!("prompt should not be called")
163163+ });
164164+ assert_eq!(result.unwrap(), " spaced ");
165165+ }
166166+167167+ #[test]
168168+ fn test_resolve_password_rejects_empty_from_env() {
169169+ let result = resolve_password(Some("".into()), || panic!("prompt should not be called"));
170170+ assert!(result.is_err());
171171+ }
172172+173173+ #[test]
174174+ fn test_resolve_password_rejects_empty_from_prompt() {
175175+ // user just hits enter — trims to empty
176176+ let result = resolve_password(None, || Ok("".into()));
177177+ assert!(result.is_err());
178178+ }
179179+}
+19
crates/opake-cli/src/commands/ls.rs
···11+use anyhow::Result;
22+use clap::Args;
33+44+use crate::commands::Execute;
55+66+#[derive(Args)]
77+/// List your documents
88+pub struct LsCommand {
99+ /// Filter by tag
1010+ #[arg(long)]
1111+ tag: Option<String>,
1212+}
1313+1414+impl Execute for LsCommand {
1515+ async fn execute(self) -> Result<()> {
1616+ let _client = crate::config::load_client()?;
1717+ anyhow::bail!("ls not yet implemented (tracking: chainlink #7)")
1818+ }
1919+}
+11
crates/opake-cli/src/commands/mod.rs
···11+pub mod download;
22+pub mod login;
33+pub mod ls;
44+pub mod rm;
55+pub mod upload;
66+77+use anyhow::Result;
88+99+pub trait Execute {
1010+ fn execute(self) -> impl std::future::Future<Output = Result<()>>;
1111+}
+18
crates/opake-cli/src/commands/rm.rs
···11+use anyhow::Result;
22+use clap::Args;
33+44+use crate::commands::Execute;
55+66+#[derive(Args)]
77+/// Delete a document
88+pub struct RmCommand {
99+ /// AT URI of the document record
1010+ uri: String,
1111+}
1212+1313+impl Execute for RmCommand {
1414+ async fn execute(self) -> Result<()> {
1515+ let _client = crate::config::load_client()?;
1616+ anyhow::bail!("rm not yet implemented (tracking: chainlink #8)")
1717+ }
1818+}
+28
crates/opake-cli/src/commands/upload.rs
···11+use std::path::PathBuf;
22+33+use anyhow::Result;
44+use clap::Args;
55+66+use crate::commands::Execute;
77+88+#[derive(Args)]
99+/// Upload and encrypt a file
1010+pub struct UploadCommand {
1111+ /// Path to the file to encrypt and upload
1212+ path: PathBuf,
1313+1414+ /// Encrypt under a keyring instead of direct keys
1515+ #[arg(long)]
1616+ keyring: Option<String>,
1717+1818+ /// Comma-separated tags for categorization
1919+ #[arg(long, value_delimiter = ',')]
2020+ tags: Vec<String>,
2121+}
2222+2323+impl Execute for UploadCommand {
2424+ async fn execute(self) -> Result<()> {
2525+ let _client = crate::config::load_client()?;
2626+ anyhow::bail!("upload not yet implemented (tracking: chainlink #5)")
2727+ }
2828+}