Support server-side deploy config merging in deploy flow
- Add DeployConfig model for API response - Fetch and merge server deploy config into local config if available - Add get_deploy_config API call in smbcloud-networking-project - Update FrontendApp and Project models with new deploy config fields - Extend URL builder for deploy_config endpoint
Seto Elkahfi committed
May 17, 2026 at 10:46 UTC
61e1950ee088390ff3fee2b71dedc538f2295689
8 files changed
+249
-2
crates/cli/src/deploy/config.rs
+79
-2
@@ -1,5 +1,6 @@
1
use {
2
crate::{
3
+ client,
4
deploy::setup_project::setup_project,
5
ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
6
},
@@ -10,7 +11,9 @@ use {
11
},
12
smbcloud_network::environment::Environment,
13
smbcloud_networking::smb_client::SmbClient,
13
- smbcloud_networking_project::crud_project_read::get_project,
14
+ smbcloud_networking_project::{
15
+ crud_frontend_app_deploy_config::get_deploy_config, crud_project_read::get_project,
16
+ },
17
smbcloud_utils::config::Config,
18
spinners::Spinner,
19
std::{fs, path::Path},
@@ -46,7 +49,7 @@ pub(crate) async fn get_config(
49
message: ErrorCode::MissingConfig.message(None).to_string(),
50
})?;
51
49
- let config: Config = match toml::from_str(&config_content) {
52
+ let mut config: Config = match toml::from_str(&config_content) {
53
Ok(value) => value,
54
Err(e) => {
55
println!("Error parsing config: {}", e);
@@ -59,6 +62,80 @@ pub(crate) async fn get_config(
62
succeed_message(&format!("Valid config for {}", config.name)),
63
);
64
65
+ // Attempt to fetch server-side deploy config when we have both a token
66
+ // and a frontend_app_id. Falls back to local config silently on failure.
67
+ if let Some(token) = access_token {
68
+ if let Some(ref frontend_app_id) = config.project.frontend_app_id {
69
+ match get_deploy_config(env, client(), token.to_string(), frontend_app_id).await {
70
+ Ok(deploy_config) => {
71
+ let has_server_fields =
72
+ deploy_config.kind.is_some() || deploy_config.remote_path.is_some();
73
+
74
+ if has_server_fields {
75
+ println!(
76
+ " {} Using server-side deploy config",
77
+ console::style("ℹ").cyan()
78
+ );
79
+
80
+ if let Some(remote_kind) = deploy_config.kind {
81
+ config.project.kind = Some(remote_kind);
82
+ }
83
+ if let Some(remote_path) = deploy_config.remote_path {
84
+ config.project.path = Some(remote_path);
85
+ }
86
+ if let Some(remote_output_path) = deploy_config.output_path {
87
+ config.project.output = Some(remote_output_path);
88
+ }
89
+ if let Some(remote_build_command) = deploy_config.build_command {
90
+ config.project.compile_cmd = Some(remote_build_command);
91
+ }
92
+ if let Some(remote_install_command) = deploy_config.install_command {
93
+ config.project.install_command = Some(remote_install_command);
94
+ }
95
+ if let Some(remote_binary_name) = deploy_config.binary_name {
96
+ config.project.binary_name = Some(remote_binary_name);
97
+ }
98
+ if let Some(remote_build_target) = deploy_config.build_target {
99
+ config.project.rust_target = Some(remote_build_target);
100
+ }
101
+ if let Some(remote_package_manager) = deploy_config.package_manager {
102
+ config.project.package_manager = Some(remote_package_manager);
103
+ }
104
+ if let Some(remote_pm2_app) = deploy_config.pm2_app {
105
+ config.project.pm2_app = Some(remote_pm2_app);
106
+ }
107
+ if let Some(remote_port) = deploy_config.port {
108
+ config.project.port = Some(remote_port);
109
+ }
110
+ if let Some(remote_shared_lib_path) = deploy_config.shared_lib_path {
111
+ config.project.shared_lib = Some(remote_shared_lib_path);
112
+ }
113
+ if let Some(remote_source_path) = deploy_config.source_path {
114
+ config.project.source_path = Some(remote_source_path);
115
+ }
116
+ if let Some(remote_repository) = deploy_config.repository {
117
+ config.project.repository = Some(remote_repository);
118
+ }
119
+ config.project.runner = deploy_config.runner;
120
+ config.project.deployment_method = deploy_config.deployment_method;
121
+ config.project.deploy_repo_id = deploy_config.deploy_repo_id;
122
+ } else {
123
+ println!(
124
+ " {} Using local deploy config (.smb/config.toml)",
125
+ console::style("ℹ").cyan()
126
+ );
127
+ }
128
+ }
129
+ Err(_) => {
130
+ println!(
131
+ " {} Could not fetch server config, using local .smb/config.toml",
132
+ console::style("ℹ").yellow()
133
+ );
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
Ok(config)
140
}
141
crates/smbcloud-model/src/deploy_config.rs
new
+108
@@ -0,0 +1,108 @@
1
+use {
2
+ crate::{project::DeploymentMethod, runner::Runner},
3
+ serde::{Deserialize, Serialize},
4
+};
5
+
6
+/// Server-side deploy configuration returned by the API.
7
+///
8
+/// The CLI fetches this from `GET /v1/frontend_apps/{id}/deploy_config`
9
+/// and merges the populated fields into the local `.smb/config.toml`.
10
+#[derive(Debug, Serialize, Deserialize, Clone)]
11
+pub struct DeployConfig {
12
+ pub id: String,
13
+ pub name: String,
14
+ pub runner: Runner,
15
+ pub deployment_method: DeploymentMethod,
16
+ #[serde(default)]
17
+ pub source_path: Option<String>,
18
+ #[serde(default)]
19
+ pub kind: Option<String>,
20
+ #[serde(default)]
21
+ pub remote_path: Option<String>,
22
+ #[serde(default)]
23
+ pub output_path: Option<String>,
24
+ #[serde(default)]
25
+ pub build_command: Option<String>,
26
+ #[serde(default)]
27
+ pub install_command: Option<String>,
28
+ #[serde(default)]
29
+ pub binary_name: Option<String>,
30
+ #[serde(default)]
31
+ pub build_target: Option<String>,
32
+ #[serde(default)]
33
+ pub package_manager: Option<String>,
34
+ #[serde(default)]
35
+ pub pm2_app: Option<String>,
36
+ #[serde(default)]
37
+ pub port: Option<u16>,
38
+ #[serde(default)]
39
+ pub shared_lib_path: Option<String>,
40
+ #[serde(default)]
41
+ pub project_id: Option<i32>,
42
+ #[serde(default)]
43
+ pub deploy_repo_id: Option<i64>,
44
+ #[serde(default)]
45
+ pub repository: Option<String>,
46
+}
47
+
48
+#[cfg(test)]
49
+mod tests {
50
+ use super::*;
51
+
52
+ #[test]
53
+ fn deserializes_full_deploy_config() {
54
+ let json = r#"{
55
+ "id": "80b309e8-c39a-45b6-9121-e616fde3cd42",
56
+ "name": "onde-cloud",
57
+ "runner": 0,
58
+ "deployment_method": 1,
59
+ "source_path": ".",
60
+ "kind": "rust",
61
+ "remote_path": "apps/rest-api/onde-cloud",
62
+ "output_path": null,
63
+ "build_command": null,
64
+ "install_command": null,
65
+ "binary_name": "onde-cloud",
66
+ "build_target": "x86_64-unknown-linux-gnu",
67
+ "package_manager": null,
68
+ "pm2_app": null,
69
+ "port": 8090,
70
+ "shared_lib_path": null,
71
+ "project_id": 50,
72
+ "deploy_repo_id": 18,
73
+ "repository": "test"
74
+ }"#;
75
+
76
+ let config: DeployConfig = serde_json::from_str(json).expect("should deserialize");
77
+ assert_eq!(config.id, "80b309e8-c39a-45b6-9121-e616fde3cd42");
78
+ assert_eq!(config.kind.as_deref(), Some("rust"));
79
+ assert_eq!(
80
+ config.remote_path.as_deref(),
81
+ Some("apps/rest-api/onde-cloud")
82
+ );
83
+ assert_eq!(config.binary_name.as_deref(), Some("onde-cloud"));
84
+ assert_eq!(
85
+ config.build_target.as_deref(),
86
+ Some("x86_64-unknown-linux-gnu")
87
+ );
88
+ assert_eq!(config.port, Some(8090));
89
+ assert_eq!(config.runner, Runner::NodeJs);
90
+ }
91
+
92
+ #[test]
93
+ fn deserializes_minimal_deploy_config() {
94
+ let json = r#"{
95
+ "id": "abc-123",
96
+ "name": "my-app",
97
+ "runner": 4,
98
+ "deployment_method": 0
99
+ }"#;
100
+
101
+ let config: DeployConfig = serde_json::from_str(json).expect("should deserialize");
102
+ assert_eq!(config.id, "abc-123");
103
+ assert_eq!(config.runner, Runner::Rust);
104
+ assert!(config.kind.is_none());
105
+ assert!(config.remote_path.is_none());
106
+ assert!(config.port.is_none());
107
+ }
108
+}
crates/smbcloud-model/src/frontend_app.rs
+24
@@ -77,21 +77,45 @@ pub struct FrontendApp {
77
// These are not persisted in the database; they are stored in the local
78
// .smbcloud config file alongside the FrontendApp record.
79
/// Deployment kind, e.g. "vite-spa". Absent for server-side runners.
80
+ #[serde(default)]
81
pub kind: Option<String>,
82
/// Local source directory to build from, e.g. "frontend/my-app".
83
+ #[serde(default)]
84
pub source: Option<String>,
85
/// Build output directory relative to `source`, e.g. "dist".
86
+ #[serde(default)]
87
pub output: Option<String>,
88
/// Package manager to use for the build step, e.g. "pnpm".
89
+ #[serde(default)]
90
pub package_manager: Option<String>,
91
/// PM2 process name to restart after a nextjs-ssr deploy.
92
+ #[serde(default)]
93
pub pm2_app: Option<String>,
94
/// Path to a shared lib directory to rsync before deploying.
95
+ #[serde(default)]
96
pub shared_lib: Option<String>,
97
/// SSH command to run on the server after rsyncing the shared lib.
98
+ #[serde(default)]
99
pub compile_cmd: Option<String>,
100
/// Remote destination path on the server.
101
+ #[serde(default)]
102
pub path: Option<String>,
103
+ #[serde(default)]
104
+ pub remote_path: Option<String>,
105
+ #[serde(default)]
106
+ pub output_path: Option<String>,
107
+ #[serde(default)]
108
+ pub build_command: Option<String>,
109
+ #[serde(default)]
110
+ pub install_command: Option<String>,
111
+ #[serde(default)]
112
+ pub binary_name: Option<String>,
113
+ #[serde(default)]
114
+ pub build_target: Option<String>,
115
+ #[serde(default)]
116
+ pub port: Option<u16>,
117
+ #[serde(default)]
118
+ pub shared_lib_path: Option<String>,
119
120
pub created_at: DateTime<Utc>,
121
pub updated_at: DateTime<Utc>,
crates/smbcloud-model/src/lib.rs
+1
@@ -1,5 +1,6 @@
1
pub mod account;
2
pub mod app_auth;
3
+pub mod deploy_config;
4
pub mod error_codes;
5
pub mod forgot;
6
pub mod frontend_app;
crates/smbcloud-model/src/project.rs
+3
@@ -88,6 +88,9 @@ pub struct Project {
88
/// SSH command to run on the server after rsyncing the shared lib,
89
/// e.g. "cd ~/lib/gems/gem_error_codes && rbenv local 3.4.2 && bundle install && bundle exec rake compile".
90
pub compile_cmd: Option<String>,
91
+ /// Install command override, e.g. "pnpm install --frozen-lockfile".
92
+ #[serde(default)]
93
+ pub install_command: Option<String>,
94
/// Rust binary filename to upload and restart, e.g. "onde-cloud".
95
/// When absent, the CLI falls back to the Cargo package name.
96
pub binary_name: Option<String>,
crates/smbcloud-networking-project/src/crud_frontend_app_deploy_config.rs
new
+21
@@ -0,0 +1,21 @@
1
+use {
2
+ crate::url_builder::build_frontend_app_deploy_config_url,
3
+ reqwest::Client,
4
+ smbcloud_model::{deploy_config::DeployConfig, error_codes::ErrorResponse},
5
+ smbcloud_network::{environment::Environment, network::request},
6
+ smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient},
7
+};
8
+
9
+pub async fn get_deploy_config(
10
+ environment: Environment,
11
+ client: (&SmbClient, &str),
12
+ access_token: String,
13
+ frontend_app_id: &str,
14
+) -> Result<DeployConfig, ErrorResponse> {
15
+ let url = build_frontend_app_deploy_config_url(environment, client, frontend_app_id);
16
+ let builder = Client::new()
17
+ .get(url)
18
+ .header("Authorization", access_token)
19
+ .header("User-agent", SMB_USER_AGENT);
20
+ request(builder).await
21
+}
crates/smbcloud-networking-project/src/lib.rs
+1
@@ -1,4 +1,5 @@
1
pub mod crud_frontend_app_create;
2
+pub mod crud_frontend_app_deploy_config;
3
pub mod crud_frontend_app_read;
4
pub mod crud_project_create;
5
pub mod crud_project_delete;
crates/smbcloud-networking-project/src/url_builder.rs
+12
@@ -9,6 +9,18 @@ pub(crate) fn build_frontend_apps_url(env: Environment, client: (&SmbClient, &st
9
url_builder.build()
10
}
11
12
+pub(crate) fn build_frontend_app_deploy_config_url(
13
+ env: Environment,
14
+ client: (&SmbClient, &str),
15
+ frontend_app_id: &str,
16
+) -> String {
17
+ let mut url_builder = smb_base_url_builder(env, client);
18
+ url_builder.add_route("v1/frontend_apps");
19
+ url_builder.add_route(frontend_app_id);
20
+ url_builder.add_route("deploy_config");
21
+ url_builder.build()
22
+}
23
+
24
// Private functions
25
26
pub(crate) fn build_project_url(env: Environment, client: (&SmbClient, &str)) -> String {