1use crate::{
2 common_args,
3 util::{host_or_url_to_host_and_protocol, spacetime_server_fingerprint, y_or_n, UNSTABLE_WARNING, VALID_PROTOCOLS},
4 Config,
5};
6use anyhow::Context;
7use clap::{Arg, ArgAction, ArgMatches, Command};
8use spacetimedb_paths::{server::ServerDataDir, SpacetimePaths};
9use tabled::{
10 settings::{object::Columns, Alignment, Modify, Style},
11 Table, Tabled,
12};
13
14pub fn cli() -> Command {
15 Command::new("server")
16 .args_conflicts_with_subcommands(true)
17 .subcommand_required(true)
18 .subcommands(get_subcommands())
19 .about(format!(
20 "Manage the connection to the SpacetimeDB server. {}",
21 UNSTABLE_WARNING
22 ))
23}
24
25fn get_subcommands() -> Vec<Command> {
26 vec![
27 Command::new("list").about("List stored server configurations"),
28 Command::new("set-default")
29 .about("Set the default server for future operations")
30 .arg(
31 Arg::new("server")
32 .help("The nickname, host name or URL of the new default server")
33 .required(true),
34 ),
35 Command::new("add")
36 .about("Add a new server configuration")
37 .arg(
38 Arg::new("url")
39 .long("url")
40 .help("The URL of the server to add")
41 .required(true),
42 )
43 .arg(Arg::new("name").help("Nickname for this server").required(true))
44 .arg(
45 Arg::new("default")
46 .help("Make the new server the default server for future operations")
47 .long("default")
48 .short('d')
49 .action(ArgAction::SetTrue),
50 )
51 .arg(
52 Arg::new("no-fingerprint")
53 .help("Skip fingerprinting the server")
54 .long("no-fingerprint")
55 .action(ArgAction::SetTrue),
56 ),
57 Command::new("remove")
58 .about("Remove a saved server configuration")
59 .arg(
60 Arg::new("server")
61 .help("The nickname, host name or URL of the server to remove")
62 .required(true),
63 )
64 .arg(common_args::yes()),
65 Command::new("fingerprint")
66 .about("Show or update a saved server's fingerprint")
67 .arg(
68 Arg::new("server")
69 .required(true)
70 .help("The nickname, host name or URL of the server"),
71 )
72 .arg(common_args::yes()),
73 Command::new("ping")
74 .about("Checks to see if a SpacetimeDB host is online")
75 .arg(
76 Arg::new("server")
77 .required(true)
78 .help("The nickname, host name or URL of the server to ping"),
79 ),
80 Command::new("edit")
81 .about("Update a saved server's nickname, host name or protocol")
82 .arg(
83 Arg::new("server")
84 .required(true)
85 .help("The nickname, host name or URL of the server"),
86 )
87 .arg(
88 Arg::new("nickname")
89 .help("A new nickname to assign the server configuration")
90 .long("new-name"),
91 )
92 .arg(
93 Arg::new("url")
94 .long("url")
95 .help("A new URL to assign the server configuration"),
96 )
97 .arg(
98 Arg::new("no-fingerprint")
99 .help("Skip fingerprinting the server")
100 .long("no-fingerprint")
101 .action(ArgAction::SetTrue),
102 )
103 .arg(common_args::yes()),
104 Command::new("clear")
105 .about("Deletes all data from all local databases")
106 .arg(
107 Arg::new("data_dir")
108 .long("data-dir")
109 .help("The path to the server data directory to clear [default: that of the selected spacetime instance]")
110 .value_parser(clap::value_parser!(ServerDataDir)),
111 )
112 .arg(common_args::yes()),
113 ]
115}
116
117pub async fn exec(config: Config, paths: &SpacetimePaths, args: &ArgMatches) -> Result<(), anyhow::Error> {
118 let (cmd, subcommand_args) = args.subcommand().expect("Subcommand required");
119 eprintln!("{}\n", UNSTABLE_WARNING);
120 exec_subcommand(config, paths, cmd, subcommand_args).await
121}
122
123async fn exec_subcommand(
124 config: Config,
125 paths: &SpacetimePaths,
126 cmd: &str,
127 args: &ArgMatches,
128) -> Result<(), anyhow::Error> {
129 match cmd {
130 "list" => exec_list(config, args).await,
131 "set-default" => exec_set_default(config, args).await,
132 "add" => exec_add(config, args).await,
133 "remove" => exec_remove(config, args).await,
134 "fingerprint" => exec_fingerprint(config, args).await,
135 "ping" => exec_ping(config, args).await,
136 "edit" => exec_edit(config, args).await,
137 "clear" => exec_clear(config, paths, args).await,
138 unknown => Err(anyhow::anyhow!("Invalid subcommand: {}", unknown)),
139 }
140}
141
142#[derive(Tabled)]
143#[tabled(rename_all = "UPPERCASE")]
144struct LsRow {
145 default: String,
146 hostname: String,
147 protocol: String,
148 nickname: String,
149}
150
151pub async fn exec_list(config: Config, _args: &ArgMatches) -> Result<(), anyhow::Error> {
152 let mut rows: Vec<LsRow> = Vec::new();
153 for server_config in config.server_configs() {
154 let default = if let Some(default_name) = config.default_server_name() {
155 server_config.nick_or_host_or_url_is(default_name)
156 } else {
157 false
158 };
159 rows.push(LsRow {
160 default: if default { "***" } else { "" }.to_string(),
161 hostname: server_config.host.to_string(),
162 protocol: server_config.protocol.to_string(),
163 nickname: server_config.nickname.as_deref().unwrap_or("").to_string(),
164 });
165 }
166
167 let mut table = Table::new(&rows);
168 table
169 .with(Style::empty())
170 .with(Modify::new(Columns::first()).with(Alignment::right()));
171 println!("{}", table);
172
173 Ok(())
174}
175
176pub async fn exec_set_default(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
177 let server = args.get_one::<String>("server").unwrap();
178 config.set_default_server(server)?;
179 config.save();
180 Ok(())
181}
182
183fn valid_protocol_or_error(protocol: &str) -> anyhow::Result<()> {
184 if !VALID_PROTOCOLS.contains(&protocol) {
185 Err(anyhow::anyhow!("Invalid protocol: {}", protocol))
186 } else {
187 Ok(())
188 }
189}
190
191pub async fn exec_add(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
192 let url = args.get_one::<String>("url").unwrap().trim_end_matches('/');
195 let nickname = args.get_one::<String>("name");
196 let default = *args.get_one::<bool>("default").unwrap();
197 let no_fingerprint = *args.get_one::<bool>("no-fingerprint").unwrap();
198
199 let (host, protocol) = host_or_url_to_host_and_protocol(url);
200 let protocol = protocol.ok_or_else(|| anyhow::anyhow!("Invalid url: {}", url))?;
201
202 valid_protocol_or_error(protocol)?;
203
204 let fingerprint = if no_fingerprint {
205 None
206 } else {
207 let fingerprint = spacetime_server_fingerprint(url).await.with_context(|| {
208 format!(
209 "Unable to retrieve fingerprint for server: {url}
210Is the server running?
211Add a server without retrieving its fingerprint with:
212\tspacetime server add --url {url} --no-fingerprint",
213 )
214 })?;
215 println!("For server {}, got fingerprint:\n{}", url, fingerprint);
216 Some(fingerprint)
217 };
218
219 config.add_server(host.to_string(), protocol.to_string(), fingerprint, nickname.cloned())?;
220
221 if default {
222 config.set_default_server(host)?;
223 }
224
225 println!("Host: {}", host);
226 println!("Protocol: {}", protocol);
227
228 config.save();
229
230 Ok(())
231}
232
233pub async fn exec_remove(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
234 let server = args.get_one::<String>("server").unwrap();
235
236 config.remove_server(server)?;
237
238 config.save();
239
240 Ok(())
241}
242
243async fn update_server_fingerprint(config: &mut Config, server: Option<&str>) -> Result<bool, anyhow::Error> {
244 let url = config.get_host_url(server)?;
245 let nick_or_host = config.server_nick_or_host(server)?;
246 let new_fing = spacetime_server_fingerprint(&url)
247 .await
248 .context("Error fetching server fingerprint")?;
249 if let Some(saved_fing) = config.server_fingerprint(server)? {
250 if saved_fing == new_fing {
251 println!("Fingerprint is unchanged for server {}:\n{}", nick_or_host, saved_fing);
252
253 Ok(false)
254 } else {
255 println!(
256 "Fingerprint has changed for server {}.\nWas:\n{}\nNew:\n{}",
257 nick_or_host, saved_fing, new_fing
258 );
259
260 config.set_server_fingerprint(server, new_fing)?;
261
262 Ok(true)
263 }
264 } else {
265 println!(
266 "No saved fingerprint for server {}. New fingerprint:\n{}",
267 nick_or_host, new_fing
268 );
269
270 config.set_server_fingerprint(server, new_fing)?;
271
272 Ok(true)
273 }
274}
275
276pub async fn exec_fingerprint(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
277 let server = args.get_one::<String>("server").unwrap().as_str();
278 let force = args.get_flag("force");
279
280 if update_server_fingerprint(&mut config, Some(server)).await? {
281 if !y_or_n(force, "Continue?")? {
282 anyhow::bail!("Aborted");
283 }
284
285 config.save();
286 }
287
288 Ok(())
289}
290
291pub async fn exec_ping(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
292 let server = args.get_one::<String>("server").unwrap().as_str();
293 let url = config.get_host_url(Some(server))?;
294
295 let builder = reqwest::Client::new().get(format!("{}/v1/ping", url).as_str());
296 let response = builder.send().await?;
297
298 match response.status() {
299 reqwest::StatusCode::OK => {
300 println!("Server is online: {}", url);
301 }
302 reqwest::StatusCode::NOT_FOUND => {
303 println!("Server returned 404 (Not Found): {}", url);
304 }
305 err => {
306 println!("Server could not be reached ({}): {}", err, url);
307 }
308 }
309 Ok(())
310}
311
312pub async fn exec_edit(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
313 let server = args.get_one::<String>("server").unwrap().as_str();
314
315 let old_url = config.get_host_url(Some(server))?;
316
317 let new_nick = args.get_one::<String>("nickname").map(|s| s.as_str());
318 let new_url = args.get_one::<String>("url").map(|s| s.as_str());
319 let (new_host, new_proto) = match new_url {
320 None => (None, None),
321 Some(new_url) => {
322 let (new_host, new_proto) = host_or_url_to_host_and_protocol(new_url);
323 let new_proto = new_proto.ok_or_else(|| anyhow::anyhow!("Invalid url: {}", new_url))?;
324 (Some(new_host), Some(new_proto))
325 }
326 };
327
328 let no_fingerprint = args.get_flag("no-fingerprint");
329 let force = args.get_flag("force");
330
331 if let Some(new_proto) = new_proto {
332 valid_protocol_or_error(new_proto)?;
333 }
334
335 let (old_nick, old_host, old_proto) = config.edit_server(server, new_nick, new_host, new_proto)?;
336 let server = new_nick.unwrap_or(server);
337
338 if let (Some(new_nick), Some(old_nick)) = (new_nick, old_nick) {
339 println!("Changing nickname from {} to {}", old_nick, new_nick);
340 }
341 if let (Some(new_host), Some(old_host)) = (new_host, old_host) {
342 println!("Changing host from {} to {}", old_host, new_host);
343 }
344 if let (Some(new_proto), Some(old_proto)) = (new_proto, old_proto) {
345 println!("Changing protocol from {} to {}", old_proto, new_proto);
346 }
347
348 let new_url = config.get_host_url(Some(server))?;
349
350 if old_url != new_url {
351 if no_fingerprint {
352 config.delete_server_fingerprint(Some(&new_url))?;
353 } else {
354 update_server_fingerprint(&mut config, Some(&new_url)).await?;
355 }
356 }
357
358 if !y_or_n(force, "Continue?")? {
359 anyhow::bail!("Aborted");
360 }
361
362 config.save();
363
364 Ok(())
365}
366
367async fn exec_clear(_config: Config, paths: &SpacetimePaths, args: &ArgMatches) -> Result<(), anyhow::Error> {
368 let force = args.get_flag("force");
369 let data_dir = args.get_one::<ServerDataDir>("data_dir").unwrap_or(&paths.data_dir);
370
371 if data_dir.0.exists() {
372 println!("Database path: {}", data_dir.display());
373
374 if !y_or_n(
375 force,
376 "Are you sure you want to delete all data from the local database?",
377 )? {
378 println!("Aborting");
379 return Ok(());
380 }
381
382 std::fs::remove_dir_all(data_dir)?;
383 println!("Deleted database: {}", data_dir.display());
384 } else {
385 println!("Local database not found. Nothing has been deleted.");
386 }
387 Ok(())
388}