diff --git a/Cargo.lock b/Cargo.lock
index fac9a31..450f54b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -41,6 +41,56 @@ dependencies = [
"libc",
]
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -465,6 +515,52 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
[[package]]
name = "combine"
version = "4.6.7"
@@ -494,6 +590,16 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -517,7 +623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.1",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@@ -530,7 +636,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.1",
- "core-foundation",
+ "core-foundation 0.10.1",
"libc",
]
@@ -661,6 +767,16 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "dbus-secret-service"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
+dependencies = [
+ "dbus",
+ "zeroize",
+]
+
[[package]]
name = "defmt"
version = "1.1.1"
@@ -1794,6 +1910,12 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1970,7 +2092,12 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
+ "byteorder",
+ "dbus-secret-service",
"log",
+ "security-framework 2.11.1",
+ "security-framework 3.7.0",
+ "windows-sys 0.60.2",
"zeroize",
]
@@ -2145,7 +2272,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
- "security-framework",
+ "security-framework 3.7.0",
"security-framework-sys",
"tempfile",
]
@@ -2239,6 +2366,16 @@ dependencies = [
[[package]]
name = "nut"
version = "0.1.0"
+dependencies = [
+ "clap",
+ "nextcloud_client",
+ "open",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
[[package]]
name = "objc2"
@@ -2441,6 +2578,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
[[package]]
name = "open"
version = "5.4.1"
@@ -3100,6 +3243,19 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -3107,7 +3263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.1",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3552,7 +3708,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.1",
"block2",
- "core-foundation",
+ "core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -3978,7 +4134,9 @@ dependencies = [
"bytes",
"libc",
"mio",
+ "parking_lot",
"pin-project-lite",
+ "signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
@@ -4349,6 +4507,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
[[package]]
name = "uuid"
version = "1.25.0"
@@ -4819,6 +4983,15 @@ dependencies = [
"windows-targets 0.52.6",
]
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
[[package]]
name = "windows-sys"
version = "0.61.2"
@@ -4852,13 +5025,30 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm",
+ "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link 0.2.1",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
[[package]]
name = "windows-threading"
version = "0.1.0"
@@ -4889,6 +5079,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
@@ -4901,6 +5097,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
@@ -4913,12 +5115,24 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
@@ -4931,6 +5145,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
@@ -4943,6 +5163,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
@@ -4955,6 +5181,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
@@ -4967,6 +5199,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
[[package]]
name = "winnow"
version = "0.5.40"
@@ -5197,6 +5435,20 @@ name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
[[package]]
name = "zerotrie"
diff --git a/Tasks.md b/Tasks.md
index 529abbd..4a0c989 100644
--- a/Tasks.md
+++ b/Tasks.md
@@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for
| ID | Title | Status | Type |
|---|---|---|---|
-| [NUT-008](#nut-008) | Implement CLI Frontend | Triage | Feature |
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature |
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature |
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature |
@@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for
| [NUT-005](#nut-005) | Implement Direct Download URL Builder | Fixed | Feature |
| [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature |
| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature |
+| [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature |
---
@@ -232,21 +232,21 @@ Support multiple Nextcloud accounts in the backend credential system.
- NUT-006
-
+
### Implement CLI Frontend
**ID:** NUT-008
-**Status:** Triage
+**Status:** Fixed
**Type:** Feature
**Description:**
Implement the CLI tool using the shared backend library, including interactive browser login via Login Flow v2 and file upload commands.
**Requirements:**
-- [ ] Add `login` command (browser-based Login Flow v2)
-- [ ] Add `upload` command
-- [ ] Add `--account` flag
-- [ ] Add `--stdin` support
-- [ ] Add error reporting
+- [x] Add `login` command (browser-based Login Flow v2)
+- [x] Add `upload` command
+- [x] Add `--account` flag
+- [x] Add `--stdin` support
+- [x] Add error reporting
**Dependencies:**
- NUT-003
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index f29c75d..81d0afa 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -4,3 +4,11 @@ version = "0.1.0"
edition = "2024"
[dependencies]
+nextcloud_client = { path = "../nextcloud_client" }
+clap = { version = "4.5", features = ["derive", "cargo"] }
+tokio = { version = "1", features = ["full"] }
+reqwest = { version = "0.12", default-features = false, features = ["json", "default-tls"] }
+open = "5.3"
+url = "2.5"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
diff --git a/cli/src/main.rs b/cli/src/main.rs
index e7a11a9..bba9309 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1,3 +1,349 @@
-fn main() {
- println!("Hello, world!");
+use clap::{Args, Parser, Subcommand};
+use std::io::{self, Read};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::Duration;
+
+use nextcloud_client::{
+ initiate_login_flow, poll_login_flow, ClientConfig, CredentialStore, NextcloudClient,
+ ProgressCallback, ProgressEvent, Result, UploadOptions,
+};
+
+#[derive(Parser, Debug)]
+#[command(
+ name = "nut",
+ author = "Tom Hicks ",
+ version,
+ about = "Nextcloud Upload Tool — Fast, seamless file uploads & share links",
+ long_about = "Upload files to Nextcloud directly from your terminal, automatically generate public share links, and manage multiple accounts with secure OS keychain storage."
+)]
+struct Cli {
+ #[command(subcommand)]
+ command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+ /// Log in to a Nextcloud server using browser authorization (Login Flow v2)
+ Login(LoginArgs),
+
+ /// Upload a file or stream to Nextcloud
+ Upload(UploadArgs),
+
+ /// Manage configured Nextcloud accounts
+ #[command(subcommand)]
+ Account(AccountCommands),
+
+ /// Quick alias to list all configured accounts
+ Accounts,
+}
+
+#[derive(Args, Debug)]
+struct LoginArgs {
+ /// Base URL of the Nextcloud instance (e.g. https://cloud.example.com)
+ server_url: String,
+
+ /// Optional friendly label for this account (e.g. "Work", "Home")
+ #[arg(short, long)]
+ label: Option,
+
+ /// Set this account as the default active account
+ #[arg(short, long, default_value_t = true)]
+ default: bool,
+}
+
+#[derive(Args, Debug)]
+struct UploadArgs {
+ /// Path to local file(s) to upload
+ #[arg(value_name = "FILE")]
+ files: Vec,
+
+ /// Specific account identifier or label to use (defaults to active account)
+ #[arg(short, long)]
+ account: Option,
+
+ /// Remote destination folder on Nextcloud (defaults to "Uploads")
+ #[arg(short = 'd', long, default_value = "Uploads")]
+ remote_dir: String,
+
+ /// Automatically generate a public share link after upload
+ #[arg(short, long)]
+ share: bool,
+
+ /// Optional password to protect the public share link
+ #[arg(short, long)]
+ password: Option,
+
+ /// Upload content from standard input (stdin)
+ #[arg(long)]
+ stdin: bool,
+
+ /// Remote filename to use when uploading via stdin
+ #[arg(long, default_value = "stdin_upload.txt")]
+ filename: String,
+}
+
+#[derive(Subcommand, Debug)]
+enum AccountCommands {
+ /// List all configured Nextcloud accounts
+ List,
+
+ /// Set the active default account
+ Default {
+ /// Account ID, label, or username
+ account: String,
+ },
+
+ /// Remove a configured account and its credentials
+ Delete {
+ /// Account ID, label, or username
+ account: String,
+ },
+}
+
+#[tokio::main]
+async fn main() {
+ let cli = Cli::parse();
+
+ let result = match cli.command {
+ Commands::Login(args) => handle_login(args).await,
+ Commands::Upload(args) => handle_upload(args).await,
+ Commands::Account(AccountCommands::List) | Commands::Accounts => handle_account_list(),
+ Commands::Account(AccountCommands::Default { account }) => {
+ handle_account_set_default(&account)
+ }
+ Commands::Account(AccountCommands::Delete { account }) => handle_account_delete(&account),
+ };
+
+ if let Err(err) = result {
+ eprintln!("\x1b[1;31mError:\x1b[0m {err}");
+ std::process::exit(1);
+ }
+}
+
+/// Handle interactive browser login flow.
+async fn handle_login(args: LoginArgs) -> Result<()> {
+ let server_url = ClientConfig::normalize_url(&args.server_url)?;
+ let http = reqwest::Client::new();
+
+ println!("\x1b[1;34m==>\x1b[0m Initiating Nextcloud authentication with {}", server_url);
+ let flow = initiate_login_flow(&http, &server_url).await?;
+
+ println!("\x1b[1;32m==>\x1b[0m Please authorize access in your browser:");
+ println!(" \x1b[1;36m{}\x1b[0m\n", flow.login);
+
+ // Attempt to open the default desktop browser
+ if open::that(&flow.login).is_err() {
+ println!(" (Could not automatically launch browser. Please copy and open the link above.)");
+ }
+
+ print!("\x1b[1;33m==>\x1b[0m Waiting for browser authorization...");
+ io::Write::flush(&mut io::stdout())?;
+
+ let poll_interval = Duration::from_secs(2);
+ let timeout = Duration::from_secs(300); // 5 minute timeout
+ let start_time = tokio::time::Instant::now();
+
+ loop {
+ if start_time.elapsed() >= timeout {
+ println!();
+ return Err(nextcloud_client::NextcloudError::Other(
+ "Timed out waiting for authorization in browser".into(),
+ ));
+ }
+
+ tokio::time::sleep(poll_interval).await;
+
+ if let Some(creds) = poll_login_flow(&http, &flow.poll.endpoint, &flow.poll.token).await? {
+ println!("\n\x1b[1;32m==>\x1b[0m Authorization granted for user '\x1b[1m{}\x1b[0m'!", creds.login_name);
+
+ // Save credentials to keychain & accounts list
+ let mut account = CredentialStore::save_account(
+ &creds.server,
+ &creds.login_name,
+ &creds.app_password,
+ args.default,
+ )?;
+
+ if let Some(lbl) = args.label {
+ account.label = Some(lbl);
+ let mut accounts = CredentialStore::list_accounts()?;
+ if let Some(a) = accounts.iter_mut().find(|a| a.id == account.id) {
+ a.label = account.label.clone();
+ }
+ CredentialStore::save_accounts(&accounts)?;
+ }
+
+ println!("\x1b[1;32m==>\x1b[0m Account '\x1b[1m{}\x1b[0m' saved securely in system keychain.", account.id);
+ if account.is_default {
+ println!(" Set as default active account.");
+ }
+ return Ok(());
+ }
+ }
+}
+
+/// Handle file and stdin uploads.
+async fn handle_upload(args: UploadArgs) -> Result<()> {
+ let client = match args.account.as_deref() {
+ Some(acc) => CredentialStore::create_client_for_account(acc)?,
+ None => CredentialStore::create_client_for_default()?,
+ };
+
+ // Stdin Upload
+ if args.stdin {
+ println!("\x1b[1;34m==>\x1b[0m Reading data from stdin...");
+ let mut buffer = Vec::new();
+ io::stdin().read_to_end(&mut buffer)?;
+
+ let len = buffer.len() as u64;
+ let remote_path = format!("{}/{}", args.remote_dir.trim_matches('/'), args.filename);
+ println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path);
+
+ let progress_cb = create_progress_callback(len);
+ let cursor = io::Cursor::new(buffer);
+ let bytes = client
+ .upload_reader(cursor, &remote_path, Some(len), Some(progress_cb))
+ .await?;
+
+ println!("\n\x1b[1;32m✓\x1b[0m Upload complete! ({} bytes)", bytes);
+
+ if args.share {
+ create_and_print_share(&client, &remote_path, args.password.as_deref()).await?;
+ }
+ return Ok(());
+ }
+
+ if args.files.is_empty() {
+ return Err(nextcloud_client::NextcloudError::Other(
+ "No files specified for upload. Usage: nut upload ... or nut upload --stdin".into(),
+ ));
+ }
+
+ for file_path in &args.files {
+ upload_single_file(&client, file_path, &args.remote_dir, args.share, args.password.as_deref()).await?;
+ }
+
+ Ok(())
+}
+
+async fn upload_single_file(
+ client: &NextcloudClient,
+ local_path: &Path,
+ remote_dir: &str,
+ create_share: bool,
+ password: Option<&str>,
+) -> Result<()> {
+ if !local_path.exists() {
+ return Err(nextcloud_client::NextcloudError::NotFound {
+ path: local_path.display().to_string(),
+ });
+ }
+
+ let file_name = local_path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("file");
+
+ let clean_dir = remote_dir.trim_matches('/');
+ let remote_path = if clean_dir.is_empty() {
+ file_name.to_string()
+ } else {
+ format!("{clean_dir}/{file_name}")
+ };
+
+ let metadata = tokio::fs::metadata(local_path).await?;
+ let file_size = metadata.len();
+
+ println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path);
+
+ let progress_cb = create_progress_callback(file_size);
+ let options = UploadOptions {
+ remote_path: remote_path.clone(),
+ create_share,
+ share_password: password.map(|s| s.to_string()),
+ overwrite: true,
+ };
+
+ let result = client
+ .upload_and_share(local_path, &options, Some(progress_cb))
+ .await?;
+
+ println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", file_name, result.bytes_uploaded);
+
+ if let Some(share_url) = result.share_url {
+ println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url);
+ }
+ if let Some(direct_url) = result.direct_download_url {
+ println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
+ }
+
+ Ok(())
+}
+
+async fn create_and_print_share(
+ client: &NextcloudClient,
+ remote_path: &str,
+ password: Option<&str>,
+) -> Result<()> {
+ println!("\x1b[1;34m==>\x1b[0m Generating public share link...");
+ let share = client.create_public_share(remote_path, password).await?;
+ let direct_url = client.direct_download_url(&share.token)?;
+
+ println!(" \x1b[1;32mShare Link:\x1b[0m {}", share.url);
+ println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
+ Ok(())
+}
+
+fn create_progress_callback(total_bytes: u64) -> ProgressCallback {
+ Arc::new(move |event| match event {
+ ProgressEvent::Progress { bytes_transferred, total_bytes: total } => {
+ let tot = total.unwrap_or(total_bytes);
+ if tot > 0 {
+ let percent = (bytes_transferred as f64 / tot as f64) * 100.0;
+ print!("\r \x1b[1;33mProgress:\x1b[0m {:>3.0}% ({}/{} bytes)", percent, bytes_transferred, tot);
+ } else {
+ print!("\r \x1b[1;33mProgress:\x1b[0m {} bytes transferred", bytes_transferred);
+ }
+ let _ = io::Write::flush(&mut io::stdout());
+ }
+ ProgressEvent::Completed { total_bytes } => {
+ print!("\r \x1b[1;33mProgress:\x1b[0m 100% ({} bytes) ", total_bytes);
+ let _ = io::Write::flush(&mut io::stdout());
+ }
+ _ => {}
+ })
+}
+
+/// List stored accounts.
+fn handle_account_list() -> Result<()> {
+ let accounts = CredentialStore::list_accounts()?;
+ if accounts.is_empty() {
+ println!("No Nextcloud accounts configured yet.");
+ println!("Run \x1b[1;36mnut login \x1b[0m to connect an account.");
+ return Ok(());
+ }
+
+ println!("\x1b[1mConfigured Nextcloud Accounts:\x1b[0m\n");
+ for acc in accounts {
+ let default_mark = if acc.is_default { "\x1b[1;32m* (active default)\x1b[0m" } else { "" };
+ let label_str = acc.label.as_deref().unwrap_or("—");
+ println!(" • \x1b[1;36m{}\x1b[0m {}", acc.id, default_mark);
+ println!(" Username: {}", acc.username);
+ println!(" Server: {}", acc.server_url);
+ println!(" Label: {}\n", label_str);
+ }
+ Ok(())
+}
+
+fn handle_account_set_default(account: &str) -> Result<()> {
+ CredentialStore::set_default_account(account)?;
+ println!("\x1b[1;32m✓\x1b[0m Active default account set to '\x1b[1m{}\x1b[0m'.", account);
+ Ok(())
+}
+
+fn handle_account_delete(account: &str) -> Result<()> {
+ CredentialStore::delete_account(account)?;
+ println!("\x1b[1;32m✓\x1b[0m Account '\x1b[1m{}\x1b[0m' deleted.", account);
+ Ok(())
}
diff --git a/nextcloud_client/Cargo.toml b/nextcloud_client/Cargo.toml
index e27bcfc..1825202 100644
--- a/nextcloud_client/Cargo.toml
+++ b/nextcloud_client/Cargo.toml
@@ -14,6 +14,6 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0"
url = { version = "2.5", features = ["serde"] }
-keyring = "3"
+keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service"] }
dirs = "6.0"
open = "5.3"
diff --git a/nextcloud_client/src/config.rs b/nextcloud_client/src/config.rs
index c3efb4a..d7d7d56 100644
--- a/nextcloud_client/src/config.rs
+++ b/nextcloud_client/src/config.rs
@@ -69,11 +69,8 @@ mod humantime_serde {
}
impl ClientConfig {
- /// Create a new `ClientConfig` by parsing a URL string and optional credentials.
- pub fn new(
- server_url_str: &str,
- credentials: Option,
- ) -> Result {
+ /// Normalize a server URL string: validates http/https scheme and ensures a trailing slash.
+ pub fn normalize_url(server_url_str: &str) -> Result {
let mut server_url = Url::parse(server_url_str)?;
// Ensure scheme is http or https
@@ -90,6 +87,16 @@ impl ClientConfig {
server_url.set_path(&new_path);
}
+ Ok(server_url)
+ }
+
+ /// Create a new `ClientConfig` by parsing a URL string and optional credentials.
+ pub fn new(
+ server_url_str: &str,
+ credentials: Option,
+ ) -> Result {
+ let server_url = Self::normalize_url(server_url_str)?;
+
Ok(Self {
server_url,
credentials,
@@ -127,6 +134,12 @@ mod tests {
assert_eq!(config.credentials.as_ref().unwrap().username, "alice");
}
+ #[test]
+ fn test_subpath_normalization() {
+ let url = ClientConfig::normalize_url("https://disobedient.cloud/nextcloud").unwrap();
+ assert_eq!(url.as_str(), "https://disobedient.cloud/nextcloud/");
+ }
+
#[test]
fn test_invalid_scheme() {
let result = ClientConfig::new("ftp://cloud.example.com", None);
diff --git a/nextcloud_client/src/credentials.rs b/nextcloud_client/src/credentials.rs
index 36eaf0a..89174f1 100644
--- a/nextcloud_client/src/credentials.rs
+++ b/nextcloud_client/src/credentials.rs
@@ -31,7 +31,7 @@ pub struct StoredAccount {
#[serde(default)]
pub is_default: bool,
- /// Fallback password storage (only populated if system keyring is unavailable).
+ /// Fallback password storage (populated if system keyring is unavailable).
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_password: Option,
}
@@ -137,7 +137,7 @@ impl CredentialStore {
let keyring_key = account.keyring_key();
- // 1. Attempt retrieval from OS Keyring
+ // 1. Attempt retrieval from OS Keyring (using full id username@host)
if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, keyring_key) {
if let Ok(password) = entry.get_password() {
let username = account.username.clone();
@@ -145,7 +145,7 @@ impl CredentialStore {
}
}
- // Also try fallback by username alone if migrated
+ // Also try retrieval by username alone (if previously stored with just username)
if let Ok(entry) = Entry::new(KEYRING_SERVICE_NAME, &account.username) {
if let Ok(password) = entry.get_password() {
let username = account.username.clone();
@@ -158,7 +158,7 @@ impl CredentialStore {
let username = account.username.clone();
return Ok(Some((
account.clone(),
- AccountCredentials::new(username, pass),
+ AccountCredentials::new(username, pass.clone()),
)));
}
@@ -168,14 +168,26 @@ impl CredentialStore {
/// Retrieve the default/active account credentials.
pub fn get_default_credentials() -> Result