spacetimedb_cli/subcommands/
publish.rs

1use clap::Arg;
2use clap::ArgAction::{Set, SetTrue};
3use clap::ArgMatches;
4use reqwest::{StatusCode, Url};
5use spacetimedb_client_api_messages::name::PublishOp;
6use spacetimedb_client_api_messages::name::{is_identity, parse_database_name, PublishResult};
7use std::fs;
8use std::path::PathBuf;
9
10use crate::config::Config;
11use crate::util::{add_auth_header_opt, get_auth_header, ResponseExt};
12use crate::util::{decode_identity, unauth_error_context, y_or_n};
13use crate::{build, common_args};
14
15pub fn cli() -> clap::Command {
16    clap::Command::new("publish")
17        .about("Create and update a SpacetimeDB database")
18        .arg(
19            Arg::new("clear_database")
20                .long("delete-data")
21                .short('c')
22                .action(SetTrue)
23                .requires("name|identity")
24                .help("When publishing to an existing database identity, first DESTROY all data associated with the module"),
25        )
26        .arg(
27            Arg::new("build_options")
28                .long("build-options")
29                .alias("build-opts")
30                .action(Set)
31                .default_value("")
32                .help("Options to pass to the build command, for example --build-options='--lint-dir='")
33        )
34        .arg(
35            Arg::new("project_path")
36                .value_parser(clap::value_parser!(PathBuf))
37                .default_value(".")
38                .long("project-path")
39                .short('p')
40                .help("The system path (absolute or relative) to the module project")
41        )
42        .arg(
43            Arg::new("wasm_file")
44                .value_parser(clap::value_parser!(PathBuf))
45                .long("bin-path")
46                .short('b')
47                .conflicts_with("project_path")
48                .conflicts_with("build_options")
49                .help("The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project."),
50        )
51        .arg(
52            Arg::new("num_replicas")
53                .value_parser(clap::value_parser!(u8))
54                .long("num-replicas")
55                .hide(true)
56                .help("UNSTABLE: The number of replicas the database should have")
57        )
58        .arg(
59            common_args::anonymous()
60        )
61        .arg(
62            Arg::new("name|identity")
63                .help("A valid domain or identity for this database")
64                .long_help(
65"A valid domain or identity for this database.
66
67Database names must match the regex `/^[a-z0-9]+(-[a-z0-9]+)*$/`,
68i.e. only lowercase ASCII letters and numbers, separated by dashes."),
69        )
70        .arg(common_args::server()
71                .help("The nickname, domain name or URL of the server to host the database."),
72        )
73        .arg(
74            common_args::yes()
75        )
76        .after_help("Run `spacetime help publish` for more detailed information.")
77}
78
79pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
80    let server = args.get_one::<String>("server").map(|s| s.as_str());
81    let name_or_identity = args.get_one::<String>("name|identity");
82    let path_to_project = args.get_one::<PathBuf>("project_path").unwrap();
83    let clear_database = args.get_flag("clear_database");
84    let force = args.get_flag("force");
85    let anon_identity = args.get_flag("anon_identity");
86    let wasm_file = args.get_one::<PathBuf>("wasm_file");
87    let database_host = config.get_host_url(server)?;
88    let build_options = args.get_one::<String>("build_options").unwrap();
89    let num_replicas = args.get_one::<u8>("num_replicas");
90
91    // If the user didn't specify an identity and we didn't specify an anonymous identity, then
92    // we want to use the default identity
93    // TODO(jdetter): We should maybe have some sort of user prompt here for them to be able to
94    //  easily create a new identity with an email
95    let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?;
96
97    let client = reqwest::Client::new();
98
99    // If a domain or identity was provided, we should locally make sure it looks correct and
100    let mut builder = if let Some(name_or_identity) = name_or_identity {
101        if !is_identity(name_or_identity) {
102            parse_database_name(name_or_identity)?;
103        }
104        let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') };
105        let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set);
106        client.put(format!("{database_host}/v1/database/{domain}"))
107    } else {
108        client.post(format!("{database_host}/v1/database"))
109    };
110
111    if !path_to_project.exists() {
112        return Err(anyhow::anyhow!(
113            "Project path does not exist: {}",
114            path_to_project.display()
115        ));
116    }
117
118    let path_to_wasm = if let Some(path) = wasm_file {
119        println!("Skipping build. Instead we are publishing {}", path.display());
120        path.clone()
121    } else {
122        build::exec_with_argstring(config.clone(), path_to_project, build_options).await?
123    };
124    let program_bytes = fs::read(path_to_wasm)?;
125
126    let server_address = {
127        let url = Url::parse(&database_host)?;
128        url.host_str().unwrap_or("<default>").to_string()
129    };
130    if server_address != "localhost" && server_address != "127.0.0.1" {
131        println!("You are about to publish to a non-local server: {server_address}");
132        if !y_or_n(force, "Are you sure you want to proceed?")? {
133            println!("Aborting");
134            return Ok(());
135        }
136    }
137
138    println!(
139        "Uploading to {} => {}",
140        server.unwrap_or(config.default_server_name().unwrap_or("<default>")),
141        database_host
142    );
143
144    if clear_database {
145        // Note: `name_or_identity` should be set, because it is `required` in the CLI arg config.
146        println!(
147            "This will DESTROY the current {} module, and ALL corresponding data.",
148            name_or_identity.unwrap()
149        );
150        if !y_or_n(
151            force,
152            format!(
153                "Are you sure you want to proceed? [deleting {}]",
154                name_or_identity.unwrap()
155            )
156            .as_str(),
157        )? {
158            println!("Aborting");
159            return Ok(());
160        }
161        builder = builder.query(&[("clear", true)]);
162    }
163    if let Some(n) = num_replicas {
164        eprintln!("WARNING: Use of unstable option `--num-replicas`.\n");
165        builder = builder.query(&[("num_replicas", *n)]);
166    }
167
168    println!("Publishing module...");
169
170    builder = add_auth_header_opt(builder, &auth_header);
171
172    let res = builder.body(program_bytes).send().await?;
173    if res.status() == StatusCode::UNAUTHORIZED && !anon_identity {
174        // If we're not in the `anon_identity` case, then we have already forced the user to log in above (using `get_auth_header`), so this should be safe to unwrap.
175        let token = config.spacetimedb_token().unwrap();
176        let identity = decode_identity(token)?;
177        let err = res.text().await?;
178        return unauth_error_context(
179            Err(anyhow::anyhow!(err)),
180            &identity,
181            config.server_nick_or_host(server)?,
182        );
183    }
184
185    let response: PublishResult = res.json_or_error().await?;
186    match response {
187        PublishResult::Success {
188            domain,
189            database_identity,
190            op,
191        } => {
192            let op = match op {
193                PublishOp::Created => "Created new",
194                PublishOp::Updated => "Updated",
195            };
196            if let Some(domain) = domain {
197                println!("{op} database with name: {domain}, identity: {database_identity}");
198            } else {
199                println!("{op} database with identity: {database_identity}");
200            }
201        }
202        PublishResult::PermissionDenied { name } => {
203            if anon_identity {
204                anyhow::bail!("You need to be logged in as the owner of {name} to publish to {name}",);
205            }
206            // If we're not in the `anon_identity` case, then we have already forced the user to log in above (using `get_auth_header`), so this should be safe to unwrap.
207            let token = config.spacetimedb_token().unwrap();
208            let identity = decode_identity(token)?;
209            //TODO(jdetter): Have a nice name generator here, instead of using some abstract characters
210            // we should perhaps generate fun names like 'green-fire-dragon' instead
211            let suggested_tld: String = identity.chars().take(12).collect();
212            return Err(anyhow::anyhow!(
213                "The database {name} is not registered to the identity you provided.\n\
214                We suggest you push to either a domain owned by you, or a new domain like:\n\
215                \tspacetime publish {suggested_tld}\n",
216            ));
217        }
218    }
219
220    Ok(())
221}