1use crate::api::kasl_server::{AGENT_TOKEN_PROMPT, AGENT_TOKEN_SECRET, KaslServer, normalize_url};
18use crate::libs::config::{Config, KaslServerConfig};
19use crate::libs::messages::Message;
20use crate::libs::secret::Secret;
21use crate::{msg_error_anyhow, msg_info, msg_print, msg_success, msg_warning};
22use anyhow::{Context, Result};
23use clap::{Args, Subcommand};
24use dialoguer::{Input, Password, theme::ColorfulTheme};
25
26#[derive(Debug, Args)]
28pub struct ServerArgs {
29 #[command(subcommand)]
30 command: ServerCommand,
31}
32
33#[derive(Debug, Subcommand)]
35enum ServerCommand {
36 #[command(about = "Connect this machine to a kasl-server")]
38 Connect(ConnectArgs),
39
40 #[command(about = "Show the current connection to a kasl-server")]
42 Status,
43
44 #[command(about = "Forget the connection and the stored agent token")]
46 Disconnect,
47}
48
49#[derive(Debug, Args)]
51pub struct ConnectArgs {
52 #[arg(long, value_name = "URL")]
54 url: Option<String>,
55
56 #[arg(long, value_name = "PATH")]
58 ca_certificate: Option<String>,
59}
60
61pub async fn cmd(args: ServerArgs) -> Result<()> {
63 match args.command {
64 ServerCommand::Connect(args) => connect(args).await,
65 ServerCommand::Status => status().await,
66 ServerCommand::Disconnect => disconnect(),
67 }
68}
69
70async fn connect(args: ConnectArgs) -> Result<()> {
76 crate::libs::prompt::ensure_interactive("`kasl server connect` needs a terminal to ask for the agent token")?;
83
84 let mut config = Config::read().unwrap_or_default();
85
86 let url = match args.url {
87 Some(url) => normalize_url(&url),
88 None => {
89 let entered: String = Input::with_theme(&ColorfulTheme::default())
90 .with_prompt(Message::PromptKaslServerUrl.to_string())
91 .with_initial_text(config.kasl_server.as_ref().map(|s| s.url.clone()).unwrap_or_default())
92 .interact_text()?;
93 normalize_url(&entered)
94 }
95 };
96
97 if !url.starts_with("http://") && !url.starts_with("https://") {
100 return Err(msg_error_anyhow!(Message::KaslServerUrlNeedsScheme(url)));
101 }
102
103 let candidate = KaslServerConfig {
104 url: url.clone(),
105 ca_certificate: args
108 .ca_certificate
109 .or_else(|| config.kasl_server.as_ref().and_then(|s| s.ca_certificate.clone())),
110 };
111
112 let client = KaslServer::new(&candidate)?;
113
114 let health = client.health().await?;
117 msg_info!(Message::KaslServerReached {
118 url: url.clone(),
119 version: health.version.clone(),
120 });
121 if health.database != "ok" {
122 msg_warning!(Message::KaslServerDatabaseUnhealthy(health.database.clone()));
125 }
126
127 let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
128 let token: String = Password::with_theme(&ColorfulTheme::default())
129 .with_prompt(Message::PromptKaslServerToken.to_string())
130 .interact()?;
131 let token = token.trim().to_string();
132 if token.is_empty() {
133 return Err(msg_error_anyhow!(Message::KaslServerTokenEmpty));
134 }
135
136 let identity = client.identify(&token).await?;
138
139 secret
141 .store(&token)
142 .context("the token was accepted but could not be stored in the OS keyring")?;
143 config.kasl_server = Some(candidate);
144 config.save()?;
145
146 msg_success!(Message::KaslServerConnected {
147 user_name: identity.user_name,
148 agent_name: identity.agent_name,
149 });
150 Ok(())
151}
152
153async fn status() -> Result<()> {
159 let config = Config::read().unwrap_or_default();
160 let Some(server_config) = config.kasl_server else {
161 msg_print!(Message::KaslServerNotConnected);
162 return Ok(());
163 };
164
165 msg_info!(Message::KaslServerConfigured(server_config.url.clone()));
166
167 let secret = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT);
168 let Some(token) = secret.try_get_cached() else {
169 msg_warning!(Message::KaslServerTokenMissing);
172 return Ok(());
173 };
174
175 let client = KaslServer::new(&server_config)?;
176
177 match client.health().await {
178 Ok(health) => msg_info!(Message::KaslServerReached {
179 url: server_config.url.clone(),
180 version: health.version,
181 }),
182 Err(error) => {
183 msg_warning!(Message::KaslServerUnreachable(error.to_string()));
184 return Ok(());
185 }
186 }
187
188 match client.identify(&token).await {
189 Ok(identity) => msg_success!(Message::KaslServerConnected {
190 user_name: identity.user_name,
191 agent_name: identity.agent_name,
192 }),
193 Err(error) => msg_warning!(Message::KaslServerTokenRejected(error.to_string())),
194 }
195
196 Ok(())
197}
198
199fn disconnect() -> Result<()> {
205 let mut config = Config::read().unwrap_or_default();
206
207 if let Err(error) = Secret::new(AGENT_TOKEN_SECRET, AGENT_TOKEN_PROMPT).delete() {
214 msg_warning!(Message::KaslServerTokenNotRemoved(error.to_string()));
215 }
216
217 if config.kasl_server.take().is_some() {
218 config.save()?;
219 msg_success!(Message::KaslServerDisconnected);
220 } else {
221 msg_print!(Message::KaslServerNotConnected);
223 }
224
225 Ok(())
226}