@smbcloud / cli / commits / c918dc9

Decouple build and ship

Seto Elkahfi committed Jul 1, 2026 at 22:25 UTC c918dc98b5e59e2aefa8268cb7a421e606175799
6 files changed +176 -60
.agents/plans/deploy-modularization.md new
+78
@@ -0,0 +1,78 @@
1 +# Plan: decouple build/deploy from the CLI into `smbcloud-deploy`
2 +
3 +Status: in progress. Stages 0 to 3 (vite_spa slice) shipped. Remaining work below
4 +is the **next milestone**.
5 +
6 +## Goal
7 +
8 +Move the deploy logic out of `crates/cli` into a reusable `crates/smbcloud-deploy`
9 +engine so the CLI, the CI action, and (later) the server-side git receiver all
10 +drive the same code. They differ only in how they report progress and how they
11 +authenticate.
12 +
13 +Two inversions make it reusable:
14 +- `Reporter` replaces direct `spinners` / `dialoguer` / `println!`, so the engine
15 + never owns the terminal. The CLI supplies `SpinnerReporter`.
16 +- Auth is passed in (a token / credentials); the engine never reads `~/.smb` or
17 + prompts for login.
18 +
19 +Decisions already made:
20 +- Networking crates (`smbcloud-networking*`) stay direct deps of the engine.
21 + Only reporting and auth are inverted.
22 +- `git.rs` (SSH-git remote setup) stays in the CLI for now. It is the SSH-git
23 + path slated for replacement by git-smart-HTTP, so decoupling it is wasted work.
24 +- Interactive setup (`setup*.rs`, `process_migrate.rs`) stays in the CLI. It is
25 + UX, not engine.
26 +
27 +## Done
28 +
29 +- **Stage 0** engine crate: `error::DeployError`, `report::{Reporter, NoopReporter}`,
30 + `runner::detect_runner`.
31 +- **Stage 1** CLI depends on the engine; `ui/reporter.rs::SpinnerReporter`;
32 + `detect_runner` rewired; old `deploy/detect_runner.rs` removed.
33 +- **Stage 2** transport: `transport::{Transport, RsyncTransport}` + moved
34 + `known_hosts`. CLI helper `deploy::rsync_transport(config, runner, user_id)`
35 + resolves the local `~/.ssh` identity and remote path.
36 +- **Stage 3 (vite_spa slice)** build split: `build::{BuildStrategy, BuildArtifact,
37 + ViteSpaBuild}`. `process_deploy_vite_spa.rs` now calls the engine for the build
38 + and reuses one `SpinnerReporter` for build + transport.
39 +
40 +## Next milestone
41 +
42 +### 1. Shared remote-command step
43 +
44 +Several strategies do the same "SSH in and restart the service" step, inlined
45 +per file. Extract it into the engine first (e.g. `transport::RemoteCommand` or a
46 +`remote` module) so the strategies can share it. Model it behind the same pinned
47 +host-key SSH used by `RsyncTransport`.
48 +
49 +### 2. Remaining `BuildStrategy` extractions (one per pass, compile-green each)
50 +
51 +Do smallest first. Each: lift build mechanics into a `BuildStrategy`, swap
52 +`spinners` + `stop_and_persist` for `Reporter`, take auth as a param, route the
53 +remote-restart step through step 1.
54 +
55 +| File | Lines | Shape |
56 +| --- | --- | --- |
57 +| `process_deploy_rails.rs` | ~395 | rsync shared lib, SSH compile native gem, git force-push |
58 +| `process_deploy_rust.rs` | ~641 | cross-build Linux binary, rsync, SSH restart |
59 +| `process_deploy_nextjs_ssr.rs` | ~700 | install, build, upload `.next/standalone`, SSH restart |
60 +| `process_deploy_swift.rs` | ~832 | Docker Linux build, rsync binary + Resources, SSH restart |
61 +
62 +Follow `ViteSpaBuild` as the template. Keep the deployment-record API calls
63 +(`create_deployment` / `update`) in the CLI router, not the engine.
64 +
65 +### 3. Stage 4: router + retire the CommandResult wart
66 +
67 +- Flip `process_deploy.rs`'s router to call an `engine::deploy(...)` orchestrator.
68 +- Retire `CommandResult`'s `spinner: Spinner` field (the "return a live spinner,
69 + caller stops it" pattern) now that the engine reports the whole flow through
70 + `Reporter`. `CommandResult` has ~50 construction sites, so this is its own pass:
71 + either make `spinner` optional or drop it and have commands return plain data.
72 +
73 +## Reference
74 +
75 +- Spec: `smbcloud` repo `.agents/specs/smbcloud-deploy.md` (product direction),
76 + `smbcloud-actions.md` (CI wrapper). Direction is git-smart-HTTP, local-first
77 + build, drop SSH transport later.
78 +- Engine crate: `crates/smbcloud-deploy/`.
crates/cli/src/deploy/process_deploy_vite_spa.rs
+11 -57
@@ -2,20 +2,19 @@ use {
2 crate::{
3 cli::CommandResult,
4 client,
5 - ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
5 + ui::{succeed_message, succeed_symbol},
6 },
7 - anyhow::{anyhow, Result},
7 + anyhow::Result,
8 chrono::Utc,
9 smbcloud_auth::me::me,
10 + smbcloud_deploy::{BuildStrategy, Transport},
11 smbcloud_model::project::{DeploymentPayload, DeploymentStatus},
12 smbcloud_network::environment::Environment,
12 - smbcloud_deploy::Transport,
13 smbcloud_networking_project::{
14 crud_project_deployment_create::create_deployment, crud_project_deployment_update::update,
15 },
16 smbcloud_utils::config::Config,
17 spinners::{Spinner, Spinners},
18 - std::process::Command,
18 };
19
20 pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result<CommandResult> {
@@ -30,57 +29,15 @@ pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result
29 let access_token = crate::token::get_smb_token::get_smb_token(env)?;
30 let user = me(env, client(), &access_token).await?;
31
33 - // ── Step 1: build locally ────────────────────────────────────────────────
32 + // ── Step 1: build locally (engine BuildStrategy) ─────────────────────────
33
35 - let mut build_spinner = Spinner::new(
36 - Spinners::SimpleDotsScrolling,
37 - succeed_message(&format!(
38 - "Building {} with {}…",
39 - project_path, package_manager
40 - )),
41 - );
42 -
43 - // Validate the directory exists before spawning — current_dir silently
44 - // produces os error 2 at status() time if the path doesn't exist, which
45 - // gives a confusing "Failed to spawn" message instead of the real cause.
46 - let project_dir = std::path::Path::new(project_path);
47 - if !project_dir.exists() {
48 - build_spinner.stop_and_persist(
49 - &fail_symbol(),
50 - fail_message(&format!(
51 - "Project path '{}' does not exist. Check the 'path' field in .smb/config.toml.",
52 - project_path
53 - )),
54 - );
55 - return Err(anyhow!(fail_message(&format!(
56 - "Project path '{}' does not exist.",
57 - project_path
58 - ))));
59 - }
60 -
61 - let build_status = Command::new(package_manager)
62 - .arg("build")
63 - .current_dir(project_path)
64 - .status()
65 - .map_err(|error| {
66 - anyhow!(fail_message(&format!(
67 - "Failed to spawn '{}': {}",
68 - package_manager, error
69 - )))
70 - })?;
71 -
72 - if !build_status.success() {
73 - build_spinner.stop_and_persist(
74 - &fail_symbol(),
75 - fail_message("Build failed. See output above."),
76 - );
77 - return Err(anyhow!(fail_message(&format!(
78 - "'{} build' exited with status {}",
79 - package_manager, build_status,
80 - ))));
34 + let reporter = crate::ui::reporter::SpinnerReporter::new();
35 + let artifact = smbcloud_deploy::ViteSpaBuild {
36 + project_path: project_path.to_string(),
37 + output_dir: output_dir.to_string(),
38 + package_manager: package_manager.to_string(),
39 }
82 -
83 - build_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Build complete."));
40 + .build(&reporter)?;
41
42 // ── Step 2: record deployment as Started ─────────────────────────────────
43 //
@@ -113,12 +70,9 @@ pub async fn process_deploy_vite_spa(env: Environment, config: Config) -> Result
70 // pinned known-hosts and the user's smbCloud SSH key — exactly the same
71 // transport used for static site deployments.
72
116 - let local_dist = format!("{}/{}", project_path, output_dir);
73 let runner = config.project.runner;
118 -
74 let transport = crate::deploy::rsync_transport(&config, &runner, user.id)?;
120 - let reporter = crate::ui::reporter::SpinnerReporter::new();
121 - match transport.ship(std::path::Path::new(&local_dist), &reporter) {
75 + match transport.ship(&artifact.source_dir, &reporter) {
76 Ok(()) => {}
77 Err(error) => {
78 if let Some(ref deployment) = created_deployment {
crates/cli/src/ui/reporter.rs
+4 -2
@@ -43,8 +43,10 @@ impl Default for SpinnerReporter {
43 impl Reporter for SpinnerReporter {
44 fn step_start(&self, msg: &str) {
45 // Starting a new step supersedes any spinner left running.
46 - *self.current.lock().unwrap() =
47 - Some(Spinner::new(Spinners::SimpleDotsScrolling, succeed_message(msg)));
46 + *self.current.lock().unwrap() = Some(Spinner::new(
47 + Spinners::SimpleDotsScrolling,
48 + succeed_message(msg),
49 + ));
50 }
51
52 fn step_done(&self, msg: &str) {
crates/smbcloud-deploy/src/build.rs new
+78
@@ -0,0 +1,78 @@
1 +//! Local build strategies.
2 +//!
3 +//! A [`BuildStrategy`] runs a project's build locally and reports where the
4 +//! shippable output is, as a [`BuildArtifact`]. Transport (see
5 +//! [`crate::transport`]) takes it from there. Keeping "build" and "ship"
6 +//! separate is what lets the same engine drive a local build, a CI build, or a
7 +//! server-side build without the strategies knowing which.
8 +
9 +use crate::{error::DeployError, report::Reporter};
10 +use anyhow::anyhow;
11 +use std::{
12 + path::{Path, PathBuf},
13 + process::Command,
14 +};
15 +
16 +/// What a build produced and where to ship it from.
17 +pub struct BuildArtifact {
18 + /// Local directory whose contents should be shipped.
19 + pub source_dir: PathBuf,
20 +}
21 +
22 +/// Builds a project locally into a [`BuildArtifact`].
23 +pub trait BuildStrategy {
24 + fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError>;
25 +}
26 +
27 +/// A Vite / SPA build: run `<package_manager> build` in the project directory
28 +/// and ship its output directory (e.g. `dist`).
29 +pub struct ViteSpaBuild {
30 + pub project_path: String,
31 + pub output_dir: String,
32 + pub package_manager: String,
33 +}
34 +
35 +impl BuildStrategy for ViteSpaBuild {
36 + fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError> {
37 + reporter.step_start(&format!(
38 + "Building {} with {}…",
39 + self.project_path, self.package_manager
40 + ));
41 +
42 + // Validate up front: `current_dir` on a missing path only surfaces as a
43 + // confusing spawn error at status() time.
44 + let project_dir = Path::new(&self.project_path);
45 + if !project_dir.exists() {
46 + reporter.step_fail(&format!(
47 + "Project path '{}' does not exist.",
48 + self.project_path
49 + ));
50 + return Err(DeployError::Other(anyhow!(
51 + "Project path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
52 + self.project_path
53 + )));
54 + }
55 +
56 + let status = Command::new(&self.package_manager)
57 + .arg("build")
58 + .current_dir(&self.project_path)
59 + .status()
60 + .map_err(|e| {
61 + reporter.step_fail(&format!("Failed to spawn '{}': {e}", self.package_manager));
62 + DeployError::Other(anyhow!("Failed to spawn '{}': {e}", self.package_manager))
63 + })?;
64 +
65 + if !status.success() {
66 + reporter.step_fail("Build failed. See output above.");
67 + return Err(DeployError::Other(anyhow!(
68 + "'{} build' exited with status {status}",
69 + self.package_manager
70 + )));
71 + }
72 +
73 + reporter.step_done("Build complete.");
74 + Ok(BuildArtifact {
75 + source_dir: project_dir.join(&self.output_dir),
76 + })
77 + }
78 +}
crates/smbcloud-deploy/src/lib.rs
+2
@@ -15,12 +15,14 @@
15 //! `.smb/config.toml`) stays in the front-end. The engine takes a resolved
16 //! config, or returns [`DeployError::NeedsSetup`] for the caller to handle.
17
18 +pub mod build;
19 pub mod error;
20 pub mod known_hosts;
21 pub mod report;
22 pub mod runner;
23 pub mod transport;
24
25 +pub use build::{BuildArtifact, BuildStrategy, ViteSpaBuild};
26 pub use error::DeployError;
27 pub use report::{NoopReporter, Reporter};
28 pub use runner::detect_runner;
crates/smbcloud-deploy/src/runner.rs
+3 -1
@@ -46,7 +46,9 @@ fn label_for(runner: Runner) -> &'static str {
46 Runner::Swift if Path::new("Package.swift").exists() => {
47 "Swift runner with Vapor app detected"
48 }
49 - Runner::Rust if Path::new("Cargo.toml").exists() => "Rust runner with Cargo project detected",
49 + Runner::Rust if Path::new("Cargo.toml").exists() => {
50 + "Rust runner with Cargo project detected"
51 + }
52 _ => "Runner detected",
53 }
54 }