1use crate::api::kasl_server::{AGENT_TOKEN_PROMPT, AGENT_TOKEN_SECRET, KaslServer, UploadError, normalize_url};
18use crate::libs::config::{Config, KaslServerConfig};
19use crate::libs::day_upload::build_day_upload;
20use crate::libs::messages::Message;
21use crate::libs::secret::Secret;
22use crate::{msg_error_anyhow, msg_info, msg_print, msg_success, msg_warning};
23use anyhow::{Context, Result};
24use chrono::{Duration, Local, NaiveDate};
25use clap::{Args, Subcommand};
26use dialoguer::{Input, Password, theme::ColorfulTheme};
27use reqwest::StatusCode;
28
29#[derive(Debug, Args)]
31pub struct ServerArgs {
32 #[command(subcommand)]
33 command: ServerCommand,
34}
35
36#[derive(Debug, Subcommand)]
38enum ServerCommand {
39 #[command(about = "Connect this machine to a kasl-server")]
41 Connect(ConnectArgs),
42
43 #[command(about = "Show the current connection to a kasl-server")]
45 Status,
46
47 #[command(about = "Send a day's work to the connected kasl-server")]
49 Push(PushArgs),
50
51 #[command(about = "Forget the connection and the stored agent token")]
53 Disconnect,
54}
55
56#[derive(Debug, Args)]
58pub struct PushArgs {
59 #[arg(long, short, help = "Send the last day instead of today")]
61 last: bool,
62
63 #[arg(long, value_name = "YYYY-MM-DD", conflicts_with = "last")]
65 date: Option<NaiveDate>,
66}
67
68#[derive(Debug, Args)]
70pub struct ConnectArgs {
71 #[arg(long, value_name = "URL")]
73 url: Option<String>,
74
75 #[arg(long, value_name = "PATH")]
77 ca_certificate: Option<String>,
78}
79
80pub async fn cmd(args: ServerArgs) -> Result<()> {
82 match args.command {
83 ServerCommand::Connect(args) => connect(args).await,
84 ServerCommand::Status => status().await,
85 ServerCommand::Push(args) => push(args).await,
86 ServerCommand::Disconnect => disconnect(),
87 }
88}
89
90async fn connect(args: ConnectArgs) -> Result<()> {
96 crate::libs::prompt::ensure_interactive("`kasl server connect` needs a terminal to ask for the agent token")?;
103
104 let mut config = Config::read().unwrap_or_default();
105
106 let url = match args.url {
107 Some(url) => normalize_url(&url),
108 None => {
109 let entered: String = Input::with_theme(&ColorfulTheme::default())
110 .with_prompt(Message::PromptKaslServerUrl.to_string())
111 .with_initial_text(config.kasl_server.as_ref().map(|s| s.url.clone()).unwrap_or_default())
112 .interact_text()?;
113 normalize_url(&entered)
114 }
115 };
116
117 if !url.starts_with("http://") && !url.starts_with("https://") {
120 return Err(msg_error_anyhow!(Message::KaslServerUrlNeedsScheme(url)));
121 }
122
123 let candidate = KaslServerConfig {
124 url: url.clone(),
125 ca_certificate: args
128 .ca_certificate
129 .or_else(|| config.kasl_server.as_ref().and_then(|s| s.ca_certificate.clone())),
130 };
131
132 let client = KaslServer::new(&candidate)?;
133
134 let health = client.health().await?;
137 msg_info!(Message::KaslServerReached {
138 url: url.clone(),
139 version: health.version.clone(),
140 });
141 if health.database != "ok" {
142 msg_warning!(Message::KaslServerDatabaseUnhealthy(health.database.clone()));
145 }
146
147 let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
148 let token: String = Password::with_theme(&ColorfulTheme::default())
149 .with_prompt(Message::PromptKaslServerToken.to_string())
150 .interact()?;
151 let token = token.trim().to_string();
152 if token.is_empty() {
153 return Err(msg_error_anyhow!(Message::KaslServerTokenEmpty));
154 }
155
156 let identity = client.identify(&token).await?;
158
159 secret
161 .store(&token)
162 .context("the token was accepted but could not be stored in the OS keyring")?;
163 config.kasl_server = Some(candidate);
164 config.save()?;
165
166 msg_success!(Message::KaslServerConnected {
167 user_name: identity.user_name,
168 agent_name: identity.agent_name,
169 });
170 Ok(())
171}
172
173async fn status() -> Result<()> {
179 let config = Config::read().unwrap_or_default();
180 let Some(server_config) = config.kasl_server else {
181 msg_print!(Message::KaslServerNotConnected);
182 return Ok(());
183 };
184
185 msg_info!(Message::KaslServerConfigured(server_config.url.clone()));
186
187 let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
188 let Some(token) = secret.try_get_cached() else {
189 msg_warning!(Message::KaslServerTokenMissing);
192 return Ok(());
193 };
194
195 let client = KaslServer::new(&server_config)?;
196
197 match client.health().await {
198 Ok(health) => msg_info!(Message::KaslServerReached {
199 url: server_config.url.clone(),
200 version: health.version,
201 }),
202 Err(error) => {
203 msg_warning!(Message::KaslServerUnreachable(error.to_string()));
204 return Ok(());
205 }
206 }
207
208 match client.identify(&token).await {
209 Ok(identity) => msg_success!(Message::KaslServerConnected {
210 user_name: identity.user_name,
211 agent_name: identity.agent_name,
212 }),
213 Err(error) => msg_warning!(Message::KaslServerTokenRejected(error.to_string())),
214 }
215
216 Ok(())
217}
218
219async fn push(args: PushArgs) -> Result<()> {
231 let date = match args.date {
232 Some(date) => date,
233 None if args.last => (Local::now() - Duration::days(1)).date_naive(),
234 None => Local::now().date_naive(),
235 };
236
237 let config = Config::read().unwrap_or_default();
238 let Some(server_config) = config.kasl_server else {
239 return Err(msg_error_anyhow!(Message::KaslServerNotConnected));
240 };
241
242 let Some(day) = build_day_upload(date)? else {
243 msg_print!(Message::KaslServerNoDayToPush(date.to_string()));
244 return Ok(());
245 };
246
247 let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
248 let Some(token) = secret.try_get_cached() else {
249 return Err(msg_error_anyhow!(Message::KaslServerTokenMissing));
250 };
251
252 let client = KaslServer::new(&server_config)?;
253
254 match client.upload_day(&token, &day).await {
255 Ok(accepted) => {
256 msg_success!(Message::KaslServerDayPushed {
257 date: accepted.date.to_string(),
258 pauses: accepted.pauses,
259 tasks: accepted.tasks,
260 });
261 if accepted.deleted_tasks > 0 {
265 msg_info!(Message::KaslServerTasksDeleted(accepted.deleted_tasks));
266 }
267 Ok(())
268 }
269 Err(
275 error @ UploadError::Rejected {
276 status: StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN,
277 ..
278 },
279 ) => Err(msg_error_anyhow!(Message::KaslServerPushTokenRejected(error.to_string()))),
280 Err(error @ UploadError::Rejected { .. }) => Err(msg_error_anyhow!(Message::KaslServerPushRejected(error.to_string()))),
281 Err(error) => Err(msg_error_anyhow!(Message::KaslServerPushRetryable(error.to_string()))),
282 }
283}
284
285fn disconnect() -> Result<()> {
291 let mut config = Config::read().unwrap_or_default();
292
293 if let Err(error) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).delete() {
300 msg_warning!(Message::KaslServerTokenNotRemoved(error.to_string()));
301 }
302
303 if config.kasl_server.take().is_some() {
304 config.save()?;
305 msg_success!(Message::KaslServerDisconnected);
306 } else {
307 msg_print!(Message::KaslServerNotConnected);
309 }
310
311 Ok(())
312}