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
}