@smbcloud / cli / commits / e253bd4

Add smbCloud GitHub App integration: manifest, smb github commands, API contract

Ship the client side of a Vercel-style GitHub App (auto-deploy on push): - github-app/manifest.json for GitHub's app-manifest registration flow (contents:read, metadata:read, statuses:write; push webhook), plus docs/github-app.md covering registration, architecture, and CLI usage. - New `smb github` command group: install (opens the app install page and polls until the installation appears), connect (link a GitHub repo and production branch to the current project's deploy repo, interactive or --repo/--branch for CI), status, and disconnect. - New GithubInstallation/GithubRepository/GithubConnection models and an optional github_connection field on DeployRepo (backward compatible). - New v1/github and v1/deploy_repos/{id}/github_connection client calls in smbcloud-networking-project defining the contract the server implements. - Move current_project helpers from mail to project so github shares them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RVaVmviDggxPidc7wvYwer

Claude committed Jul 10, 2026 at 06:48 UTC e253bd48b43541251fc419cdac45f5d693e4b557
19 files changed +820 -3
crates/cli/src/cli/mod.rs
+6 -1
@@ -1,5 +1,5 @@
1 use {
2 - crate::{account, mail, project},
2 + crate::{account, github, mail, project},
3 clap::{Parser, Subcommand},
4 smbcloud_network::environment::Environment,
5 spinners::Spinner,
@@ -71,6 +71,11 @@ pub enum Commands {
71 #[clap(subcommand)]
72 command: project::cli::Commands,
73 },
74 + #[clap(about = "Connect GitHub repositories for automatic deploys.")]
75 + Github {
76 + #[clap(subcommand)]
77 + command: github::cli::Commands,
78 + },
79 #[clap(about = "Manage smbCloud Mail.")]
80 Mail {
81 #[clap(subcommand)]
crates/cli/src/github.rs new
+2
@@ -0,0 +1,2 @@
1 +pub mod cli;
2 +pub mod process;
crates/cli/src/github/cli.rs new
+37
@@ -0,0 +1,37 @@
1 +use clap::Subcommand;
2 +
3 +#[derive(Subcommand)]
4 +pub enum Commands {
5 + #[clap(about = "Install the smbCloud GitHub App on your account or organization.")]
6 + Install {},
7 +
8 + #[clap(about = "Connect a GitHub repository to a project for auto-deploy on push.")]
9 + Connect {
10 + /// GitHub repository as owner/name. Omit to pick interactively.
11 + #[clap(long)]
12 + repo: Option<String>,
13 +
14 + /// Branch that triggers production deploys. Defaults to the
15 + /// repository's default branch.
16 + #[clap(long)]
17 + branch: Option<String>,
18 +
19 + /// Project to connect. Defaults to the current project (`smb project use`).
20 + #[clap(long)]
21 + project_id: Option<String>,
22 + },
23 +
24 + #[clap(about = "Show the GitHub connection for a project.")]
25 + Status {
26 + /// Project to inspect. Defaults to the current project.
27 + #[clap(long)]
28 + project_id: Option<String>,
29 + },
30 +
31 + #[clap(about = "Disconnect the GitHub repository from a project.")]
32 + Disconnect {
33 + /// Project to disconnect. Defaults to the current project.
34 + #[clap(long)]
35 + project_id: Option<String>,
36 + },
37 +}
crates/cli/src/github/process.rs new
+324
@@ -0,0 +1,324 @@
1 +use {
2 + crate::{
3 + ci,
4 + cli::CommandResult,
5 + client,
6 + github::cli::Commands,
7 + project::current_project::resolve_required_project_id,
8 + token::get_smb_token::get_smb_token,
9 + ui::{prompt, succeed_message, succeed_symbol},
10 + },
11 + anyhow::{anyhow, Result},
12 + smbcloud_model::github::{
13 + GithubConnection, GithubConnectionCreate, GithubInstallation, GithubRepository,
14 + },
15 + smbcloud_network::environment::Environment,
16 + smbcloud_networking::constants::GH_APP_SLUG,
17 + smbcloud_networking_project::{
18 + crud_github_connection::{
19 + create_github_connection, delete_github_connection, get_github_connection,
20 + get_github_installation_repositories, get_github_installations,
21 + },
22 + crud_project_read::get_project,
23 + },
24 + spinners::Spinner,
25 + std::{collections::HashSet, time::Duration},
26 +};
27 +
28 +pub async fn process_github(env: Environment, commands: Commands) -> Result<CommandResult> {
29 + match commands {
30 + Commands::Install {} => process_github_install(env).await,
31 + Commands::Connect {
32 + repo,
33 + branch,
34 + project_id,
35 + } => process_github_connect(env, repo, branch, project_id).await,
36 + Commands::Status { project_id } => process_github_status(env, project_id).await,
37 + Commands::Disconnect { project_id } => process_github_disconnect(env, project_id).await,
38 + }
39 +}
40 +
41 +async fn process_github_install(env: Environment) -> Result<CommandResult> {
42 + let access_token = get_smb_token(env)?;
43 + if ci::is_ci() {
44 + return Err(anyhow!(ci::interactive_message(
45 + "GitHub App installation (a browser flow)"
46 + )));
47 + }
48 +
49 + let existing_ids: HashSet<i64> = get_github_installations(env, client(), access_token.clone())
50 + .await
51 + .map_err(api_error)?
52 + .iter()
53 + .map(|installation| installation.id)
54 + .collect();
55 +
56 + let install_url = format!("https://github.com/apps/{GH_APP_SLUG}/installations/new");
57 + if open::that(&install_url).is_err() {
58 + println!("Open this URL in your browser to install the app:\n {install_url}");
59 + }
60 +
61 + let mut spinner = loading_spinner("Waiting for the installation to complete in your browser");
62 + let poll_interval = Duration::from_secs(5);
63 + let poll_attempts = 24;
64 + for _ in 0..poll_attempts {
65 + tokio::time::sleep(poll_interval).await;
66 + let installations = get_github_installations(env, client(), access_token.clone())
67 + .await
68 + .map_err(api_error)?;
69 + if let Some(new_installation) = installations
70 + .iter()
71 + .find(|installation| !existing_ids.contains(&installation.id))
72 + {
73 + spinner.stop_and_persist(
74 + &succeed_symbol(),
75 + succeed_message(&format!(
76 + "App installed on {}.",
77 + new_installation.account_login
78 + )),
79 + );
80 + return Ok(done_result(
81 + "Run `smb github connect` to link a repository to your project.",
82 + ));
83 + }
84 + }
85 +
86 + spinner.stop_and_persist(
87 + &succeed_symbol(),
88 + succeed_message("No new installation detected yet."),
89 + );
90 + Ok(done_result(
91 + "If you completed the installation in the browser, run `smb github connect` \
92 + or `smb github status` to continue.",
93 + ))
94 +}
95 +
96 +async fn process_github_connect(
97 + env: Environment,
98 + repo: Option<String>,
99 + branch: Option<String>,
100 + project_id: Option<String>,
101 +) -> Result<CommandResult> {
102 + let access_token = get_smb_token(env)?;
103 + let deploy_repo_id = require_deploy_repo_id(env, access_token.clone(), project_id).await?;
104 +
105 + let mut spinner = loading_spinner("Loading your GitHub App installations");
106 + let installations = get_github_installations(env, client(), access_token.clone())
107 + .await
108 + .map_err(api_error)?;
109 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
110 +
111 + if installations.is_empty() {
112 + return Err(anyhow!(
113 + "No smbCloud GitHub App installations found. Run `smb github install` first."
114 + ));
115 + }
116 +
117 + let (installation, repository) = match normalize_optional(repo) {
118 + Some(full_name) => {
119 + find_repository(env, access_token.clone(), &installations, &full_name).await?
120 + }
121 + None => pick_repository(env, access_token.clone(), &installations).await?,
122 + };
123 +
124 + let payload = GithubConnectionCreate {
125 + github_installation_id: installation.id,
126 + github_repo_full_name: repository.full_name.clone(),
127 + production_branch: normalize_optional(branch),
128 + };
129 +
130 + let mut spinner = loading_spinner("Connecting the repository");
131 + let connection = create_github_connection(env, client(), access_token, deploy_repo_id, payload)
132 + .await
133 + .map_err(api_error)?;
134 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Connected."));
135 + print_connection(&connection);
136 +
137 + Ok(done_result(&format!(
138 + "Connected. Pushes to `{}` now deploy automatically.",
139 + connection.production_branch
140 + )))
141 +}
142 +
143 +async fn process_github_status(
144 + env: Environment,
145 + project_id: Option<String>,
146 +) -> Result<CommandResult> {
147 + let access_token = get_smb_token(env)?;
148 + let deploy_repo_id = require_deploy_repo_id(env, access_token.clone(), project_id).await?;
149 +
150 + let mut spinner = loading_spinner("Loading the GitHub connection");
151 + let status = get_github_connection(env, client(), access_token, deploy_repo_id)
152 + .await
153 + .map_err(api_error)?;
154 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
155 +
156 + match status.connection.filter(|_| status.connected) {
157 + Some(connection) => {
158 + print_connection(&connection);
159 + Ok(done_result("Done."))
160 + }
161 + None => Ok(done_result(
162 + "Not connected. Run `smb github connect` to set up auto-deploy on push.",
163 + )),
164 + }
165 +}
166 +
167 +async fn process_github_disconnect(
168 + env: Environment,
169 + project_id: Option<String>,
170 +) -> Result<CommandResult> {
171 + let access_token = get_smb_token(env)?;
172 + let deploy_repo_id = require_deploy_repo_id(env, access_token.clone(), project_id).await?;
173 +
174 + let mut spinner = loading_spinner("Loading the GitHub connection");
175 + let status = get_github_connection(env, client(), access_token.clone(), deploy_repo_id)
176 + .await
177 + .map_err(api_error)?;
178 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
179 +
180 + let connection = match status.connection.filter(|_| status.connected) {
181 + Some(connection) => connection,
182 + None => return Ok(done_result("Nothing to disconnect.")),
183 + };
184 +
185 + let confirmed = prompt::confirm(
186 + &format!(
187 + "Disconnect {} from this project? Pushes will no longer deploy automatically",
188 + connection.github_repo_full_name
189 + ),
190 + false,
191 + )?;
192 + if !confirmed {
193 + return Ok(done_result("Aborted."));
194 + }
195 +
196 + let mut spinner = loading_spinner("Disconnecting the repository");
197 + delete_github_connection(env, client(), access_token, deploy_repo_id)
198 + .await
199 + .map_err(api_error)?;
200 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Disconnected."));
201 +
202 + Ok(done_result("Disconnected."))
203 +}
204 +
205 +/// Resolve the project (flag or current project) and return its deploy repo id,
206 +/// the unit a GitHub repository connects to.
207 +async fn require_deploy_repo_id(
208 + env: Environment,
209 + access_token: String,
210 + project_id: Option<String>,
211 +) -> Result<i64> {
212 + let resolved_project_id = resolve_required_project_id(env, normalize_optional(project_id))?;
213 + let project = get_project(env, client(), access_token, resolved_project_id)
214 + .await
215 + .map_err(api_error)?;
216 + project.deploy_repo_id.ok_or_else(|| {
217 + anyhow!("This project has no deploy repo yet. Run `smb deploy` once to initialize it.")
218 + })
219 +}
220 +
221 +/// Locate `full_name` across all installations. Non-interactive, so
222 +/// `--repo owner/name` works in CI.
223 +async fn find_repository(
224 + env: Environment,
225 + access_token: String,
226 + installations: &[GithubInstallation],
227 + full_name: &str,
228 +) -> Result<(GithubInstallation, GithubRepository)> {
229 + for installation in installations {
230 + let repositories = get_github_installation_repositories(
231 + env,
232 + client(),
233 + access_token.clone(),
234 + installation.id,
235 + )
236 + .await
237 + .map_err(api_error)?;
238 + if let Some(repository) = repositories
239 + .into_iter()
240 + .find(|repository| repository.full_name == full_name)
241 + {
242 + return Ok((installation.clone(), repository));
243 + }
244 + }
245 + Err(anyhow!(
246 + "Repository `{full_name}` is not accessible by any smbCloud GitHub App installation. \
247 + Check the app's repository access on GitHub, or run `smb github install`."
248 + ))
249 +}
250 +
251 +async fn pick_repository(
252 + env: Environment,
253 + access_token: String,
254 + installations: &[GithubInstallation],
255 +) -> Result<(GithubInstallation, GithubRepository)> {
256 + let installation = if installations.len() == 1 {
257 + installations[0].clone()
258 + } else {
259 + let index = prompt::select("Select a GitHub account", installations, 0, None)?;
260 + installations
261 + .get(index)
262 + .ok_or_else(|| anyhow!("Invalid selection."))?
263 + .clone()
264 + };
265 +
266 + let mut spinner = loading_spinner("Loading repositories");
267 + let repositories =
268 + get_github_installation_repositories(env, client(), access_token, installation.id)
269 + .await
270 + .map_err(api_error)?;
271 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
272 +
273 + if repositories.is_empty() {
274 + return Err(anyhow!(
275 + "The installation on {} has no accessible repositories. \
276 + Grant the app access to a repository on GitHub first.",
277 + installation.account_login
278 + ));
279 + }
280 +
281 + let index = prompt::select("Select a repository", &repositories, 0, None)?;
282 + let repository = repositories
283 + .get(index)
284 + .ok_or_else(|| anyhow!("Invalid selection."))?
285 + .clone();
286 + Ok((installation, repository))
287 +}
288 +
289 +fn print_connection(connection: &GithubConnection) {
290 + println!("Repository: {}", connection.github_repo_full_name);
291 + println!("Production branch: {}", connection.production_branch);
292 + println!("Installation id: {}", connection.github_installation_id);
293 + println!("Updated at: {}", connection.updated_at);
294 +}
295 +
296 +fn loading_spinner(message: &str) -> Spinner {
297 + Spinner::new(
298 + spinners::Spinners::SimpleDotsScrolling,
299 + succeed_message(message),
300 + )
301 +}
302 +
303 +fn done_result(message: &str) -> CommandResult {
304 + CommandResult {
305 + spinner: loading_spinner("Done"),
306 + symbol: succeed_symbol(),
307 + msg: succeed_message(message),
308 + }
309 +}
310 +
311 +fn normalize_optional(value: Option<String>) -> Option<String> {
312 + value.and_then(|value| {
313 + let normalized_value = value.trim().to_string();
314 + if normalized_value.is_empty() {
315 + None
316 + } else {
317 + Some(normalized_value)
318 + }
319 + })
320 +}
321 +
322 +fn api_error(error: impl std::fmt::Display) -> anyhow::Error {
323 + anyhow!(error.to_string())
324 +}
crates/cli/src/lib.rs
+1
@@ -4,6 +4,7 @@ pub mod account;
4 pub mod ci;
5 pub mod cli;
6 pub mod deploy;
7 +pub mod github;
8 pub mod mail;
9 pub mod project;
10 mod token;
crates/cli/src/mail.rs
-1
@@ -1,4 +1,3 @@
1 pub mod cli;
2 -mod current_project;
2 pub mod process;
3 mod render;
crates/cli/src/mail/process.rs
+1 -1
@@ -3,12 +3,12 @@ use crate::{
3 client,
4 mail::{
5 cli::{Commands, InboxCommands, MessageCommands},
6 - current_project::{resolve_optional_project_id, resolve_required_project_id},
6 render::{
7 print_mail_app_detail, print_mail_apps, print_mail_inbox_detail,
8 print_mail_message_detail, print_mail_messages, print_mail_test_delivery,
9 },
10 },
11 + project::current_project::{resolve_optional_project_id, resolve_required_project_id},
12 token::get_smb_token::get_smb_token,
13 ui::{
14 confirm_dialog::confirm_delete_tui, fail_message, fail_symbol, succeed_message,
crates/cli/src/main.rs
+3
@@ -7,6 +7,7 @@ use {
7 clear_smb_token,
8 cli::{Cli, CommandResult, Commands},
9 deploy::{process_deploy::process_deploy, process_migrate::process_migrate},
10 + github::process::process_github,
11 mail::process::process_mail,
12 project::{crud_create::process_project_init, process::process_project},
13 },
@@ -128,6 +129,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
129 | Some(Commands::Login {})
130 | Some(Commands::Logout {})
131 | Some(Commands::Account { .. })
132 + | Some(Commands::Github { .. })
133 | Some(Commands::Mail { .. })
134 | Some(Commands::Project { .. })
135 | Some(Commands::Migrate {})
@@ -149,6 +151,7 @@ async fn run(cli: Cli) -> Result<CommandResult> {
151 Some(Commands::Account { command }) => process_account(cli.environment, command).await,
152 Some(Commands::Login {}) => process_login(cli.environment, None).await,
153 Some(Commands::Logout {}) => process_logout(cli.environment).await,
154 + Some(Commands::Github { command }) => process_github(cli.environment, command).await,
155 Some(Commands::Mail { command }) => process_mail(cli.environment, command).await,
156 Some(Commands::Project { command }) => process_project(cli.environment, command).await,
157 Some(Commands::Migrate {}) => process_migrate(cli.environment).await,
crates/cli/src/project/current_project.rs renamed
crates/cli/src/project/mod.rs
+1
@@ -3,6 +3,7 @@ pub mod crud_create;
3 pub mod crud_delete;
4 pub mod crud_read;
5 pub mod crud_update;
6 +pub(crate) mod current_project;
7 pub mod deploy_target;
8 mod deployment;
9 pub mod process;
crates/smbcloud-model/src/frontend_app.rs
+30
@@ -152,6 +152,9 @@ pub struct DeployRepo {
152 /// app automatically server-side; this carries it back to the client.
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub frontend_apps: Option<Vec<FrontendApp>>,
155 + /// GitHub App connection driving auto-deploy on push, when one exists.
156 + #[serde(default, skip_serializing_if = "Option::is_none")]
157 + pub github_connection: Option<crate::github::GithubConnection>,
158 }
159
160 /// Payload for creating a new DeployRepo via the API.
@@ -340,6 +343,33 @@ mod tests {
343 assert_eq!(deploy_repo.deployment_method, DeploymentMethod::Git);
344 }
345
346 + #[test]
347 + fn test_deploy_repo_deserializes_embedded_github_connection() {
348 + let deploy_repo: DeployRepo = serde_json::from_value(json!({
349 + "id": 5,
350 + "name": "my-repo",
351 + "repository": "my-repo",
352 + "root_path": ".",
353 + "repo_kind": 0,
354 + "runner": 0,
355 + "deployment_method": 0,
356 + "github_connection": {
357 + "id": 1,
358 + "deploy_repo_id": 5,
359 + "github_installation_id": 42,
360 + "github_repo_full_name": "octocat/hello-world",
361 + "production_branch": "main",
362 + "created_at": "2026-01-01T00:00:00Z",
363 + "updated_at": "2026-01-01T00:00:00Z"
364 + }
365 + }))
366 + .unwrap();
367 + let connection = deploy_repo
368 + .github_connection
369 + .expect("github_connection should be present");
370 + assert_eq!(connection.github_repo_full_name, "octocat/hello-world");
371 + }
372 +
373 #[test]
374 fn test_deploy_repo_deserializes_legacy_string_enums() {
375 let deploy_repo: DeployRepo = serde_json::from_value(json!({
crates/smbcloud-model/src/github.rs new
+168
@@ -0,0 +1,168 @@
1 +use {
2 + chrono::{DateTime, Utc},
3 + serde::{Deserialize, Serialize},
4 + std::fmt::Display,
5 + tsync::tsync,
6 +};
7 +
8 +/// An installation of the smbCloud GitHub App on a user or organization
9 +/// account, as reported by the smbCloud API.
10 +#[derive(Deserialize, Serialize, Debug, Clone)]
11 +#[tsync]
12 +pub struct GithubInstallation {
13 + /// GitHub installation id.
14 + pub id: i64,
15 + pub account_login: String,
16 + /// "User" or "Organization".
17 + pub account_type: String,
18 + /// "all" or "selected".
19 + pub repository_selection: String,
20 + pub created_at: DateTime<Utc>,
21 +}
22 +
23 +impl Display for GithubInstallation {
24 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 + write!(f, "{} ({})", self.account_login, self.account_type)
26 + }
27 +}
28 +
29 +/// A GitHub repository accessible through an installation.
30 +#[derive(Deserialize, Serialize, Debug, Clone)]
31 +#[tsync]
32 +pub struct GithubRepository {
33 + /// GitHub repository id.
34 + pub id: i64,
35 + pub full_name: String,
36 + pub default_branch: String,
37 + pub private: bool,
38 +}
39 +
40 +impl Display for GithubRepository {
41 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 + write!(f, "{} [{}]", self.full_name, self.default_branch)
43 + }
44 +}
45 +
46 +/// A link between a DeployRepo and a GitHub repository. Pushes to
47 +/// `production_branch` trigger a deploy server-side.
48 +#[derive(Deserialize, Serialize, Debug, Clone)]
49 +#[tsync]
50 +pub struct GithubConnection {
51 + pub id: i64,
52 + pub deploy_repo_id: i64,
53 + pub github_installation_id: i64,
54 + pub github_repo_full_name: String,
55 + pub production_branch: String,
56 + pub created_at: DateTime<Utc>,
57 + pub updated_at: DateTime<Utc>,
58 +}
59 +
60 +/// Payload for connecting a GitHub repository to a DeployRepo.
61 +#[derive(Deserialize, Serialize, Debug, Clone)]
62 +#[tsync]
63 +pub struct GithubConnectionCreate {
64 + pub github_installation_id: i64,
65 + pub github_repo_full_name: String,
66 + /// Omit to let the server default to the repository's default branch.
67 + #[serde(default, skip_serializing_if = "Option::is_none")]
68 + pub production_branch: Option<String>,
69 +}
70 +
71 +/// Connection state of a DeployRepo, always returned with 200 so the CLI
72 +/// can distinguish "not connected" from request errors.
73 +#[derive(Deserialize, Serialize, Debug, Clone)]
74 +#[tsync]
75 +pub struct GithubConnectionStatus {
76 + pub connected: bool,
77 + #[serde(default, skip_serializing_if = "Option::is_none")]
78 + pub connection: Option<GithubConnection>,
79 +}
80 +
81 +#[cfg(test)]
82 +mod tests {
83 + use super::*;
84 + use serde_json::json;
85 +
86 + #[test]
87 + fn test_github_connection_create_omits_missing_branch() {
88 + let create = GithubConnectionCreate {
89 + github_installation_id: 42,
90 + github_repo_full_name: "octocat/hello-world".to_owned(),
91 + production_branch: None,
92 + };
93 + let value = serde_json::to_value(&create).unwrap();
94 + assert_eq!(value["github_installation_id"], json!(42));
95 + assert_eq!(value["github_repo_full_name"], json!("octocat/hello-world"));
96 + assert!(value.get("production_branch").is_none());
97 + }
98 +
99 + #[test]
100 + fn test_github_connection_create_includes_branch() {
101 + let create = GithubConnectionCreate {
102 + github_installation_id: 42,
103 + github_repo_full_name: "octocat/hello-world".to_owned(),
104 + production_branch: Some("main".to_owned()),
105 + };
106 + let value = serde_json::to_value(&create).unwrap();
107 + assert_eq!(value["production_branch"], json!("main"));
108 + }
109 +
110 + #[test]
111 + fn test_github_connection_status_deserializes_without_connection() {
112 + let status: GithubConnectionStatus =
113 + serde_json::from_value(json!({ "connected": false })).unwrap();
114 + assert!(!status.connected);
115 + assert!(status.connection.is_none());
116 + }
117 +
118 + #[test]
119 + fn test_github_connection_status_deserializes_with_connection() {
120 + let status: GithubConnectionStatus = serde_json::from_value(json!({
121 + "connected": true,
122 + "connection": {
123 + "id": 1,
124 + "deploy_repo_id": 5,
125 + "github_installation_id": 42,
126 + "github_repo_full_name": "octocat/hello-world",
127 + "production_branch": "main",
128 + "created_at": "2026-01-01T00:00:00Z",
129 + "updated_at": "2026-01-01T00:00:00Z"
130 + }
131 + }))
132 + .unwrap();
133 + assert!(status.connected);
134 + let connection = status.connection.expect("connection should be present");
135 + assert_eq!(connection.deploy_repo_id, 5);
136 + assert_eq!(connection.github_repo_full_name, "octocat/hello-world");
137 + assert_eq!(connection.production_branch, "main");
138 + }
139 +
140 + #[test]
141 + fn test_github_installation_round_trip() {
142 + let installation: GithubInstallation = serde_json::from_value(json!({
143 + "id": 42,
144 + "account_login": "octocat",
145 + "account_type": "User",
146 + "repository_selection": "selected",
147 + "created_at": "2026-01-01T00:00:00Z"
148 + }))
149 + .unwrap();
150 + assert_eq!(installation.to_string(), "octocat (User)");
151 + let value = serde_json::to_value(&installation).unwrap();
152 + assert_eq!(value["account_login"], json!("octocat"));
153 + }
154 +
155 + #[test]
156 + fn test_github_repository_round_trip() {
157 + let repository: GithubRepository = serde_json::from_value(json!({
158 + "id": 7,
159 + "full_name": "octocat/hello-world",
160 + "default_branch": "main",
161 + "private": true
162 + }))
163 + .unwrap();
164 + assert_eq!(repository.to_string(), "octocat/hello-world [main]");
165 + let value = serde_json::to_value(&repository).unwrap();
166 + assert_eq!(value["private"], json!(true));
167 + }
168 +}
crates/smbcloud-model/src/lib.rs
+1
@@ -5,6 +5,7 @@ pub mod deploy_config_update;
5 pub mod error_codes;
6 pub mod forgot;
7 pub mod frontend_app;
8 +pub mod github;
9 pub mod login;
10 pub mod mail;
11 pub mod oauth;
crates/smbcloud-networking-project/src/crud_github_connection.rs new
+98
@@ -0,0 +1,98 @@
1 +use {
2 + crate::url_builder::{
3 + build_deploy_repo_github_connection_url, build_github_installation_repositories_url,
4 + build_github_installations_url,
5 + },
6 + reqwest::Client,
7 + smbcloud_model::{
8 + error_codes::ErrorResponse,
9 + github::{
10 + GithubConnection, GithubConnectionCreate, GithubConnectionStatus, GithubInstallation,
11 + GithubRepository,
12 + },
13 + },
14 + smbcloud_network::{environment::Environment, network::request},
15 + smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient},
16 +};
17 +
18 +pub async fn get_github_installations(
19 + env: Environment,
20 + client: (&SmbClient, &str),
21 + access_token: String,
22 +) -> Result<Vec<GithubInstallation>, ErrorResponse> {
23 + let builder = Client::new()
24 + .get(build_github_installations_url(env, client))
25 + .header("Authorization", access_token)
26 + .header("User-agent", SMB_USER_AGENT);
27 + request(builder).await
28 +}
29 +
30 +pub async fn get_github_installation_repositories(
31 + env: Environment,
32 + client: (&SmbClient, &str),
33 + access_token: String,
34 + installation_id: i64,
35 +) -> Result<Vec<GithubRepository>, ErrorResponse> {
36 + let builder = Client::new()
37 + .get(build_github_installation_repositories_url(
38 + env,
39 + client,
40 + installation_id,
41 + ))
42 + .header("Authorization", access_token)
43 + .header("User-agent", SMB_USER_AGENT);
44 + request(builder).await
45 +}
46 +
47 +pub async fn get_github_connection(
48 + env: Environment,
49 + client: (&SmbClient, &str),
50 + access_token: String,
51 + deploy_repo_id: i64,
52 +) -> Result<GithubConnectionStatus, ErrorResponse> {
53 + let builder = Client::new()
54 + .get(build_deploy_repo_github_connection_url(
55 + env,
56 + client,
57 + deploy_repo_id,
58 + ))
59 + .header("Authorization", access_token)
60 + .header("User-agent", SMB_USER_AGENT);
61 + request(builder).await
62 +}
63 +
64 +pub async fn create_github_connection(
65 + env: Environment,
66 + client: (&SmbClient, &str),
67 + access_token: String,
68 + deploy_repo_id: i64,
69 + payload: GithubConnectionCreate,
70 +) -> Result<GithubConnection, ErrorResponse> {
71 + let builder = Client::new()
72 + .post(build_deploy_repo_github_connection_url(
73 + env,
74 + client,
75 + deploy_repo_id,
76 + ))
77 + .json(&payload)
78 + .header("Authorization", access_token)
79 + .header("User-agent", SMB_USER_AGENT);
80 + request(builder).await
81 +}
82 +
83 +pub async fn delete_github_connection(
84 + env: Environment,
85 + client: (&SmbClient, &str),
86 + access_token: String,
87 + deploy_repo_id: i64,
88 +) -> Result<(), ErrorResponse> {
89 + let builder = Client::new()
90 + .delete(build_deploy_repo_github_connection_url(
91 + env,
92 + client,
93 + deploy_repo_id,
94 + ))
95 + .header("Authorization", access_token)
96 + .header("User-agent", SMB_USER_AGENT);
97 + request(builder).await
98 +}
crates/smbcloud-networking-project/src/lib.rs
+1
@@ -3,6 +3,7 @@ pub mod crud_frontend_app_create;
3 pub mod crud_frontend_app_deploy_config;
4 pub mod crud_frontend_app_read;
5 pub mod crud_frontend_app_update_deploy_config;
6 +pub mod crud_github_connection;
7 pub mod crud_project_create;
8 pub mod crud_project_delete;
9 pub mod crud_project_deployment_create;
crates/smbcloud-networking-project/src/url_builder.rs
+33
@@ -9,6 +9,39 @@ pub(crate) fn build_deploy_repos_url(env: Environment, client: (&SmbClient, &str
9 url_builder.build()
10 }
11
12 +pub(crate) fn build_github_installations_url(
13 + env: Environment,
14 + client: (&SmbClient, &str),
15 +) -> String {
16 + let mut url_builder = smb_base_url_builder(env, client);
17 + url_builder.add_route("v1/github/installations");
18 + url_builder.build()
19 +}
20 +
21 +pub(crate) fn build_github_installation_repositories_url(
22 + env: Environment,
23 + client: (&SmbClient, &str),
24 + installation_id: i64,
25 +) -> String {
26 + let mut url_builder = smb_base_url_builder(env, client);
27 + url_builder.add_route("v1/github/installations");
28 + url_builder.add_route(&installation_id.to_string());
29 + url_builder.add_route("repositories");
30 + url_builder.build()
31 +}
32 +
33 +pub(crate) fn build_deploy_repo_github_connection_url(
34 + env: Environment,
35 + client: (&SmbClient, &str),
36 + deploy_repo_id: i64,
37 +) -> String {
38 + let mut url_builder = smb_base_url_builder(env, client);
39 + url_builder.add_route("v1/deploy_repos");
40 + url_builder.add_route(&deploy_repo_id.to_string());
41 + url_builder.add_route("github_connection");
42 + url_builder.build()
43 +}
44 +
45 pub(crate) fn build_frontend_apps_url(env: Environment, client: (&SmbClient, &str)) -> String {
46 let mut url_builder = smb_base_url_builder(env, client);
47 url_builder.add_route("v1/frontend_apps");
crates/smbcloud-networking/src/constants.rs
+7
@@ -6,6 +6,13 @@ pub const GH_OAUTH_CLIENT_ID: &str = "bf1f12d97659a6495e43";
6 pub const GH_OAUTH_REDIRECT_HOST: &str = "http://localhost";
7 pub const GH_OAUTH_REDIRECT_PORT: &str = "8808";
8
9 +// GitHub App slug used to build the installation URL. Provisional until the
10 +// app is registered; the dev app is separate so webhooks can target a dev API.
11 +#[cfg(debug_assertions)]
12 +pub const GH_APP_SLUG: &str = "smbcloud-dev";
13 +#[cfg(not(debug_assertions))]
14 +pub const GH_APP_SLUG: &str = "smbcloud";
15 +
16 pub const SMB_USER_AGENT: &str = "smbcloud-cli";
17
18 // Paths
docs/github-app.md new
+87
@@ -0,0 +1,87 @@
1 +# smbCloud GitHub App
2 +
3 +The smbCloud GitHub App connects a GitHub repository to an smbCloud project so
4 +that pushes to the production branch deploy automatically — no `smb deploy`
5 +needed. Deploy results are reported back to GitHub as commit statuses
6 +(`context: smbcloud/deploy`).
7 +
8 +## How it works
9 +
10 +1. You install the app on your GitHub account or organization and grant it
11 + access to one or more repositories.
12 +2. `smb github connect` links a repository (and a production branch) to your
13 + smbCloud project's deploy repo.
14 +3. On every push, GitHub delivers a `push` webhook to
15 + `https://api.smbcloud.xyz/v1/github/webhook`. The server verifies the
16 + webhook signature, matches the repository, installation, and branch against
17 + a connection, then builds and deploys the project's apps.
18 +4. The server records a deployment (visible via `smb project deployment`) and
19 + sets a commit status on the pushed SHA: `pending` while building, then
20 + `success` or `failure`.
21 +
22 +The webhook receiver and build pipeline are server-side; this repository ships
23 +the app manifest, the CLI commands, and the client-side API contract.
24 +
25 +## CLI usage
26 +
27 +All commands accept `--project-id <id>`; without it they use the current
28 +project (`smb project use --id <id>`). The project must have a deploy repo —
29 +run `smb deploy` once to initialize it.
30 +
31 +```sh
32 +smb github install # open the app installation page, wait for it to complete
33 +smb github connect # pick an installation + repository interactively
34 +smb github connect --repo <owner>/<repo> --branch main # non-interactive, CI-friendly
35 +smb github status # show the current connection
36 +smb github disconnect # remove the connection (asks for confirmation)
37 +```
38 +
39 +`smb github install` opens the browser and polls the API until a new
40 +installation appears. A GitHub App's setup URL is fixed app-wide and cannot
41 +point at `localhost`, so a local callback server (as used for OAuth login) is
42 +not possible here — polling is the intended flow. If the poll times out, just
43 +finish the installation in the browser and run `smb github connect`.
44 +
45 +## Registering the app (operator, one-time)
46 +
47 +The app is registered from [`github-app/manifest.json`](../github-app/manifest.json)
48 +using GitHub's [manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest):
49 +
50 +1. Serve a small HTML form that POSTs a `manifest` field containing the JSON to
51 + `https://github.com/settings/apps/new?state=<random>` (or
52 + `https://github.com/organizations/<org>/settings/apps/new?state=<random>`
53 + for an org-owned app).
54 +2. GitHub redirects to the manifest's `redirect_url` with a temporary `code`.
55 +3. Exchange it within one hour: `POST https://api.github.com/app-manifests/<code>/conversions`.
56 + The response contains the app id, slug, webhook secret, private key, and
57 + OAuth credentials — store these in your server configuration.
58 +4. If the granted slug differs from `smbcloud`, update `GH_APP_SLUG` in
59 + `crates/smbcloud-networking/src/constants.rs` (the debug build uses a
60 + separate `smbcloud-dev` app whose webhook targets a dev API).
61 +
62 +### Permissions and events
63 +
64 +| Manifest entry | Why |
65 +| --- | --- |
66 +| `contents: read` | Fetch the repository at the pushed commit to build it. |
67 +| `metadata: read` | Mandatory baseline for every GitHub App; powers repository listing. |
68 +| `statuses: write` | Report deploy state as a commit status (one POST per state change). The Checks API adds no value for v1; upgrading to `checks: write` later is additive. |
69 +| `default_events: ["push"]` | The only event auto-deploy needs. `installation` and `installation_repositories` events are always delivered to App webhooks without subscription — that is how the server tracks installs. |
70 +
71 +## API contract
72 +
73 +Endpoints the CLI calls (all under the standard authenticated `v1/` API;
74 +implemented server-side):
75 +
76 +| Method | Path | Request | Response |
77 +| --- | --- | --- | --- |
78 +| GET | `v1/github/installations` | — | `[GithubInstallation]` — installations belonging to the authenticated user (correlated via their linked GitHub account) |
79 +| GET | `v1/github/installations/{installation_id}/repositories` | — | `[GithubRepository]` |
80 +| GET | `v1/deploy_repos/{deploy_repo_id}/github_connection` | — | `GithubConnectionStatus` — always `200`, `connected: false` when absent |
81 +| POST | `v1/deploy_repos/{deploy_repo_id}/github_connection` | `GithubConnectionCreate` | `GithubConnection` — upsert; omitted `production_branch` defaults to the repository's default branch |
82 +| DELETE | `v1/deploy_repos/{deploy_repo_id}/github_connection` | — | `null` (`200`, idempotent) |
83 +| POST | `v1/github/webhook` | GitHub webhook payload | server-only; never called by the CLI |
84 +
85 +Response bodies must use `200`/`201` (not `204`). The serde models live in
86 +`crates/smbcloud-model/src/github.rs`; a connection may also arrive embedded in
87 +a `DeployRepo` payload as `github_connection`.
github-app/manifest.json new
+20
@@ -0,0 +1,20 @@
1 +{
2 + "name": "smbCloud",
3 + "url": "https://smbcloud.xyz",
4 + "description": "Deploy your GitHub repositories to smbCloud automatically on every push.",
5 + "hook_attributes": {
6 + "url": "https://api.smbcloud.xyz/v1/github/webhook",
7 + "active": true
8 + },
9 + "redirect_url": "https://smbcloud.xyz/github/registered",
10 + "callback_urls": [],
11 + "setup_url": "https://smbcloud.xyz/github/setup",
12 + "setup_on_update": true,
13 + "public": true,
14 + "default_permissions": {
15 + "contents": "read",
16 + "metadata": "read",
17 + "statuses": "write"
18 + },
19 + "default_events": ["push"]
20 +}