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