@smbcloud / cli / commits / 03811e6

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 ".".