Add an astro-ssr deploy kind
Astro apps using @astrojs/node could only deploy through the git-push NodeJs runner, which pushes the whole repository and cannot build a monorepo sub-folder. kind = "astro-ssr" follows the nextjs-ssr flow instead: build locally, rsync dist/ and the package manifest, then install production dependencies on the server and restart a systemctl --user service named by pm2_app. The server installs dependencies itself because the standalone server leaves them out of dist/server and a macOS node_modules does not run on a Linux host. Environment and secrets stay with the service unit. restart_stanza and mark_failed in the nextjs-ssr module are now shared. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
keypair34 committed
Oct 26, 2026 at 21:30 UTC
03811e6b21f19721f404094eaca28dde2c3cbe3e
7 files changed
+491
-5
.agents/skills/smbcloud-deploy-monorepo/SKILL.md
+11
-1
@@ -150,7 +150,7 @@ updated_at = "2025-09-20T16:33:05.154Z"
150
| `source` | yes | local path to the sub-project directory, relative to the monorepo root |
151
| `path` | depends | remote directory on the server, relative to `~/`, required for `nextjs-ssr`, `vite-spa`, and `sveltekit` |
152
| `runner` | yes | server tier: `0` (NodeJs), `1` (Static), `2` (Ruby), `3` (Swift) |
153
-| `kind` | depends | deploy strategy: `"nextjs-ssr"`, `"vite-spa"`, `"sveltekit"`, `"rails"`, or omitted for generic deploy |
153
+| `kind` | depends | deploy strategy: `"nextjs-ssr"`, `"astro-ssr"`, `"vite-spa"`, `"sveltekit"`, `"rails"`, or omitted for generic deploy |
154
| `package_manager` | depends | `"pnpm"` or `"npm"`, used by `nextjs-ssr`, `vite-spa`, and `sveltekit`; defaults to `"pnpm"` |
155
| `pm2_app` | depends | PM2 process name — required for `nextjs-ssr` |
156
| `port` | depends | runtime port — required for `nextjs-ssr`, defaults to `3000` if omitted |
@@ -199,6 +199,16 @@ Local build, rsync three directories, then SSH delete + fresh PM2 start (prefer
199
200
Required fields: `kind`, `source`, `path`, `pm2_app`, `package_manager`, `port`
201
202
+### Strategy: `astro-ssr`
203
+
204
+For an Astro app built with the `@astrojs/node` adapter in `mode: 'standalone'`. `output: 'static'` with a few `prerender = false` routes works too, as long as `dist/server/entry.mjs` is produced.
205
+
206
+The CLI installs and builds locally, rsyncs `dist/` (mirrored with `--delete`) plus `package.json`, the lockfile and `pnpm-workspace.yaml` to `path`, then over SSH installs production dependencies from the lockfile (`pnpm install --prod --frozen-lockfile`, or `npm ci --omit=dev`) and restarts `<pm2_app>.service` with `systemctl --user`. The server installs the dependencies itself because a macOS `node_modules` does not run on a Linux server.
207
+
208
+The unit is created on the server beforehand and owns the app's environment (`PORT`, `HOST` and any secrets via `EnvironmentFile`); nothing from the developer's environment is uploaded. The deploy fails loudly when the unit does not exist. `pm2_app` keeps its name but is the service name here, and there is no pm2 path.
209
+
210
+Required fields: `kind`, `source`, `path`, `pm2_app`. Optional: `package_manager` (`pnpm` default, or `npm`).
211
+
212
### Strategy: `vite-spa`
213
214
Local build, rsync the output directory. No PM2 — Nginx serves static files directly.
crates/cli/src/cloud-deploy/mod.rs
+1
@@ -1,6 +1,7 @@
1
pub mod config;
2
mod git;
3
pub mod process_deploy;
4
+pub mod process_deploy_astro_ssr;
5
pub mod process_deploy_nextjs_ssr;
6
pub mod process_deploy_rails;
7
pub mod process_deploy_rust;
crates/cli/src/cloud-deploy/process_deploy.rs
+6
@@ -6,6 +6,7 @@ use {
6
deploy::{
7
config::{check_project, credentials, get_config, overlay_server_config},
8
git::remote_deployment_setup,
9
+ process_deploy_astro_ssr::process_deploy_astro_ssr,
10
process_deploy_nextjs_ssr::process_deploy_nextjs_ssr,
11
process_deploy_rails::process_deploy_rails,
12
process_deploy_rust::process_deploy_rust,
@@ -154,6 +155,11 @@ pub async fn process_deploy(
155
return process_deploy_nextjs_ssr(env, config).await;
156
}
157
158
+ // Route Astro SSR projects (@astrojs/node standalone): build locally, rsync dist/, SSH install + systemd restart.
159
+ if config.project.kind.as_deref() == Some("astro-ssr") {
160
+ return process_deploy_astro_ssr(env, config).await;
161
+ }
162
+
163
// Route Rails projects: rsync shared lib, SSH compile native gem, git force-push sub-project.
164
if config.project.kind.as_deref() == Some("rails") {
165
return process_deploy_rails(env, config).await;
crates/cli/src/cloud-deploy/process_deploy_astro_ssr.rs
new
+469
@@ -0,0 +1,469 @@
1
+use {
2
+ super::{
3
+ process_deploy_nextjs_ssr::{mark_failed, restart_stanza},
4
+ resolve_rsync_host,
5
+ },
6
+ crate::{
7
+ cli::CommandResult,
8
+ client,
9
+ deploy::known_hosts,
10
+ ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
11
+ },
12
+ anyhow::{anyhow, Result},
13
+ chrono::Utc,
14
+ smbcloud_auth::me::me,
15
+ smbcloud_model::project::{DeploymentPayload, DeploymentStatus},
16
+ smbcloud_network::environment::Environment,
17
+ smbcloud_networking_project::{
18
+ crud_project_deployment_create::create_deployment, crud_project_deployment_update::update,
19
+ },
20
+ smbcloud_utils::config::Config,
21
+ spinners::{Spinner, Spinners},
22
+ std::{
23
+ io::Write,
24
+ path::Path,
25
+ process::{Command, Stdio},
26
+ },
27
+ tempfile::NamedTempFile,
28
+};
29
+
30
+/// How the server installs production dependencies for a package manager, and
31
+/// the lockfile that install needs.
32
+struct PackageManagerPlan {
33
+ lockfile: &'static str,
34
+ install_command: &'static str,
35
+}
36
+
37
+fn package_manager_plan(package_manager: &str) -> Result<PackageManagerPlan> {
38
+ match package_manager {
39
+ "pnpm" => Ok(PackageManagerPlan {
40
+ lockfile: "pnpm-lock.yaml",
41
+ install_command: "pnpm install --prod --frozen-lockfile",
42
+ }),
43
+ "npm" => Ok(PackageManagerPlan {
44
+ lockfile: "package-lock.json",
45
+ install_command: "npm ci --omit=dev",
46
+ }),
47
+ other => Err(anyhow!(fail_message(&format!(
48
+ "Unsupported package_manager '{other}' for kind \"astro-ssr\". Expected \"pnpm\" or \"npm\"."
49
+ )))),
50
+ }
51
+}
52
+
53
+/// Shell script run on the server after the upload: install production
54
+/// dependencies next to the uploaded `dist/`, then restart the app's
55
+/// `systemctl --user` service.
56
+///
57
+/// The `@astrojs/node` standalone server leaves its runtime dependencies out of
58
+/// `dist/server`, and a Linux server cannot reuse a macOS `node_modules`, so
59
+/// the server installs them itself from the uploaded lockfile.
60
+fn remote_script(
61
+ remote_path: &str,
62
+ service: &str,
63
+ install_command: &str,
64
+ port: u16,
65
+) -> Result<String> {
66
+ let restart = restart_stanza(Some("systemd"), port)?;
67
+ Ok(format!(
68
+ r#"set -e
69
+source ~/.profile 2>/dev/null || true
70
+source ~/.bashrc 2>/dev/null || true
71
+APP_PATH="{remote_path}"
72
+PM2_APP="{service}"
73
+
74
+case "$APP_PATH" in
75
+ /*) ;;
76
+ *) APP_PATH="$HOME/$APP_PATH" ;;
77
+esac
78
+
79
+if [ ! -d "$APP_PATH" ]; then
80
+ echo "Error: $APP_PATH is not a directory."
81
+ exit 1
82
+fi
83
+
84
+cd "$APP_PATH"
85
+echo "Installing production dependencies…"
86
+{install_command}
87
+
88
+{restart}"#
89
+ ))
90
+}
91
+
92
+/// Deploys an Astro SSR app built with `@astrojs/node` in standalone mode.
93
+///
94
+/// Required config fields:
95
+/// - `kind = "astro-ssr"`
96
+/// - `path` — remote app directory on the server
97
+/// - `pm2_app` — name of the `systemctl --user` service (`<pm2_app>.service`),
98
+/// created on the server beforehand. The field keeps its `nextjs-ssr` name.
99
+///
100
+/// Optional config fields:
101
+/// - `source` — local app directory (defaults to the current directory)
102
+/// - `package_manager` — `"pnpm"` (default) or `"npm"`
103
+///
104
+/// The port is not a deploy setting: the service unit decides what the app binds.
105
+///
106
+/// Steps:
107
+/// 1. install and build locally (`dist/client` + `dist/server/entry.mjs`)
108
+/// 2. rsync `dist/`, `package.json`, the lockfile and `pnpm-workspace.yaml`
109
+/// 3. SSH: install production dependencies, restart the service
110
+///
111
+/// Environment variables (such as secrets) belong to the service on the
112
+/// server, never to the upload.
113
+pub async fn process_deploy_astro_ssr(env: Environment, config: Config) -> Result<CommandResult> {
114
+ let source = config.project.source.as_deref().unwrap_or(".");
115
+ let package_manager = config.project.package_manager.as_deref().unwrap_or("pnpm");
116
+ let plan = package_manager_plan(package_manager)?;
117
+
118
+ let remote_path = config.project.path.as_deref().ok_or_else(|| {
119
+ anyhow!(fail_message(
120
+ "path not set in .smb/config.toml (e.g. path = \"apps/web/myapp\")"
121
+ ))
122
+ })?;
123
+
124
+ let service = config.project.pm2_app.as_deref().ok_or_else(|| {
125
+ anyhow!(fail_message(
126
+ "pm2_app not set in .smb/config.toml. For kind \"astro-ssr\" it names the systemd user service (e.g. pm2_app = \"my-app\")."
127
+ ))
128
+ })?;
129
+
130
+ let source_dir = Path::new(source);
131
+ if !source_dir.exists() {
132
+ return Err(anyhow!(fail_message(&format!(
133
+ "Source path '{}' does not exist. Check the 'source' field in .smb/config.toml.",
134
+ source
135
+ ))));
136
+ }
137
+ if !source_dir.join(plan.lockfile).exists() {
138
+ return Err(anyhow!(fail_message(&format!(
139
+ "{} not found in '{}'. The server installs dependencies from it.",
140
+ plan.lockfile, source
141
+ ))));
142
+ }
143
+
144
+ let access_token = crate::token::get_smb_token::get_smb_token(env)?;
145
+ let user = me(env, client(), &access_token).await?;
146
+
147
+ // ── Step 1: install and build locally ────────────────────────────────────
148
+
149
+ let mut install_spinner = Spinner::new(
150
+ Spinners::SimpleDotsScrolling,
151
+ succeed_message(&format!("Installing dependencies in {}…", source)),
152
+ );
153
+ let install_output = Command::new(package_manager)
154
+ .args(["install", "--ignore-scripts"])
155
+ .current_dir(source)
156
+ .output()
157
+ .map_err(|e| {
158
+ anyhow!(fail_message(&format!(
159
+ "Failed to spawn '{} install': {}",
160
+ package_manager, e
161
+ )))
162
+ })?;
163
+
164
+ if !install_output.status.success() {
165
+ install_spinner.stop_and_persist(&fail_symbol(), fail_message("Install failed."));
166
+ let stderr = String::from_utf8_lossy(&install_output.stderr);
167
+ let stdout = String::from_utf8_lossy(&install_output.stdout);
168
+ let details = if stderr.trim().is_empty() {
169
+ stdout
170
+ } else {
171
+ stderr
172
+ };
173
+ if !details.trim().is_empty() {
174
+ eprintln!("{}", details.trim());
175
+ }
176
+ return Err(anyhow!(fail_message(&format!(
177
+ "'{} install --ignore-scripts' exited with status {}",
178
+ package_manager, install_output.status
179
+ ))));
180
+ }
181
+ install_spinner.stop_and_persist(
182
+ &succeed_symbol(),
183
+ succeed_message("Dependencies installed."),
184
+ );
185
+
186
+ println!();
187
+ let build_status = Command::new(package_manager)
188
+ .args(["run", "build"])
189
+ .current_dir(source)
190
+ .status()
191
+ .map_err(|e| {
192
+ anyhow!(fail_message(&format!(
193
+ "Failed to spawn '{} run build': {}",
194
+ package_manager, e
195
+ )))
196
+ })?;
197
+ println!();
198
+
199
+ if !build_status.success() {
200
+ return Err(anyhow!(fail_message(&format!(
201
+ "'{} run build' exited with status {}",
202
+ package_manager, build_status
203
+ ))));
204
+ }
205
+
206
+ // A static build, or a missing adapter, succeeds but leaves no server.
207
+ if !source_dir.join("dist/server/entry.mjs").exists() {
208
+ return Err(anyhow!(fail_message(
209
+ "dist/server/entry.mjs not found. Add the @astrojs/node adapter with mode: 'standalone' to astro.config."
210
+ )));
211
+ }
212
+ println!(
213
+ "{} {}",
214
+ succeed_symbol(),
215
+ succeed_message("Build complete.")
216
+ );
217
+
218
+ // ── Step 2: record the deployment, then upload ───────────────────────────
219
+
220
+ let deploy_ref = Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
221
+ let created_deployment = create_deployment(
222
+ env,
223
+ client(),
224
+ &access_token,
225
+ config.project.id,
226
+ DeploymentPayload {
227
+ commit_hash: deploy_ref.clone(),
228
+ status: DeploymentStatus::Started,
229
+ frontend_app_id: config.project.frontend_app_id.clone(),
230
+ },
231
+ )
232
+ .await
233
+ .ok();
234
+
235
+ let rsync_host =
236
+ resolve_rsync_host(config.project.rsync_host.as_deref(), &config.project.runner)?;
237
+
238
+ let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not determine home directory"))?;
239
+ let identity_file = home.join(".ssh").join(format!("id_{}@smbcloud", user.id));
240
+ let identity_file_str = identity_file.to_string_lossy().into_owned();
241
+
242
+ let mut known_hosts_file = NamedTempFile::new()
243
+ .map_err(|e| anyhow!("Failed to create temp known_hosts file: {}", e))?;
244
+ writeln!(known_hosts_file, "{}", known_hosts::for_host(&rsync_host))
245
+ .map_err(|e| anyhow!("Failed to write known_hosts: {}", e))?;
246
+
247
+ let ssh_options = [
248
+ "-i".to_string(),
249
+ identity_file_str.clone(),
250
+ "-o".to_string(),
251
+ "StrictHostKeyChecking=yes".to_string(),
252
+ "-o".to_string(),
253
+ format!("UserKnownHostsFile={}", known_hosts_file.path().display()),
254
+ "-o".to_string(),
255
+ "IdentitiesOnly=yes".to_string(),
256
+ "-o".to_string(),
257
+ "PasswordAuthentication=no".to_string(),
258
+ "-o".to_string(),
259
+ "BatchMode=yes".to_string(),
260
+ ];
261
+ let ssh_command = format!("ssh {}", ssh_options.join(" "));
262
+
263
+ let remote_base = format!(
264
+ "git@{}:{}",
265
+ rsync_host,
266
+ if remote_path.ends_with('/') {
267
+ remote_path.to_owned()
268
+ } else {
269
+ format!("{}/", remote_path)
270
+ }
271
+ );
272
+
273
+ // `dist/` mirrors exactly (--delete). The manifest files go to the app root
274
+ // without --delete so the server's own files (service env, logs) survive.
275
+ let manifest_files: Vec<String> = [plan.lockfile, "package.json", "pnpm-workspace.yaml"]
276
+ .iter()
277
+ .map(|name| format!("{}/{}", source.trim_end_matches('/'), name))
278
+ .filter(|path| Path::new(path).exists())
279
+ .collect();
280
+
281
+ let mut upload_spinner = Spinner::new(
282
+ Spinners::Hamburger,
283
+ succeed_message(&format!("Uploading to {}…", remote_path)),
284
+ );
285
+
286
+ let uploads = [
287
+ (
288
+ vec![
289
+ "-az".to_string(),
290
+ "--delete".to_string(),
291
+ "-e".to_string(),
292
+ ssh_command.clone(),
293
+ format!("{}/dist/", source.trim_end_matches('/')),
294
+ format!("{}dist/", remote_base),
295
+ ],
296
+ "dist/",
297
+ ),
298
+ (
299
+ [
300
+ vec!["-az".to_string(), "-e".to_string(), ssh_command.clone()],
301
+ manifest_files,
302
+ vec![remote_base.clone()],
303
+ ]
304
+ .concat(),
305
+ "package manifest",
306
+ ),
307
+ ];
308
+
309
+ for (rsync_args, label) in uploads {
310
+ let output = Command::new("rsync")
311
+ .args(&rsync_args)
312
+ .output()
313
+ .map_err(|e| anyhow!(fail_message(&format!("Failed to launch rsync: {}", e))))?;
314
+ if !output.status.success() {
315
+ upload_spinner.stop_and_persist(&fail_symbol(), fail_message("Upload failed."));
316
+ mark_failed(
317
+ &deploy_ref,
318
+ &created_deployment,
319
+ &config,
320
+ env,
321
+ &access_token,
322
+ )
323
+ .await;
324
+ return Err(anyhow!(fail_message(&format!(
325
+ "rsync of {} failed (status {}): {}",
326
+ label,
327
+ output.status.code().unwrap_or(-1),
328
+ String::from_utf8_lossy(&output.stderr).trim()
329
+ ))));
330
+ }
331
+ }
332
+ upload_spinner.stop_and_persist(&succeed_symbol(), succeed_message("Upload complete."));
333
+
334
+ // ── Step 3: install runtime dependencies and restart ─────────────────────
335
+
336
+ let script = remote_script(
337
+ remote_path,
338
+ service,
339
+ plan.install_command,
340
+ config.project.port.unwrap_or(3000),
341
+ )?;
342
+
343
+ let mut restart_spinner = Spinner::new(
344
+ Spinners::SimpleDotsScrolling,
345
+ succeed_message(&format!("Restarting {} on server…", service)),
346
+ );
347
+
348
+ let mut child = Command::new("ssh")
349
+ .args(&ssh_options)
350
+ .arg(format!("git@{}", rsync_host))
351
+ .arg("bash -s")
352
+ .stdin(Stdio::piped())
353
+ .stdout(Stdio::piped())
354
+ .stderr(Stdio::piped())
355
+ .spawn()
356
+ .map_err(|e| anyhow!(fail_message(&format!("Failed to spawn SSH: {}", e))))?;
357
+
358
+ if let Some(mut stdin) = child.stdin.take() {
359
+ stdin
360
+ .write_all(script.as_bytes())
361
+ .map_err(|e| anyhow!("Failed to write deploy script to SSH stdin: {}", e))?;
362
+ }
363
+ let ssh_output = child
364
+ .wait_with_output()
365
+ .map_err(|e| anyhow!("Failed to wait for SSH process: {}", e))?;
366
+
367
+ // The known_hosts file must outlive every rsync and SSH call above.
368
+ drop(known_hosts_file);
369
+
370
+ if !ssh_output.status.success() {
371
+ restart_spinner.stop_and_persist(&fail_symbol(), fail_message("Remote restart failed."));
372
+ let stderr = String::from_utf8_lossy(&ssh_output.stderr);
373
+ let stdout = String::from_utf8_lossy(&ssh_output.stdout);
374
+ let details = if stderr.trim().is_empty() {
375
+ stdout
376
+ } else {
377
+ stderr
378
+ };
379
+ if !details.trim().is_empty() {
380
+ eprintln!("{}", details.trim());
381
+ }
382
+ mark_failed(
383
+ &deploy_ref,
384
+ &created_deployment,
385
+ &config,
386
+ env,
387
+ &access_token,
388
+ )
389
+ .await;
390
+ return Err(anyhow!(fail_message(&format!(
391
+ "SSH deploy script exited with status {}",
392
+ ssh_output.status
393
+ ))));
394
+ }
395
+ restart_spinner.stop_and_persist(
396
+ &succeed_symbol(),
397
+ succeed_message(&format!("{} restarted.", service)),
398
+ );
399
+
400
+ if let Some(ref deployment) = created_deployment {
401
+ match update(
402
+ env,
403
+ client(),
404
+ access_token,
405
+ config.project.id,
406
+ deployment.id,
407
+ DeploymentPayload {
408
+ commit_hash: deploy_ref,
409
+ status: DeploymentStatus::Done,
410
+ frontend_app_id: config.project.frontend_app_id.clone(),
411
+ },
412
+ )
413
+ .await
414
+ {
415
+ Ok(_) => println!("App is running {}", succeed_symbol()),
416
+ Err(e) => eprintln!("Error updating deployment status to Done: {}", e),
417
+ }
418
+ }
419
+
420
+ Ok(CommandResult {
421
+ spinner: Spinner::new(Spinners::Hamburger, String::new()),
422
+ symbol: succeed_symbol(),
423
+ msg: succeed_message("Deployment complete."),
424
+ })
425
+}
426
+
427
+#[cfg(test)]
428
+mod tests {
429
+ use super::{package_manager_plan, remote_script};
430
+
431
+ #[test]
432
+ fn package_managers_map_to_a_lockfile_and_install() {
433
+ let pnpm = package_manager_plan("pnpm").expect("pnpm is supported");
434
+ assert_eq!(pnpm.lockfile, "pnpm-lock.yaml");
435
+ assert!(pnpm.install_command.contains("--frozen-lockfile"));
436
+
437
+ let npm = package_manager_plan("npm").expect("npm is supported");
438
+ assert_eq!(npm.lockfile, "package-lock.json");
439
+ }
440
+
441
+ #[test]
442
+ fn unknown_package_manager_is_rejected() {
443
+ let message = package_manager_plan("yarn")
444
+ .err()
445
+ .map(|e| e.to_string())
446
+ .unwrap_or_default();
447
+ assert!(message.contains("yarn"), "names the value: {message}");
448
+ }
449
+
450
+ #[test]
451
+ fn remote_script_installs_then_restarts_the_user_service() {
452
+ let script = remote_script(
453
+ "apps/web/example",
454
+ "example-web",
455
+ "pnpm install --prod --frozen-lockfile",
456
+ 3000,
457
+ )
458
+ .expect("script builds");
459
+
460
+ let install = script.find("pnpm install --prod").expect("installs");
461
+ let restart = script
462
+ .find("systemctl --user restart")
463
+ .expect("restarts via systemd");
464
+ assert!(install < restart, "dependencies land before the restart");
465
+ assert!(script.contains("PM2_APP=\"example-web\""));
466
+ assert!(!script.contains("pm2 "), "no pm2 fallback");
467
+ assert!(!script.contains("sudo"), "no sudo");
468
+ }
469
+}
crates/cli/src/cloud-deploy/process_deploy_nextjs_ssr.rs
+2
-2
@@ -106,7 +106,7 @@ fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
106
///
107
/// The returned snippet references the shell var `$PM2_APP` (set earlier in the
108
/// deploy script). `port` is only used by the pm2 path.
109
-fn restart_stanza(process_manager: Option<&str>, port: u16) -> Result<String> {
109
+pub(super) fn restart_stanza(process_manager: Option<&str>, port: u16) -> Result<String> {
110
let use_systemd = match process_manager.map(str::trim) {
111
None | Some("") => false,
112
Some(m) if m.eq_ignore_ascii_case("pm2") => false,
@@ -877,7 +877,7 @@ EOF
877
878
/// Mark the in-flight deployment record as Failed.
879
/// Called on any early-return error path so the dashboard reflects reality.
880
-async fn mark_failed(
880
+pub(super) async fn mark_failed(
881
deploy_ref: &str,
882
created_deployment: &Option<smbcloud_model::project::Deployment>,
883
config: &Config,
crates/cli/src/mcp.rs
+1
-1
@@ -624,7 +624,7 @@ struct DeployConfigUpdateArgs {
624
runner: Option<McpRunner>,
625
#[serde(default)]
626
deployment_method: Option<McpDeploymentMethod>,
627
- /// Deployment kind, e.g. `vite-spa`, `sveltekit`, `nextjs-ssr`, `rust`.
627
+ /// Deployment kind, e.g. `vite-spa`, `sveltekit`, `nextjs-ssr`, `astro-ssr`, `rust`.
628
#[serde(default)]
629
kind: Option<String>,
630
#[serde(default)]
crates/smbcloud-model/src/project.rs
+1
-1
@@ -85,7 +85,7 @@ pub struct Project {
85
pub created_at: DateTime<Utc>,
86
#[serde(default = "default_datetime")]
87
pub updated_at: DateTime<Utc>,
88
- /// Deployment kind, e.g. "vite-spa", "sveltekit", "nextjs-ssr", or "rust".
88
+ /// Deployment kind, e.g. "vite-spa", "sveltekit", "nextjs-ssr", "astro-ssr", or "rust".
89
pub kind: Option<String>,
90
/// Local source directory to build from, e.g. "frontend/connected-devices"
91
/// or a Rust crate root like ".".