spacetimedb_cli/subcommands/
publish.rs1use 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_domain_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='--skip-println-checks'")
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 common_args::anonymous()
53 )
54 .arg(
55 Arg::new("name|identity")
56 .help("A valid domain or identity for this database"),
57 )
58 .arg(common_args::server()
59 .help("The nickname, domain name or URL of the server to host the database."),
60 )
61 .arg(
62 common_args::yes()
63 )
64 .after_help("Run `spacetime help publish` for more detailed information.")
65}
66
67pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
68 let server = args.get_one::<String>("server").map(|s| s.as_str());
69 let name_or_identity = args.get_one::<String>("name|identity");
70 let path_to_project = args.get_one::<PathBuf>("project_path").unwrap();
71 let clear_database = args.get_flag("clear_database");
72 let force = args.get_flag("force");
73 let anon_identity = args.get_flag("anon_identity");
74 let wasm_file = args.get_one::<PathBuf>("wasm_file");
75 let database_host = config.get_host_url(server)?;
76 let build_options = args.get_one::<String>("build_options").unwrap();
77
78 let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?;
83
84 let client = reqwest::Client::new();
85
86 let mut builder = if let Some(name_or_identity) = name_or_identity {
88 if !is_identity(name_or_identity) {
89 parse_domain_name(name_or_identity)?;
90 }
91 let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') };
92 let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set);
93 client.put(format!("{database_host}/v1/database/{domain}"))
94 } else {
95 client.post(format!("{database_host}/v1/database"))
96 };
97
98 if !path_to_project.exists() {
99 return Err(anyhow::anyhow!(
100 "Project path does not exist: {}",
101 path_to_project.display()
102 ));
103 }
104
105 let path_to_wasm = if let Some(path) = wasm_file {
106 println!("Skipping build. Instead we are publishing {}", path.display());
107 path.clone()
108 } else {
109 build::exec_with_argstring(config.clone(), path_to_project, build_options).await?
110 };
111 let program_bytes = fs::read(path_to_wasm)?;
112
113 let server_address = {
114 let url = Url::parse(&database_host)?;
115 url.host_str().unwrap_or("<default>").to_string()
116 };
117 if server_address != "localhost" && server_address != "127.0.0.1" {
118 println!("You are about to publish to a non-local server: {}", server_address);
119 if !y_or_n(force, "Are you sure you want to proceed?")? {
120 println!("Aborting");
121 return Ok(());
122 }
123 }
124
125 println!(
126 "Uploading to {} => {}",
127 server.unwrap_or(config.default_server_name().unwrap_or("<default>")),
128 database_host
129 );
130
131 if clear_database {
132 println!(
134 "This will DESTROY the current {} module, and ALL corresponding data.",
135 name_or_identity.unwrap()
136 );
137 if !y_or_n(
138 force,
139 format!(
140 "Are you sure you want to proceed? [deleting {}]",
141 name_or_identity.unwrap()
142 )
143 .as_str(),
144 )? {
145 println!("Aborting");
146 return Ok(());
147 }
148 builder = builder.query(&[("clear", true)]);
149 }
150
151 println!("Publishing module...");
152
153 builder = add_auth_header_opt(builder, &auth_header);
154
155 let res = builder.body(program_bytes).send().await?;
156 if res.status() == StatusCode::UNAUTHORIZED && !anon_identity {
157 let token = config.spacetimedb_token().unwrap();
159 let identity = decode_identity(token)?;
160 let err = res.text().await?;
161 return unauth_error_context(
162 Err(anyhow::anyhow!(err)),
163 &identity,
164 config.server_nick_or_host(server)?,
165 );
166 }
167
168 let response: PublishResult = res.json_or_error().await?;
169 match response {
170 PublishResult::Success {
171 domain,
172 database_identity,
173 op,
174 } => {
175 let op = match op {
176 PublishOp::Created => "Created new",
177 PublishOp::Updated => "Updated",
178 };
179 if let Some(domain) = domain {
180 println!("{} database with name: {}, identity: {}", op, domain, database_identity);
181 } else {
182 println!("{} database with identity: {}", op, database_identity);
183 }
184 }
185 PublishResult::PermissionDenied { name } => {
186 if anon_identity {
187 anyhow::bail!("You need to be logged in as the owner of {name} to publish to {name}",);
188 }
189 let token = config.spacetimedb_token().unwrap();
191 let identity = decode_identity(token)?;
192 let suggested_tld: String = identity.chars().take(12).collect();
195 return Err(anyhow::anyhow!(
196 "The database {name} is not registered to the identity you provided.\n\
197 We suggest you push to either a domain owned by you, or a new domain like:\n\
198 \tspacetime publish {suggested_tld}\n",
199 ));
200 }
201 }
202
203 Ok(())
204}