@smbcloud / cli / commits / 869c7f1

Add global --ci flag for non-interactive mode

Disables interactive prompts for CI/automation. Enabled by --ci, SMB_CI=1, or the conventional CI env var. - ci module: process-global non-interactive state + CI env resolution - ui::prompt: CI-aware confirm/input/password/select wrappers (confirm uses its default; required input/secrets/selection fail fast) - Guard the interactive flows so they fail fast with an actionable message instead of hanging on a missing TTY: deploy setup/select/create, monorepo project select, login fallback, init, project new/update/delete, signup, forgot-password. logout proceeds on its default. - docs/ci.md + README: CI usage with the AircraftsHub monorepo as the example Verified: `smb --ci deploy` deploys the AircraftsHub web app end-to-end with no prompts; `smb --ci {init,login,…}` fail fast with guidance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

keypair34 committed Jun 20, 2026 at 05:07 UTC 869c7f10ef742a3e307d62c26538d73d71ee1672
19 files changed +412 -17
README.MD
+17
@@ -85,6 +85,23 @@ smb deploy
85
86 That gets you from login to first deploy.
87
88 +## CI / non-interactive mode
89 +
90 +Pass `--ci` (or set `SMB_CI=1`, or run under any provider that sets `CI`) to
91 +disable interactive prompts. Confirmations take their default; anything that
92 +truly needs input fails fast with a clear message instead of hanging on a
93 +missing TTY.
94 +
95 +```sh
96 +smb --ci deploy
97 +```
98 +
99 +For example, the [AircraftsHub](https://aircraftshub.5mb.app) monorepo deploys
100 +its Next.js web app with a single non-interactive command — its
101 +`.smb/config.toml` pins the project, source, and runtime, so `smb --ci deploy`
102 +needs no prompts. See [`docs/ci.md`](./docs/ci.md) for token setup and the full
103 +behavior reference.
104 +
105 ## Documentation
106
107 See the [`docs/`](./docs) directory for guides on authentication, project configuration, and deployment workflows.
crates/cli/src/account/forgot/mod.rs
+9 -1
@@ -1,9 +1,10 @@
1 use crate::{
2 + ci::{interactive_message, is_ci},
3 cli::CommandResult,
4 client,
5 ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
6 };
6 -use anyhow::Result;
7 +use anyhow::{anyhow, Result};
8 use dialoguer::{theme::ColorfulTheme, Input, Password};
9 use reqwest::{Client, StatusCode};
10 use smbcloud_model::forgot::{Args, Email, Param, UserUpdatePassword};
@@ -13,6 +14,13 @@ use smbcloud_utils::email_validation;
14 use spinners::Spinner;
15
16 pub async fn process_forgot(env: Environment) -> Result<CommandResult> {
17 + // Password reset prompts for email, the security code, and a new password.
18 + if is_ci() {
19 + return Err(anyhow!(fail_message(&interactive_message(
20 + "Password reset"
21 + ))));
22 + }
23 +
24 println!("Enter your email address.");
25 let email = Input::<String>::with_theme(&ColorfulTheme::default())
26 .with_prompt("Email")
crates/cli/src/account/login/process.rs
+7
@@ -4,6 +4,7 @@ use {
4 lib::{authorize_github, store_token},
5 signup::{do_signup, signup_with_email, SignupMethod},
6 },
7 + ci::{interactive_message, is_ci},
8 cli::CommandResult,
9 client,
10 token::is_logged_in::is_logged_in as is_logged_in_async,
@@ -39,6 +40,12 @@ use {
40 };
41
42 pub async fn process_login(env: Environment, is_logged_in: Option<bool>) -> Result<CommandResult> {
43 + // Login is an interactive flow (provider choice, credentials, OAuth). In CI
44 + // the token must be provisioned ahead of time at ~/.smb/token.
45 + if is_ci() {
46 + return Err(anyhow!(fail_message(&interactive_message("Login"))));
47 + }
48 +
49 let should_continue = match is_logged_in {
50 Some(is_logged_id) => !is_logged_id,
51 None => {
crates/cli/src/account/logout/mod.rs
+4 -12
@@ -3,10 +3,9 @@ use {
3 cli::CommandResult,
4 client,
5 token::{get_smb_token::get_smb_token, smb_token_file_path::smb_token_file_path},
6 - ui::{fail_message, fail_symbol, succeed_message, succeed_symbol},
6 + ui::{fail_message, fail_symbol, prompt, succeed_message, succeed_symbol},
7 },
8 anyhow::{anyhow, Result},
9 - dialoguer::{theme::ColorfulTheme, Confirm},
9 smbcloud_auth::logout::logout,
10 smbcloud_network::environment::Environment,
11 spinners::Spinner,
@@ -15,16 +14,9 @@ use {
14
15 pub async fn process_logout(env: Environment) -> Result<CommandResult> {
16 if let Some(token_path) = smb_token_file_path(env) {
18 - let confirm = match Confirm::with_theme(&ColorfulTheme::default())
19 - .with_prompt("Do you want to logout? y/n")
20 - .interact()
21 - {
22 - Ok(confirm) => confirm,
23 - Err(_) => {
24 - let error = anyhow!("Invalid input.");
25 - return Err(error);
26 - }
27 - };
17 + // In --ci mode this defaults to "yes": running `smb --ci logout` is an
18 + // explicit request, so proceed without prompting.
19 + let confirm = prompt::confirm("Do you want to logout? y/n", true)?;
20 if !confirm {
21 return Ok(CommandResult {
22 spinner: Spinner::new(
crates/cli/src/account/signup/process.rs
+6
@@ -2,6 +2,7 @@ use {
2 super::SignupMethod,
3 crate::{
4 account::lib::authorize_github,
5 + ci::{interactive_message, is_ci},
6 cli::CommandResult,
7 client,
8 token::smb_token_file_path::smb_token_file_path,
@@ -21,6 +22,11 @@ use {
22 };
23
24 pub async fn process_signup(env: Environment) -> Result<CommandResult> {
25 + // Sign-up is an interactive wizard and has no headless equivalent.
26 + if is_ci() {
27 + return Err(anyhow!(fail_message(&interactive_message("Sign-up"))));
28 + }
29 +
30 // Check if token file exists
31 if smb_token_file_path(env).is_some() {
32 return Ok(CommandResult {
crates/cli/src/ci.rs new
+67
@@ -0,0 +1,67 @@
1 +//! Global CI / non-interactive mode.
2 +//!
3 +//! When enabled (via the global `--ci` flag, `SMB_CI=1`, or the conventional
4 +//! `CI` env var), interactive prompts are disabled: confirmations resolve to
5 +//! their default, and any prompt that needs real user input fails fast with an
6 +//! actionable error instead of blocking on a TTY that isn't there.
7 +//!
8 +//! The flag is parsed once in `main` and stored here so the deep call tree can
9 +//! consult it without threading a boolean through every function signature.
10 +
11 +use std::sync::atomic::{AtomicBool, Ordering};
12 +
13 +static CI: AtomicBool = AtomicBool::new(false);
14 +
15 +/// Enable or disable CI (non-interactive) mode. Called once from `main`.
16 +pub fn set_ci(enabled: bool) {
17 + CI.store(enabled, Ordering::Relaxed);
18 +}
19 +
20 +/// Whether CI (non-interactive) mode is active.
21 +pub fn is_ci() -> bool {
22 + CI.load(Ordering::Relaxed)
23 +}
24 +
25 +/// Standard message for a prompt that cannot run in CI mode. `what` names the
26 +/// thing that needed input (e.g. "Project setup", "Login").
27 +pub fn interactive_message(what: &str) -> String {
28 + format!(
29 + "{what} requires interactive input, but --ci (non-interactive) mode is on. \
30 + Provide it via a flag, an environment variable, or .smb/config.toml — \
31 + or run the command without --ci."
32 + )
33 +}
34 +
35 +/// Resolve CI mode from the parsed `--ci`/`SMB_CI` flag plus the conventional
36 +/// `CI` environment variable that most CI providers set (`CI=true`/`1`).
37 +pub fn resolve(flag: bool) -> bool {
38 + if flag {
39 + return true;
40 + }
41 + match std::env::var("CI") {
42 + Ok(value) => {
43 + let value = value.trim().to_ascii_lowercase();
44 + matches!(value.as_str(), "1" | "true" | "yes" | "on")
45 + }
46 + Err(_) => false,
47 + }
48 +}
49 +
50 +#[cfg(test)]
51 +mod tests {
52 + use super::*;
53 +
54 + #[test]
55 + fn flag_forces_ci_without_touching_env() {
56 + // `--ci`/`SMB_CI` short-circuits before the conventional CI var is read.
57 + assert!(resolve(true));
58 + }
59 +
60 + #[test]
61 + fn set_and_read_roundtrip() {
62 + set_ci(true);
63 + assert!(is_ci());
64 + set_ci(false);
65 + assert!(!is_ci());
66 + }
67 +}
crates/cli/src/cli/mod.rs
+6
@@ -28,6 +28,12 @@ pub struct Cli {
28 #[clap(short, long, global = true)]
29 pub log_level: Option<String>,
30
31 + /// Non-interactive mode for CI/automation: disable prompts. Confirmations
32 + /// use their default; prompts that need real input fail fast instead of
33 + /// blocking. Also enabled by SMB_CI=1 or the conventional CI env var.
34 + #[arg(long, global = true, env = "SMB_CI")]
35 + pub ci: bool,
36 +
37 #[command(subcommand)]
38 pub command: Option<Commands>,
39 }
crates/cli/src/deploy/process_deploy.rs
+16
@@ -48,6 +48,15 @@ fn prompt_select_project(config: &Config) -> Result<String> {
48
49 let labels: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
50
51 + // In CI there's no TTY to pick from a monorepo's [[projects]]; the caller
52 + // must name the target with `--project <name>`.
53 + if crate::ci::is_ci() {
54 + return Err(anyhow!(fail_message(&format!(
55 + "This is a monorepo config. In --ci mode, pass --project <name> to choose which app to deploy. Available: {}",
56 + labels.join(", ")
57 + ))));
58 + }
59 +
60 let index = Select::with_theme(&ColorfulTheme::default())
61 .with_prompt("Select project to deploy")
62 .items(&labels)
@@ -95,6 +104,13 @@ pub async fn process_deploy(
104 let is_logged_in = is_logged_in(env).await?;
105
106 if !is_logged_in {
107 + // Logging in is interactive; in CI the token must already exist.
108 + if crate::ci::is_ci() {
109 + return Err(anyhow!(fail_message(
110 + "Not authenticated. In --ci mode, log in beforehand (run `smb login` without --ci, \
111 + or provision the token at ~/.smb/token) — interactive login is disabled."
112 + )));
113 + }
114 let _ = process_login(env, Some(is_logged_in)).await?;
115 }
116
crates/cli/src/deploy/setup_create_new_project.rs
+11 -1
@@ -1,6 +1,8 @@
1 use {
2 crate::{
3 - client, project::deploy_target::ensure_default_frontend_app_for_project,
3 + ci::{interactive_message, is_ci},
4 + client,
5 + project::deploy_target::ensure_default_frontend_app_for_project,
6 token::get_smb_token::get_smb_token,
7 },
8 dialoguer::{console::Term, theme::ColorfulTheme, Input, Select},
@@ -19,6 +21,14 @@ pub(crate) async fn create_new_project(
21 env: Environment,
22 path: &str,
23 ) -> Result<Project, ErrorResponse> {
24 + // Creating a project prompts for name, repository, description, and runner.
25 + if is_ci() {
26 + return Err(ErrorResponse::Error {
27 + error_code: ErrorCode::InputError,
28 + message: interactive_message("Creating a new project"),
29 + });
30 + }
31 +
32 let default_name = Path::new(path)
33 .file_name()
34 .and_then(|os_str| os_str.to_str())
crates/cli/src/deploy/setup_project.rs
+10
@@ -1,5 +1,6 @@
1 use {
2 crate::{
3 + ci::{interactive_message, is_ci},
4 client,
5 deploy::{
6 setup_create_new_project::create_new_project, setup_select_project::select_project,
@@ -32,6 +33,15 @@ pub(crate) async fn setup_project(
33 .map(|p| p.display().to_string())
34 .unwrap_or_else(|| ".".to_string());
35
36 + // Interactive setup writes a fresh .smb/config.toml after prompting for a
37 + // project — impossible without a TTY. In CI, fail fast with guidance.
38 + if is_ci() {
39 + return Err(ErrorResponse::Error {
40 + error_code: ErrorCode::InputError,
41 + message: interactive_message("Project setup"),
42 + });
43 + }
44 +
45 let confirm = Confirm::with_theme(&ColorfulTheme::default())
46 .with_prompt(format!("Setup project in {}? y/n", highlight(&path_str)))
47 .interact()
crates/cli/src/deploy/setup_select_project.rs
+12 -1
@@ -1,5 +1,8 @@
1 use {
2 - crate::deploy::setup_create_new_project::create_new_project,
2 + crate::{
3 + ci::{interactive_message, is_ci},
4 + deploy::setup_create_new_project::create_new_project,
5 + },
6 dialoguer::{theme::ColorfulTheme, Confirm, Select},
7 smbcloud_model::{
8 error_codes::{ErrorCode, ErrorResponse},
@@ -13,6 +16,14 @@ pub(crate) async fn select_project(
16 projects: Vec<Project>,
17 path: &str,
18 ) -> Result<Project, ErrorResponse> {
19 + // Choosing among existing projects (or creating a new one) is interactive.
20 + if is_ci() {
21 + return Err(ErrorResponse::Error {
22 + error_code: ErrorCode::InputError,
23 + message: interactive_message("Selecting a project"),
24 + });
25 + }
26 +
27 let confirm = Confirm::with_theme(&ColorfulTheme::default())
28 .with_prompt("Use existing project? y/n")
29 .interact()
crates/cli/src/lib.rs
+2 -1
@@ -1,12 +1,13 @@
1 use smbcloud_networking::smb_client::SmbClient;
2
3 pub mod account;
4 +pub mod ci;
5 pub mod cli;
6 pub mod deploy;
7 pub mod mail;
8 pub mod project;
9 mod token;
9 -mod ui;
10 +pub mod ui;
11
12 pub use token::clear_smb_token::clear_smb_token;
13
crates/cli/src/main.rs
+2
@@ -76,6 +76,8 @@ fn setup_logging(env: Environment, level: Option<EnvFilter>) -> Result<()> {
76 async fn main() {
77 let cli = Cli::parse();
78 let environment = cli.environment;
79 + // Resolve CI / non-interactive mode once, before any command can prompt.
80 + smbcloud_cli::ci::set_ci(smbcloud_cli::ci::resolve(cli.ci));
81 match run(cli).await {
82 Ok(result) => {
83 result.stop_and_persist();
crates/cli/src/project/crud_create.rs
+8
@@ -1,3 +1,4 @@
1 +use crate::ci::{interactive_message, is_ci};
2 use crate::client;
3 use crate::token::{get_smb_token::get_smb_token, is_logged_in::is_logged_in};
4 use crate::{
@@ -39,6 +40,13 @@ pub async fn process_project_init(
40 env: Environment,
41 should_init_project: bool,
42 ) -> Result<CommandResult> {
43 + // `init` / `project new` are wizards (name, description, repo, runner, …).
44 + if is_ci() {
45 + return Err(anyhow!(fail_message(&interactive_message(
46 + "Project initialization"
47 + ))));
48 + }
49 +
50 let is_logged_in = is_logged_in(env).await?;
51 if !is_logged_in {
52 let _ = process_login(env, Some(is_logged_in)).await;
crates/cli/src/project/crud_delete.rs
+9
@@ -2,6 +2,7 @@ use crate::client;
2 use crate::token::get_smb_token::get_smb_token;
3 use crate::{
4 account::lib::is_logged_in,
5 + ci::{interactive_message, is_ci},
6 cli::CommandResult,
7 ui::{
8 confirm_dialog::confirm_delete_tui, fail_message, fail_symbol, succeed_message,
@@ -18,6 +19,14 @@ pub async fn process_project_delete(env: Environment, id: String) -> Result<Comm
19 return Err(anyhow!(fail_message("Please log in with `smb init`.")));
20 }
21
22 + // Deletion confirmation is a full-screen TUI; refuse in CI rather than
23 + // deleting unconfirmed.
24 + if is_ci() {
25 + return Err(anyhow!(fail_message(&interactive_message(
26 + "Project deletion confirmation"
27 + ))));
28 + }
29 +
30 let confirmed = confirm_delete_tui(&format!("Delete project #{id}")).map_err(|e| anyhow!(e))?;
31
32 let spinner = Spinner::new(
crates/cli/src/project/crud_update.rs
+9 -1
@@ -13,11 +13,19 @@ use spinners::Spinner;
13
14 use crate::{
15 account::login::process_login,
16 + ci::{interactive_message, is_ci},
17 cli::CommandResult,
17 - ui::{description, succeed_message, succeed_symbol},
18 + ui::{description, fail_message, succeed_message, succeed_symbol},
19 };
20
21 pub async fn process_project_update(env: Environment, project_id: String) -> Result<CommandResult> {
22 + // Updating a project prompts for a new description and runner.
23 + if is_ci() {
24 + return Err(anyhow!(fail_message(&interactive_message(
25 + "Project update"
26 + ))));
27 + }
28 +
29 // Check credentials.
30 let is_logged_in = is_logged_in(env).await?;
31 if !is_logged_in {
crates/cli/src/ui/mod.rs
+1
@@ -4,6 +4,7 @@ pub mod deployment_table;
4 pub mod me_view;
5 pub mod project_detail_view;
6 pub mod project_table;
7 +pub mod prompt;
8 pub mod theme;
9
10 use console::style;
crates/cli/src/ui/prompt.rs new
+129
@@ -0,0 +1,129 @@
1 +//! CI-aware interactive prompts.
2 +//!
3 +//! Thin wrappers over `dialoguer` that respect [`crate::ci::is_ci`]. In CI
4 +//! (non-interactive) mode:
5 +//!
6 +//! * [`confirm`] resolves to its default instead of asking.
7 +//! * [`input`] / [`input_optional`] return the supplied default, or fail.
8 +//! * [`password`] and the required [`input_required`] fail fast — secrets and
9 +//! free-form answers can never be guessed.
10 +//! * [`select`] / [`select_opt`] use an explicit CI default when the caller
11 +//! provides one, otherwise fail.
12 +//!
13 +//! Failures are plain [`anyhow::Error`]s with an actionable message, so a CI
14 +//! pipeline gets a clear exit code instead of a hung TTY.
15 +
16 +use {
17 + crate::{
18 + ci::{interactive_message, is_ci},
19 + ui::fail_message,
20 + },
21 + anyhow::{anyhow, Result},
22 + dialoguer::{console::Term, theme::ColorfulTheme, Confirm, Input, Password, Select},
23 +};
24 +
25 +fn ci_required(what: &str) -> anyhow::Error {
26 + anyhow!(fail_message(&interactive_message(what)))
27 +}
28 +
29 +fn io_error(err: dialoguer::Error) -> anyhow::Error {
30 + anyhow!(fail_message(&format!("Prompt failed: {err}")))
31 +}
32 +
33 +/// Yes/no confirmation. In CI mode returns `default` without prompting.
34 +pub fn confirm(prompt: &str, default: bool) -> Result<bool> {
35 + if is_ci() {
36 + return Ok(default);
37 + }
38 + Confirm::with_theme(&ColorfulTheme::default())
39 + .with_prompt(prompt)
40 + .default(default)
41 + .interact()
42 + .map_err(io_error)
43 +}
44 +
45 +/// Free-text input with a default. In CI mode returns the default.
46 +pub fn input(prompt: &str, default: &str) -> Result<String> {
47 + if is_ci() {
48 + return Ok(default.to_string());
49 + }
50 + Input::<String>::with_theme(&ColorfulTheme::default())
51 + .with_prompt(prompt)
52 + .default(default.to_string())
53 + .interact()
54 + .map_err(io_error)
55 +}
56 +
57 +/// Free-text input that allows an empty value, with a default. In CI mode
58 +/// returns the default (which may be empty).
59 +pub fn input_optional(prompt: &str, default: &str) -> Result<String> {
60 + if is_ci() {
61 + return Ok(default.to_string());
62 + }
63 + Input::<String>::with_theme(&ColorfulTheme::default())
64 + .with_prompt(prompt)
65 + .default(default.to_string())
66 + .allow_empty(true)
67 + .interact()
68 + .map_err(io_error)
69 +}
70 +
71 +/// Required free-text input with no default. Fails in CI mode.
72 +pub fn input_required(prompt: &str) -> Result<String> {
73 + if is_ci() {
74 + return Err(ci_required(prompt));
75 + }
76 + Input::<String>::with_theme(&ColorfulTheme::default())
77 + .with_prompt(prompt)
78 + .interact()
79 + .map_err(io_error)
80 +}
81 +
82 +/// Hidden password input. Always fails in CI mode — secrets are never guessed.
83 +pub fn password(prompt: &str) -> Result<String> {
84 + if is_ci() {
85 + return Err(ci_required(prompt));
86 + }
87 + Password::with_theme(&ColorfulTheme::default())
88 + .with_prompt(prompt)
89 + .interact()
90 + .map_err(io_error)
91 +}
92 +
93 +/// Single-choice selection. In CI mode returns `ci_default` when the caller
94 +/// supplies one, otherwise fails.
95 +pub fn select<T: ToString>(
96 + prompt: &str,
97 + items: &[T],
98 + default: usize,
99 + ci_default: Option<usize>,
100 +) -> Result<usize> {
101 + if is_ci() {
102 + return ci_default.ok_or_else(|| ci_required(prompt));
103 + }
104 + Select::with_theme(&ColorfulTheme::default())
105 + .with_prompt(prompt)
106 + .items(items)
107 + .default(default)
108 + .interact_on(&Term::stderr())
109 + .map_err(io_error)
110 +}
111 +
112 +/// Single-choice selection that may be cancelled (returns `None`). In CI mode
113 +/// returns `Some(ci_default)` when supplied, otherwise fails.
114 +pub fn select_opt<T: ToString>(
115 + prompt: &str,
116 + items: &[T],
117 + default: usize,
118 + ci_default: Option<usize>,
119 +) -> Result<Option<usize>> {
120 + if is_ci() {
121 + return Ok(Some(ci_default.ok_or_else(|| ci_required(prompt))?));
122 + }
123 + Select::with_theme(&ColorfulTheme::default())
124 + .with_prompt(prompt)
125 + .items(items)
126 + .default(default)
127 + .interact_on_opt(&Term::stderr())
128 + .map_err(io_error)
129 +}
docs/ci.md new
+87
@@ -0,0 +1,87 @@
1 +# CI / non-interactive deploys
2 +
3 +`smb` is interactive by default — it prompts for confirmations, project setup,
4 +credentials, and so on. In CI that's a problem: there's no TTY to answer a
5 +prompt, so the command would hang or fail with a cryptic terminal error.
6 +
7 +Pass the global **`--ci`** flag (or set **`SMB_CI=1`**, or run under any provider
8 +that sets the conventional **`CI`** env var) to put `smb` in **non-interactive
9 +mode**:
10 +
11 +- Confirmations resolve to their default instead of asking
12 + (e.g. `smb --ci logout` proceeds without a y/n prompt).
13 +- Any command that genuinely needs interactive input — `login`, `init`,
14 + `signup`, `account forgot-password`, `project new/update/delete`, and picking a
15 + monorepo target — **fails fast with a clear, actionable message** instead of
16 + blocking.
17 +
18 +```sh
19 +smb --ci deploy # flag
20 +SMB_CI=1 smb deploy # env var
21 +CI=true smb deploy # most CI providers set this automatically
22 +```
23 +
24 +## Authenticate ahead of time
25 +
26 +Logging in is interactive, so it can't run under `--ci`. Provision the token
27 +once and make it available to the CI job at `~/.smb/token` (production) or
28 +`~/.smb-dev/token` (dev). Typically you store the token contents in a CI secret
29 +and write the file at the start of the job:
30 +
31 +```yaml
32 +- run: |
33 + mkdir -p ~/.smb
34 + printf '%s' "${{ secrets.SMB_TOKEN }}" > ~/.smb/token
35 + chmod 600 ~/.smb/token
36 +```
37 +
38 +Deploys also rsync over SSH using `~/.ssh/id_<user-id>@smbcloud`; install that
39 +key the same way if your project deploys to a server tier.
40 +
41 +## Example: the AircraftsHub monorepo
42 +
43 +[AircraftsHub](https://aircraftshub.5mb.app) is a Tauri app whose Next.js web app
44 +lives in the same repo (`web/`) as a pnpm workspace. Only the web app deploys to
45 +smbCloud; the desktop/mobile app ships to the app stores. Its `.smb/config.toml`:
46 +
47 +```toml
48 +name = "aircraftshubweb"
49 +
50 +[project]
51 +id = 61
52 +kind = "nextjs-ssr"
53 +source = "web" # local Next.js app directory (workspace member)
54 +path = "apps/web/aircraftshubweb" # remote runtime directory on the server
55 +package_manager = "pnpm"
56 +pm2_app = "aircraftshub-web"
57 +port = 3022
58 +```
59 +
60 +Because the config pins the project, the source, and the runtime, deploying needs
61 +no interaction at all:
62 +
63 +```sh
64 +smb --ci deploy
65 +# ✔ Build complete.
66 +# App is running ✔
67 +# ✔ Deployment complete.
68 +```
69 +
70 +For a monorepo config with several `[[projects]]` entries, name the target —
71 +`--ci` mode won't prompt you to choose:
72 +
73 +```sh
74 +smb --ci deploy --project aircraftshubweb
75 +```
76 +
77 +## Behavior reference
78 +
79 +| Command | `--ci` behavior |
80 +|---|---|
81 +| `deploy` (config pins project) | Runs fully non-interactively |
82 +| `deploy` (monorepo, no `--project`) | Fails: pass `--project <name>` |
83 +| `deploy` (not authenticated) | Fails: provision the token first |
84 +| `logout` | Proceeds (confirmation defaults to yes) |
85 +| `login`, `init`, `signup`, `account forgot-password` | Fails fast — interactive only |
86 +| `project new`, `project update`, `project delete` | Fails fast — interactive only |
87 +| `me`, `migrate`, `project list/show`, `mail` | Unaffected (no prompts) |