//! `mnw-cli ota publish` — typed OTA release publisher. //! //! Replaces the old `server/deploy/ota-publish.sh`. Authenticates against the //! MNW SyncKit API, creates a release, registers the artifact (which returns a //! presigned S3 PUT URL), uploads the bytes, and verifies the public Tauri //! updater endpoint now serves it. //! //! Invoked as `mnw-cli ota publish [flags]` — `main()` routes here before the //! SSH daemon starts when the first argument is `ota`. use std::path::PathBuf; use anyhow::{bail, Context, Result}; use synckit_client::{SyncKitClient, SyncKitConfig}; const DEFAULT_SERVER: &str = "https://makenot.work"; const ALLOWED_TARGETS: &[&str] = &["linux", "darwin", "windows"]; const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"]; /// Entry point for the `ota` subcommand. `rest` is everything after `ota`. pub async fn run(rest: &[String]) -> Result<()> { match rest.first().map(String::as_str) { Some("publish") => publish(&rest[1..]).await, Some("-h") | Some("--help") | None => { print_usage(); Ok(()) } Some(other) => { eprintln!("Unknown ota subcommand: {other}\n"); print_usage(); std::process::exit(2); } } } fn print_usage() { eprintln!( "Usage: mnw-cli ota publish --slug SLUG --version X.Y.Z --target OS --arch ARCH --artifact FILE\n\ \n\ Required:\n\ \x20 --slug App slug (e.g. goingson, audiofiles)\n\ \x20 --version Semver version (e.g. 0.4.1)\n\ \x20 --target Target OS: {}\n\ \x20 --arch Architecture: {}\n\ \x20 --artifact Path to the built artifact file\n\ \n\ \x20 --api-key SyncKit app API key (env MNW_OTA_API_KEY)\n\ \x20 --key SyncKit SDK key (env MNW_OTA_KEY)\n\ \n\ Auth: defaults to MNW OAuth (opens a browser on this machine; required\n\ for accounts with 2FA). Pass --password (+ --email) to use password auth.\n\ \n\ Optional:\n\ \x20 --notes Release notes (default: empty)\n\ \x20 --signature Minisign signature for Tauri verification (REQUIRED for a working update)\n\ \x20 --release-id Attach to an existing release UUID instead of creating one\n\ \x20 (resume a publish whose artifact upload failed)\n\ \x20 --email MNW account email (env MNW_OTA_EMAIL; password auth only)\n\ \x20 --password MNW account password (env MNW_OTA_PASSWORD; enables password auth)\n\ \x20 --server Server URL (env MNW_OTA_SERVER, default {DEFAULT_SERVER})", ALLOWED_TARGETS.join(", "), ALLOWED_ARCHS.join(", "), ); } struct PublishArgs { slug: String, version: String, target: String, arch: String, artifact: PathBuf, notes: String, signature: String, // When set, attach the artifact to this existing release instead of creating // one (resume a failed upload; create would 409 on a duplicate version). release_id: Option, // email + password drive the legacy password auth path. When absent, the // publisher uses the interactive MNW OAuth flow (the default; required for // accounts with 2FA, which the password endpoint rejects). email: Option, password: Option, api_key: String, key: String, server: String, } // Manual Debug that redacts the credentials so they never reach logs or a // failing-test backtrace. impl std::fmt::Debug for PublishArgs { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PublishArgs") .field("slug", &self.slug) .field("version", &self.version) .field("target", &self.target) .field("arch", &self.arch) .field("artifact", &self.artifact) .field("notes", &self.notes) .field("signature", &self.signature) .field("release_id", &self.release_id) .field("email", &self.email) .field("password", &self.password.as_ref().map(|_| "")) .field("api_key", &"") .field("key", &"") .field("server", &self.server) .finish() } } /// Parse flags with environment-variable fallbacks for the credentials. fn parse_args(flags: &[String]) -> Result { let mut slug = None; let mut version = None; let mut target = None; let mut arch = None; let mut artifact = None; let mut notes = String::new(); let mut signature = String::new(); let mut release_id = None; let mut email = std::env::var("MNW_OTA_EMAIL").ok(); let mut password = std::env::var("MNW_OTA_PASSWORD").ok(); let mut api_key = std::env::var("MNW_OTA_API_KEY").ok(); let mut key = std::env::var("MNW_OTA_KEY").ok(); let mut server = std::env::var("MNW_OTA_SERVER").unwrap_or_else(|_| DEFAULT_SERVER.to_string()); let mut it = flags.iter(); while let Some(flag) = it.next() { let mut take = |name: &str| -> Result { it.next() .cloned() .with_context(|| format!("{name} requires a value")) }; match flag.as_str() { "--slug" => slug = Some(take("--slug")?), "--version" => version = Some(take("--version")?), "--target" => target = Some(take("--target")?), "--arch" => arch = Some(take("--arch")?), "--artifact" => artifact = Some(PathBuf::from(take("--artifact")?)), "--notes" => notes = take("--notes")?, "--signature" => signature = take("--signature")?, "--release-id" => release_id = Some(take("--release-id")?), "--email" => email = Some(take("--email")?), "--password" => password = Some(take("--password")?), "--api-key" => api_key = Some(take("--api-key")?), "--key" => key = Some(take("--key")?), "--server" => server = take("--server")?, "-h" | "--help" => { print_usage(); std::process::exit(0); } other => bail!("Unknown flag: {other}"), } } let missing = |name: &str| anyhow::anyhow!("missing required {name}"); let target = target.ok_or_else(|| missing("--target"))?; let arch = arch.ok_or_else(|| missing("--arch"))?; if !ALLOWED_TARGETS.contains(&target.as_str()) { bail!("invalid --target '{target}'. Allowed: {}", ALLOWED_TARGETS.join(", ")); } if !ALLOWED_ARCHS.contains(&arch.as_str()) { bail!("invalid --arch '{arch}'. Allowed: {}", ALLOWED_ARCHS.join(", ")); } Ok(PublishArgs { slug: slug.ok_or_else(|| missing("--slug"))?, version: version.ok_or_else(|| missing("--version"))?, target, arch, artifact: artifact.ok_or_else(|| missing("--artifact"))?, notes, signature, release_id, email, // optional: only used by the password auth path password, api_key: api_key.ok_or_else(|| missing("--api-key / MNW_OTA_API_KEY"))?, key: key.ok_or_else(|| missing("--key / MNW_OTA_KEY"))?, server, }) } async fn publish(flags: &[String]) -> Result<()> { let args = parse_args(flags)?; let bytes = std::fs::read(&args.artifact) .with_context(|| format!("reading artifact {}", args.artifact.display()))?; let file_size: i64 = bytes .len() .try_into() .context("artifact is too large to publish")?; if file_size == 0 { bail!("artifact is empty: {}", args.artifact.display()); } if args.signature.trim().is_empty() { eprintln!( "warning: --signature is empty. Tauri's updater silently refuses an update with no \ signature, so installed apps will NOT apply this release. Publish with the minisign \ signature of the artifact for a working update." ); } println!( "Publishing {} v{} ({}/{}, {} bytes) to {}", args.slug, args.version, args.target, args.arch, file_size, args.server ); let client = SyncKitClient::new(SyncKitConfig { server_url: args.server.clone(), api_key: args.api_key.clone(), }); // Default to MNW OAuth; fall back to password auth only when a password is // supplied. OAuth is required for accounts with 2FA (the password endpoint // rejects them) and keeps the password out of env/argv. match &args.password { Some(password) => { let email = args .email .as_deref() .context("--email / MNW_OTA_EMAIL is required with password auth")?; print!(" authenticating (password)... "); client .authenticate(email, password, &args.key) .await .context("authentication failed")?; println!("ok"); } None => { authenticate_oauth(&client, &args.key).await?; } } let app_id = client .session_info() .map(|s| s.app_id.to_string()) .unwrap_or_default(); println!(" authenticated (app {app_id})"); // With --release-id, attach to an existing release (resume a failed upload) // instead of creating one — create would 409 on a duplicate version. let release_id = match &args.release_id { Some(rid) => { let id = rid .parse::() .context("--release-id must be a UUID")?; println!(" using existing release {id} (skipping create)"); id } None => { print!(" creating release v{}... ", args.version); let release = client .ota_create_release(&args.version, &args.notes, &args.signature) .await .context("create release failed")?; println!("ok (release {})", release.id); release.id } }; print!(" registering artifact... "); let upload = client .ota_register_artifact(release_id, &args.target, &args.arch, file_size) .await .context("register artifact failed")?; println!("ok ({})", upload.s3_key); print!(" uploading {file_size} bytes... "); client .ota_upload_artifact(&upload.upload_url, bytes) .await .context("artifact upload failed")?; println!("ok"); print!(" verifying updater endpoint... "); match client .ota_updater_check(&args.slug, &args.target, &args.arch, "0.0.1") .await .context("updater check failed")? { Some(manifest) if manifest.version == args.version => { println!("ok (serving v{})", manifest.version); } Some(manifest) => { println!( "warning: updater serves v{} but just published v{} (a newer release may exist)", manifest.version, args.version ); } None => { println!( "warning: updater returned no update (204). The release was created but is not \ being served for {}/{} yet.", args.target, args.arch ); } } println!( "\nPublished {} v{} ({}/{})\nUpdater URL: {}/api/v1/sync/ota/{}/{}/{}/{}", args.slug, args.version, args.target, args.arch, args.server.trim_end_matches('/'), args.slug, args.target, args.arch, args.version, ); Ok(()) } /// Drive the MNW OAuth2 PKCE flow: bind a localhost redirect listener, open the /// browser to the authorize URL, capture the returned code, and exchange it for /// a session token. The browser must run on the same machine as this command /// (the redirect targets `http://127.0.0.1:/`). async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .context("failed to bind a localhost listener for the OAuth redirect")?; let port = listener.local_addr()?.port(); let pkce = synckit_client::generate_pkce(); let state = synckit_client::generate_oauth_state(); let url = client.build_authorize_url(port, &state, &pkce.challenge); println!("\n authenticating via MNW OAuth."); println!(" Open this URL in a browser on THIS machine and approve:"); println!(" {url}"); open_browser(&url); println!(" waiting for the authorization redirect (5 min timeout)..."); let (code, got_state) = tokio::time::timeout(std::time::Duration::from_secs(300), wait_for_code(&listener)) .await .context("timed out waiting for OAuth authorization")??; if got_state.as_deref() != Some(state.as_str()) { bail!("OAuth state mismatch — aborting (possible CSRF or a stale redirect)"); } client .authenticate_with_code(&code, &pkce.verifier, port, key) .await .context("OAuth code exchange failed")?; Ok(()) } /// Accept localhost connections until one carries an OAuth `code` (ignoring /// incidental requests like `/favicon.ico`), reply with a small page, and return /// `(code, state)`. async fn wait_for_code( listener: &tokio::net::TcpListener, ) -> Result<(String, Option)> { use tokio::io::AsyncReadExt; loop { let (mut sock, _) = listener.accept().await.context("accept failed")?; let mut buf = [0u8; 4096]; let n = sock.read(&mut buf).await.unwrap_or(0); let req = String::from_utf8_lossy(&buf[..n]); let target = req .lines() .next() .and_then(|line| line.split_whitespace().nth(1)) .unwrap_or(""); let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); let (mut code, mut state, mut oauth_err) = (None, None, None); for pair in query.split('&') { match pair.split_once('=') { // `code` (hex) and `state` (base64url) are URL-safe — no decode needed. Some(("code", v)) => code = Some(v.to_string()), Some(("state", v)) => state = Some(v.to_string()), Some(("error", v)) => oauth_err = Some(v.to_string()), _ => {} } } if let Some(e) = oauth_err { let _ = respond(&mut sock, "Authorization failed. You can close this tab.").await; bail!("authorization was denied: {e}"); } if let Some(c) = code { let _ = respond( &mut sock, "Authorization complete. You can close this tab and return to the terminal.", ) .await; return Ok((c, state)); } // Incidental request (favicon, etc.) — answer and keep waiting. let _ = respond(&mut sock, "Waiting for authorization...").await; } } /// Write a minimal HTML 200 response and close the connection. async fn respond(sock: &mut tokio::net::TcpStream, message: &str) -> std::io::Result<()> { use tokio::io::AsyncWriteExt; let body = format!( "

{message}

" ); let resp = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); sock.write_all(resp.as_bytes()).await?; sock.flush().await } /// Best-effort browser open; the URL is also printed for manual use (e.g. over SSH). fn open_browser(url: &str) { let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; let _ = std::process::Command::new(opener) .arg(url) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn(); } #[cfg(test)] mod tests { use super::*; fn base_flags() -> Vec { [ "--slug", "goingson", "--version", "0.4.1", "--target", "darwin", "--arch", "aarch64", "--artifact", "/tmp/x", "--email", "me@example.com", "--password", "pw", "--api-key", "ak", "--key", "sdk", "--server", "https://example.test", ] .iter() .map(|s| s.to_string()) .collect() } #[test] fn parses_full_flag_set() { let a = parse_args(&base_flags()).unwrap(); assert_eq!(a.slug, "goingson"); assert_eq!(a.version, "0.4.1"); assert_eq!(a.target, "darwin"); assert_eq!(a.arch, "aarch64"); assert_eq!(a.server, "https://example.test"); assert!(a.notes.is_empty()); } #[test] fn rejects_invalid_target() { let mut flags = base_flags(); let i = flags.iter().position(|f| f == "darwin").unwrap(); flags[i] = "macos".to_string(); let err = parse_args(&flags).unwrap_err().to_string(); assert!(err.contains("invalid --target"), "{err}"); } #[test] fn rejects_invalid_arch() { let mut flags = base_flags(); let i = flags.iter().position(|f| f == "aarch64").unwrap(); flags[i] = "arm64".to_string(); let err = parse_args(&flags).unwrap_err().to_string(); assert!(err.contains("invalid --arch"), "{err}"); } #[test] fn missing_required_flag_is_reported() { // Drop the trailing --server pair and the --slug pair. let flags: Vec = base_flags().into_iter().skip(2).collect(); // skip --slug goingson let err = parse_args(&flags).unwrap_err().to_string(); assert!(err.contains("--slug"), "{err}"); } #[test] fn flag_without_value_errors() { let err = parse_args(&["--slug".to_string()]).unwrap_err().to_string(); assert!(err.contains("--slug requires a value"), "{err}"); } }