spacetimedb_cli/subcommands/
server.rs

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        // TODO: set-name, set-protocol, set-host, set-url
114    ]
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    // Trim trailing `/`s because otherwise we end up with a double `//` in some later codepaths.
193    // See https://github.com/clockworklabs/SpacetimeDB/issues/1551.
194    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}