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