@smbcloud / cli / commits / 61e1950

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 {