main
claude/smbcloud-github-app-k9641p
development
feature/apps
feature/build-release
feature/ci-docs
feature/ci-ruby-gems
feature/cicd
feature/completion
feature/dist-nuget
feature/fix-ci-sdk-pypi-rubygem
feature/fix-clippy-on-stable-channel
feature/fix-deployment-script
feature/github-email
feature/gresiq-v1
feature/project-new-structure
feature/rsync-deploy
feature/ruby-runner
feature/sdk-npm
feature/sdk-workflows
feature/smbcloud-cli-release-npm
feature/smbcloud-deploy
feature/wasm
main
release/v0.7.4
| 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 | } |
| 79 | |
| 80 | /// Which SvelteKit adapter a project declares in its `package.json`. |
| 81 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 82 | pub enum SvelteKitAdapter { |
| 83 | /// `@sveltejs/adapter-static`: prerendered pages plus an optional SPA |
| 84 | /// fallback, served as plain files by nginx. |
| 85 | Static, |
| 86 | /// Any other `@sveltejs/adapter-*` package, e.g. `adapter-node` or |
| 87 | /// `adapter-auto`. |
| 88 | Other(String), |
| 89 | /// `@sveltejs/kit` is present but no adapter package is. |
| 90 | Missing, |
| 91 | } |
| 92 | |
| 93 | impl SvelteKitAdapter { |
| 94 | /// Reads the adapter from `package.json` contents. Returns `None` when the |
| 95 | /// manifest doesn't depend on `@sveltejs/kit` at all. |
| 96 | pub fn from_package_json(contents: &str) -> Result<Option<Self>, serde_json::Error> { |
| 97 | let manifest: serde_json::Value = serde_json::from_str(contents)?; |
| 98 | let dependency_names: Vec<&str> = ["dependencies", "devDependencies"] |
| 99 | .iter() |
| 100 | .filter_map(|section| manifest.get(section).and_then(|value| value.as_object())) |
| 101 | .flat_map(|section| section.keys().map(String::as_str)) |
| 102 | .collect(); |
| 103 | |
| 104 | if !dependency_names.contains(&"@sveltejs/kit") { |
| 105 | return Ok(None); |
| 106 | } |
| 107 | |
| 108 | let adapters: Vec<&str> = dependency_names |
| 109 | .iter() |
| 110 | .copied() |
| 111 | .filter(|name| name.starts_with("@sveltejs/adapter-")) |
| 112 | .collect(); |
| 113 | |
| 114 | // A project can list several adapters (e.g. one per target); static |
| 115 | // wins because it's the only one this strategy can ship. |
| 116 | if adapters.contains(&"@sveltejs/adapter-static") { |
| 117 | return Ok(Some(SvelteKitAdapter::Static)); |
| 118 | } |
| 119 | Ok(Some(match adapters.first() { |
| 120 | Some(name) => SvelteKitAdapter::Other((*name).to_string()), |
| 121 | None => SvelteKitAdapter::Missing, |
| 122 | })) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /// A SvelteKit build using `@sveltejs/adapter-static`: install dependencies, |
| 127 | /// run `<package_manager> run build`, and ship the adapter's output directory |
| 128 | /// (`build` by default). |
| 129 | /// |
| 130 | /// Server-rendered adapters are rejected up front. Their output needs a Node |
| 131 | /// process on the server, which this static path doesn't manage. |
| 132 | pub struct SvelteKitBuild { |
| 133 | pub project_path: String, |
| 134 | pub output_dir: String, |
| 135 | pub package_manager: String, |
| 136 | } |
| 137 | |
| 138 | impl SvelteKitBuild { |
| 139 | fn fail(&self, reporter: &dyn Reporter, message: String) -> DeployError { |
| 140 | reporter.step_fail(&message); |
| 141 | DeployError::Other(anyhow!(message)) |
| 142 | } |
| 143 | |
| 144 | fn run(&self, reporter: &dyn Reporter, arguments: &[&str]) -> Result<(), DeployError> { |
| 145 | let display = format!("{} {}", self.package_manager, arguments.join(" ")); |
| 146 | // Inherited stdio keeps the child on the user's terminal, so package |
| 147 | // manager prompts (e.g. pnpm's modules purge) can still be answered. |
| 148 | let status = Command::new(&self.package_manager) |
| 149 | .args(arguments) |
| 150 | .current_dir(&self.project_path) |
| 151 | .status() |
| 152 | .map_err(|error| { |
| 153 | self.fail( |
| 154 | reporter, |
| 155 | format!("Failed to spawn '{}': {error}", self.package_manager), |
| 156 | ) |
| 157 | })?; |
| 158 | if !status.success() { |
| 159 | return Err(self.fail(reporter, format!("'{display}' exited with status {status}"))); |
| 160 | } |
| 161 | Ok(()) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | impl BuildStrategy for SvelteKitBuild { |
| 166 | fn build(&self, reporter: &dyn Reporter) -> Result<BuildArtifact, DeployError> { |
| 167 | reporter.step_start(&format!( |
| 168 | "Building SvelteKit app {} with {}…", |
| 169 | self.project_path, self.package_manager |
| 170 | )); |
| 171 | |
| 172 | let project_dir = Path::new(&self.project_path); |
| 173 | if !project_dir.exists() { |
| 174 | return Err(self.fail( |
| 175 | reporter, |
| 176 | format!( |
| 177 | "Project path '{}' does not exist. Check the 'source' field in .smb/config.toml.", |
| 178 | self.project_path |
| 179 | ), |
| 180 | )); |
| 181 | } |
| 182 | |
| 183 | let manifest_path = project_dir.join("package.json"); |
| 184 | let manifest = std::fs::read_to_string(&manifest_path).map_err(|error| { |
| 185 | self.fail( |
| 186 | reporter, |
| 187 | format!("Could not read {}: {error}", manifest_path.display()), |
| 188 | ) |
| 189 | })?; |
| 190 | let adapter = SvelteKitAdapter::from_package_json(&manifest).map_err(|error| { |
| 191 | self.fail( |
| 192 | reporter, |
| 193 | format!("Could not parse {}: {error}", manifest_path.display()), |
| 194 | ) |
| 195 | })?; |
| 196 | |
| 197 | match adapter { |
| 198 | Some(SvelteKitAdapter::Static) => {} |
| 199 | None => { |
| 200 | return Err(self.fail( |
| 201 | reporter, |
| 202 | format!( |
| 203 | "'{}' does not depend on @sveltejs/kit. For a plain Svelte + Vite app, use kind = \"vite-spa\".", |
| 204 | self.project_path |
| 205 | ), |
| 206 | )) |
| 207 | } |
| 208 | Some(SvelteKitAdapter::Missing) => { |
| 209 | return Err(self.fail( |
| 210 | reporter, |
| 211 | "No SvelteKit adapter found. Add @sveltejs/adapter-static and use it in svelte.config.js.".to_string(), |
| 212 | )) |
| 213 | } |
| 214 | Some(SvelteKitAdapter::Other(name)) => { |
| 215 | return Err(self.fail( |
| 216 | reporter, |
| 217 | format!( |
| 218 | "{name} is not supported yet. smbCloud deploys SvelteKit with @sveltejs/adapter-static; switch svelte.config.js to it." |
| 219 | ), |
| 220 | )) |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | self.run(reporter, &["install"])?; |
| 225 | self.run(reporter, &["run", "build"])?; |
| 226 | |
| 227 | let source_dir = project_dir.join(&self.output_dir); |
| 228 | if !has_html_entry(&source_dir) { |
| 229 | return Err(self.fail( |
| 230 | reporter, |
| 231 | format!( |
| 232 | "{} has no HTML entry point. Prerender at least one page, or set `fallback` in adapter-static for a single-page app.", |
| 233 | source_dir.display() |
| 234 | ), |
| 235 | )); |
| 236 | } |
| 237 | |
| 238 | reporter.step_done("Build complete."); |
| 239 | Ok(BuildArtifact { source_dir }) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | /// Whether `directory` holds at least one top-level `.html` file: a |
| 244 | /// prerendered `index.html` or an adapter-static fallback such as `200.html`. |
| 245 | fn has_html_entry(directory: &Path) -> bool { |
| 246 | let Ok(entries) = std::fs::read_dir(directory) else { |
| 247 | return false; |
| 248 | }; |
| 249 | entries.flatten().any(|entry| { |
| 250 | let path: PathBuf = entry.path(); |
| 251 | path.is_file() |
| 252 | && path |
| 253 | .extension() |
| 254 | .is_some_and(|extension| extension == "html") |
| 255 | }) |
| 256 | } |
| 257 | |
| 258 | #[cfg(test)] |
| 259 | mod tests { |
| 260 | use super::*; |
| 261 | |
| 262 | #[test] |
| 263 | fn detects_static_adapter_in_dev_dependencies() { |
| 264 | let manifest = |
| 265 | r#"{"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-static":"^3"}}"#; |
| 266 | assert_eq!( |
| 267 | SvelteKitAdapter::from_package_json(manifest).unwrap(), |
| 268 | Some(SvelteKitAdapter::Static) |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn prefers_static_when_several_adapters_are_listed() { |
| 274 | let manifest = r#"{"dependencies":{"@sveltejs/adapter-node":"^5"},"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-static":"^3"}}"#; |
| 275 | assert_eq!( |
| 276 | SvelteKitAdapter::from_package_json(manifest).unwrap(), |
| 277 | Some(SvelteKitAdapter::Static) |
| 278 | ); |
| 279 | } |
| 280 | |
| 281 | #[test] |
| 282 | fn reports_other_adapters_by_name() { |
| 283 | let manifest = |
| 284 | r#"{"devDependencies":{"@sveltejs/kit":"^2","@sveltejs/adapter-auto":"^3"}}"#; |
| 285 | assert_eq!( |
| 286 | SvelteKitAdapter::from_package_json(manifest).unwrap(), |
| 287 | Some(SvelteKitAdapter::Other( |
| 288 | "@sveltejs/adapter-auto".to_string() |
| 289 | )) |
| 290 | ); |
| 291 | } |
| 292 | |
| 293 | #[test] |
| 294 | fn reports_missing_adapter() { |
| 295 | let manifest = r#"{"devDependencies":{"@sveltejs/kit":"^2"}}"#; |
| 296 | assert_eq!( |
| 297 | SvelteKitAdapter::from_package_json(manifest).unwrap(), |
| 298 | Some(SvelteKitAdapter::Missing) |
| 299 | ); |
| 300 | } |
| 301 | |
| 302 | #[test] |
| 303 | fn plain_svelte_is_not_sveltekit() { |
| 304 | let manifest = r#"{"devDependencies":{"svelte":"^5","vite":"^5"}}"#; |
| 305 | assert_eq!(SvelteKitAdapter::from_package_json(manifest).unwrap(), None); |
| 306 | } |
| 307 | |
| 308 | #[test] |
| 309 | fn invalid_manifest_is_an_error() { |
| 310 | assert!(SvelteKitAdapter::from_package_json("{").is_err()); |
| 311 | } |
| 312 | |
| 313 | #[test] |
| 314 | fn html_entry_accepts_spa_fallback() { |
| 315 | let directory = tempfile::tempdir().unwrap(); |
| 316 | std::fs::write(directory.path().join("200.html"), "<html></html>").unwrap(); |
| 317 | assert!(has_html_entry(directory.path())); |
| 318 | } |
| 319 | |
| 320 | #[test] |
| 321 | fn html_entry_rejects_missing_or_asset_only_output() { |
| 322 | let directory = tempfile::tempdir().unwrap(); |
| 323 | assert!(!has_html_entry(&directory.path().join("build"))); |
| 324 | std::fs::create_dir(directory.path().join("_app")).unwrap(); |
| 325 | std::fs::write(directory.path().join("robots.txt"), "").unwrap(); |
| 326 | assert!(!has_html_entry(directory.path())); |
| 327 | } |
| 328 | } |