| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
use std::path::PathBuf; |
| 12 |
|
| 13 |
use anyhow::{bail, Context, Result}; |
| 14 |
use synckit_client::{SyncKitClient, SyncKitConfig}; |
| 15 |
|
| 16 |
const DEFAULT_SERVER: &str = "https://makenot.work"; |
| 17 |
const ALLOWED_TARGETS: &[&str] = &["linux", "darwin", "windows"]; |
| 18 |
const ALLOWED_ARCHS: &[&str] = &["x86_64", "aarch64"]; |
| 19 |
|
| 20 |
|
| 21 |
pub async fn run(rest: &[String]) -> Result<()> { |
| 22 |
match rest.first().map(String::as_str) { |
| 23 |
Some("publish") => publish(&rest[1..]).await, |
| 24 |
Some("-h") | Some("--help") | None => { |
| 25 |
print_usage(); |
| 26 |
Ok(()) |
| 27 |
} |
| 28 |
Some(other) => { |
| 29 |
eprintln!("Unknown ota subcommand: {other}\n"); |
| 30 |
print_usage(); |
| 31 |
std::process::exit(2); |
| 32 |
} |
| 33 |
} |
| 34 |
} |
| 35 |
|
| 36 |
fn print_usage() { |
| 37 |
eprintln!( |
| 38 |
"Usage: mnw-cli ota publish --slug SLUG --version X.Y.Z --target OS --arch ARCH --artifact FILE\n\ |
| 39 |
\n\ |
| 40 |
Required:\n\ |
| 41 |
\x20 --slug App slug (e.g. goingson, audiofiles)\n\ |
| 42 |
\x20 --version Semver version (e.g. 0.4.1)\n\ |
| 43 |
\x20 --target Target OS: {}\n\ |
| 44 |
\x20 --arch Architecture: {}\n\ |
| 45 |
\x20 --artifact Path to the built artifact file\n\ |
| 46 |
\n\ |
| 47 |
\x20 --api-key SyncKit app API key (env MNW_OTA_API_KEY)\n\ |
| 48 |
\x20 --key SyncKit SDK key (env MNW_OTA_KEY)\n\ |
| 49 |
\n\ |
| 50 |
Auth: defaults to MNW OAuth (opens a browser on this machine; required\n\ |
| 51 |
for accounts with 2FA). Pass --password (+ --email) to use password auth.\n\ |
| 52 |
\n\ |
| 53 |
Optional:\n\ |
| 54 |
\x20 --notes Release notes (default: empty)\n\ |
| 55 |
\x20 --signature Minisign signature for Tauri verification (REQUIRED for a working update)\n\ |
| 56 |
\x20 --release-id Attach to an existing release UUID instead of creating one\n\ |
| 57 |
\x20 (resume a publish whose artifact upload failed)\n\ |
| 58 |
\x20 --email MNW account email (env MNW_OTA_EMAIL; password auth only)\n\ |
| 59 |
\x20 --password MNW account password (env MNW_OTA_PASSWORD; enables password auth)\n\ |
| 60 |
\x20 --server Server URL (env MNW_OTA_SERVER, default {DEFAULT_SERVER})", |
| 61 |
ALLOWED_TARGETS.join(", "), |
| 62 |
ALLOWED_ARCHS.join(", "), |
| 63 |
); |
| 64 |
} |
| 65 |
|
| 66 |
struct PublishArgs { |
| 67 |
slug: String, |
| 68 |
version: String, |
| 69 |
target: String, |
| 70 |
arch: String, |
| 71 |
artifact: PathBuf, |
| 72 |
notes: String, |
| 73 |
signature: String, |
| 74 |
|
| 75 |
|
| 76 |
release_id: Option<String>, |
| 77 |
|
| 78 |
|
| 79 |
|
| 80 |
email: Option<String>, |
| 81 |
password: Option<String>, |
| 82 |
api_key: String, |
| 83 |
key: String, |
| 84 |
server: String, |
| 85 |
} |
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
impl std::fmt::Debug for PublishArgs { |
| 90 |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 91 |
f.debug_struct("PublishArgs") |
| 92 |
.field("slug", &self.slug) |
| 93 |
.field("version", &self.version) |
| 94 |
.field("target", &self.target) |
| 95 |
.field("arch", &self.arch) |
| 96 |
.field("artifact", &self.artifact) |
| 97 |
.field("notes", &self.notes) |
| 98 |
.field("signature", &self.signature) |
| 99 |
.field("release_id", &self.release_id) |
| 100 |
.field("email", &self.email) |
| 101 |
.field("password", &self.password.as_ref().map(|_| "<redacted>")) |
| 102 |
.field("api_key", &"<redacted>") |
| 103 |
.field("key", &"<redacted>") |
| 104 |
.field("server", &self.server) |
| 105 |
.finish() |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
|
| 110 |
fn parse_args(flags: &[String]) -> Result<PublishArgs> { |
| 111 |
let mut slug = None; |
| 112 |
let mut version = None; |
| 113 |
let mut target = None; |
| 114 |
let mut arch = None; |
| 115 |
let mut artifact = None; |
| 116 |
let mut notes = String::new(); |
| 117 |
let mut signature = String::new(); |
| 118 |
let mut release_id = None; |
| 119 |
let mut email = std::env::var("MNW_OTA_EMAIL").ok(); |
| 120 |
let mut password = std::env::var("MNW_OTA_PASSWORD").ok(); |
| 121 |
let mut api_key = std::env::var("MNW_OTA_API_KEY").ok(); |
| 122 |
let mut key = std::env::var("MNW_OTA_KEY").ok(); |
| 123 |
let mut server = std::env::var("MNW_OTA_SERVER").unwrap_or_else(|_| DEFAULT_SERVER.to_string()); |
| 124 |
|
| 125 |
let mut it = flags.iter(); |
| 126 |
while let Some(flag) = it.next() { |
| 127 |
let mut take = |name: &str| -> Result<String> { |
| 128 |
it.next() |
| 129 |
.cloned() |
| 130 |
.with_context(|| format!("{name} requires a value")) |
| 131 |
}; |
| 132 |
match flag.as_str() { |
| 133 |
"--slug" => slug = Some(take("--slug")?), |
| 134 |
"--version" => version = Some(take("--version")?), |
| 135 |
"--target" => target = Some(take("--target")?), |
| 136 |
"--arch" => arch = Some(take("--arch")?), |
| 137 |
"--artifact" => artifact = Some(PathBuf::from(take("--artifact")?)), |
| 138 |
"--notes" => notes = take("--notes")?, |
| 139 |
"--signature" => signature = take("--signature")?, |
| 140 |
"--release-id" => release_id = Some(take("--release-id")?), |
| 141 |
"--email" => email = Some(take("--email")?), |
| 142 |
"--password" => password = Some(take("--password")?), |
| 143 |
"--api-key" => api_key = Some(take("--api-key")?), |
| 144 |
"--key" => key = Some(take("--key")?), |
| 145 |
"--server" => server = take("--server")?, |
| 146 |
"-h" | "--help" => { |
| 147 |
print_usage(); |
| 148 |
std::process::exit(0); |
| 149 |
} |
| 150 |
other => bail!("Unknown flag: {other}"), |
| 151 |
} |
| 152 |
} |
| 153 |
|
| 154 |
let missing = |name: &str| anyhow::anyhow!("missing required {name}"); |
| 155 |
let target = target.ok_or_else(|| missing("--target"))?; |
| 156 |
let arch = arch.ok_or_else(|| missing("--arch"))?; |
| 157 |
|
| 158 |
if !ALLOWED_TARGETS.contains(&target.as_str()) { |
| 159 |
bail!("invalid --target '{target}'. Allowed: {}", ALLOWED_TARGETS.join(", ")); |
| 160 |
} |
| 161 |
if !ALLOWED_ARCHS.contains(&arch.as_str()) { |
| 162 |
bail!("invalid --arch '{arch}'. Allowed: {}", ALLOWED_ARCHS.join(", ")); |
| 163 |
} |
| 164 |
|
| 165 |
Ok(PublishArgs { |
| 166 |
slug: slug.ok_or_else(|| missing("--slug"))?, |
| 167 |
version: version.ok_or_else(|| missing("--version"))?, |
| 168 |
target, |
| 169 |
arch, |
| 170 |
artifact: artifact.ok_or_else(|| missing("--artifact"))?, |
| 171 |
notes, |
| 172 |
signature, |
| 173 |
release_id, |
| 174 |
email, |
| 175 |
password, |
| 176 |
api_key: api_key.ok_or_else(|| missing("--api-key / MNW_OTA_API_KEY"))?, |
| 177 |
key: key.ok_or_else(|| missing("--key / MNW_OTA_KEY"))?, |
| 178 |
server, |
| 179 |
}) |
| 180 |
} |
| 181 |
|
| 182 |
async fn publish(flags: &[String]) -> Result<()> { |
| 183 |
let args = parse_args(flags)?; |
| 184 |
|
| 185 |
let bytes = std::fs::read(&args.artifact) |
| 186 |
.with_context(|| format!("reading artifact {}", args.artifact.display()))?; |
| 187 |
let file_size: i64 = bytes |
| 188 |
.len() |
| 189 |
.try_into() |
| 190 |
.context("artifact is too large to publish")?; |
| 191 |
if file_size == 0 { |
| 192 |
bail!("artifact is empty: {}", args.artifact.display()); |
| 193 |
} |
| 194 |
|
| 195 |
if args.signature.trim().is_empty() { |
| 196 |
eprintln!( |
| 197 |
"warning: --signature is empty. Tauri's updater silently refuses an update with no \ |
| 198 |
signature, so installed apps will NOT apply this release. Publish with the minisign \ |
| 199 |
signature of the artifact for a working update." |
| 200 |
); |
| 201 |
} |
| 202 |
|
| 203 |
println!( |
| 204 |
"Publishing {} v{} ({}/{}, {} bytes) to {}", |
| 205 |
args.slug, args.version, args.target, args.arch, file_size, args.server |
| 206 |
); |
| 207 |
|
| 208 |
let client = SyncKitClient::new(SyncKitConfig { |
| 209 |
server_url: args.server.clone(), |
| 210 |
api_key: args.api_key.clone(), |
| 211 |
}); |
| 212 |
|
| 213 |
|
| 214 |
|
| 215 |
|
| 216 |
match &args.password { |
| 217 |
Some(password) => { |
| 218 |
let email = args |
| 219 |
.email |
| 220 |
.as_deref() |
| 221 |
.context("--email / MNW_OTA_EMAIL is required with password auth")?; |
| 222 |
print!(" authenticating (password)... "); |
| 223 |
client |
| 224 |
.authenticate(email, password, &args.key) |
| 225 |
.await |
| 226 |
.context("authentication failed")?; |
| 227 |
println!("ok"); |
| 228 |
} |
| 229 |
None => { |
| 230 |
authenticate_oauth(&client, &args.key).await?; |
| 231 |
} |
| 232 |
} |
| 233 |
let app_id = client |
| 234 |
.session_info() |
| 235 |
.map(|s| s.app_id.to_string()) |
| 236 |
.unwrap_or_default(); |
| 237 |
println!(" authenticated (app {app_id})"); |
| 238 |
|
| 239 |
|
| 240 |
|
| 241 |
let release_id = match &args.release_id { |
| 242 |
Some(rid) => { |
| 243 |
let id = rid |
| 244 |
.parse::<uuid::Uuid>() |
| 245 |
.context("--release-id must be a UUID")?; |
| 246 |
println!(" using existing release {id} (skipping create)"); |
| 247 |
id |
| 248 |
} |
| 249 |
None => { |
| 250 |
print!(" creating release v{}... ", args.version); |
| 251 |
let release = client |
| 252 |
.ota_create_release(&args.version, &args.notes, &args.signature) |
| 253 |
.await |
| 254 |
.context("create release failed")?; |
| 255 |
println!("ok (release {})", release.id); |
| 256 |
release.id |
| 257 |
} |
| 258 |
}; |
| 259 |
|
| 260 |
print!(" registering artifact... "); |
| 261 |
let upload = client |
| 262 |
.ota_register_artifact(release_id, &args.target, &args.arch, file_size) |
| 263 |
.await |
| 264 |
.context("register artifact failed")?; |
| 265 |
println!("ok ({})", upload.s3_key); |
| 266 |
|
| 267 |
print!(" uploading {file_size} bytes... "); |
| 268 |
client |
| 269 |
.ota_upload_artifact(&upload.upload_url, bytes) |
| 270 |
.await |
| 271 |
.context("artifact upload failed")?; |
| 272 |
println!("ok"); |
| 273 |
|
| 274 |
print!(" verifying updater endpoint... "); |
| 275 |
match client |
| 276 |
.ota_updater_check(&args.slug, &args.target, &args.arch, "0.0.1") |
| 277 |
.await |
| 278 |
.context("updater check failed")? |
| 279 |
{ |
| 280 |
Some(manifest) if manifest.version == args.version => { |
| 281 |
println!("ok (serving v{})", manifest.version); |
| 282 |
} |
| 283 |
Some(manifest) => { |
| 284 |
println!( |
| 285 |
"warning: updater serves v{} but just published v{} (a newer release may exist)", |
| 286 |
manifest.version, args.version |
| 287 |
); |
| 288 |
} |
| 289 |
None => { |
| 290 |
println!( |
| 291 |
"warning: updater returned no update (204). The release was created but is not \ |
| 292 |
being served for {}/{} yet.", |
| 293 |
args.target, args.arch |
| 294 |
); |
| 295 |
} |
| 296 |
} |
| 297 |
|
| 298 |
println!( |
| 299 |
"\nPublished {} v{} ({}/{})\nUpdater URL: {}/api/v1/sync/ota/{}/{}/{}/{}", |
| 300 |
args.slug, |
| 301 |
args.version, |
| 302 |
args.target, |
| 303 |
args.arch, |
| 304 |
args.server.trim_end_matches('/'), |
| 305 |
args.slug, |
| 306 |
args.target, |
| 307 |
args.arch, |
| 308 |
args.version, |
| 309 |
); |
| 310 |
Ok(()) |
| 311 |
} |
| 312 |
|
| 313 |
|
| 314 |
|
| 315 |
|
| 316 |
|
| 317 |
async fn authenticate_oauth(client: &SyncKitClient, key: &str) -> Result<()> { |
| 318 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0") |
| 319 |
.await |
| 320 |
.context("failed to bind a localhost listener for the OAuth redirect")?; |
| 321 |
let port = listener.local_addr()?.port(); |
| 322 |
|
| 323 |
let pkce = synckit_client::generate_pkce(); |
| 324 |
let state = synckit_client::generate_oauth_state(); |
| 325 |
let url = client.build_authorize_url(port, &state, &pkce.challenge); |
| 326 |
|
| 327 |
println!("\n authenticating via MNW OAuth."); |
| 328 |
println!(" Open this URL in a browser on THIS machine and approve:"); |
| 329 |
println!(" {url}"); |
| 330 |
open_browser(&url); |
| 331 |
println!(" waiting for the authorization redirect (5 min timeout)..."); |
| 332 |
|
| 333 |
let (code, got_state) = |
| 334 |
tokio::time::timeout(std::time::Duration::from_secs(300), wait_for_code(&listener)) |
| 335 |
.await |
| 336 |
.context("timed out waiting for OAuth authorization")??; |
| 337 |
|
| 338 |
if got_state.as_deref() != Some(state.as_str()) { |
| 339 |
bail!("OAuth state mismatch — aborting (possible CSRF or a stale redirect)"); |
| 340 |
} |
| 341 |
|
| 342 |
client |
| 343 |
.authenticate_with_code(&code, &pkce.verifier, port, key) |
| 344 |
.await |
| 345 |
.context("OAuth code exchange failed")?; |
| 346 |
Ok(()) |
| 347 |
} |
| 348 |
|
| 349 |
|
| 350 |
|
| 351 |
|
| 352 |
async fn wait_for_code( |
| 353 |
listener: &tokio::net::TcpListener, |
| 354 |
) -> Result<(String, Option<String>)> { |
| 355 |
use tokio::io::AsyncReadExt; |
| 356 |
loop { |
| 357 |
let (mut sock, _) = listener.accept().await.context("accept failed")?; |
| 358 |
let mut buf = [0u8; 4096]; |
| 359 |
let n = sock.read(&mut buf).await.unwrap_or(0); |
| 360 |
let req = String::from_utf8_lossy(&buf[..n]); |
| 361 |
let target = req |
| 362 |
.lines() |
| 363 |
.next() |
| 364 |
.and_then(|line| line.split_whitespace().nth(1)) |
| 365 |
.unwrap_or(""); |
| 366 |
let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); |
| 367 |
|
| 368 |
let (mut code, mut state, mut oauth_err) = (None, None, None); |
| 369 |
for pair in query.split('&') { |
| 370 |
match pair.split_once('=') { |
| 371 |
|
| 372 |
Some(("code", v)) => code = Some(v.to_string()), |
| 373 |
Some(("state", v)) => state = Some(v.to_string()), |
| 374 |
Some(("error", v)) => oauth_err = Some(v.to_string()), |
| 375 |
_ => {} |
| 376 |
} |
| 377 |
} |
| 378 |
|
| 379 |
if let Some(e) = oauth_err { |
| 380 |
let _ = respond(&mut sock, "Authorization failed. You can close this tab.").await; |
| 381 |
bail!("authorization was denied: {e}"); |
| 382 |
} |
| 383 |
if let Some(c) = code { |
| 384 |
let _ = respond( |
| 385 |
&mut sock, |
| 386 |
"Authorization complete. You can close this tab and return to the terminal.", |
| 387 |
) |
| 388 |
.await; |
| 389 |
return Ok((c, state)); |
| 390 |
} |
| 391 |
|
| 392 |
let _ = respond(&mut sock, "Waiting for authorization...").await; |
| 393 |
} |
| 394 |
} |
| 395 |
|
| 396 |
|
| 397 |
async fn respond(sock: &mut tokio::net::TcpStream, message: &str) -> std::io::Result<()> { |
| 398 |
use tokio::io::AsyncWriteExt; |
| 399 |
let body = format!( |
| 400 |
"<!doctype html><meta charset=utf-8><body style=\"font-family:system-ui;padding:2rem\"><p>{message}</p></body>" |
| 401 |
); |
| 402 |
let resp = format!( |
| 403 |
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", |
| 404 |
body.len(), |
| 405 |
body |
| 406 |
); |
| 407 |
sock.write_all(resp.as_bytes()).await?; |
| 408 |
sock.flush().await |
| 409 |
} |
| 410 |
|
| 411 |
|
| 412 |
fn open_browser(url: &str) { |
| 413 |
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" }; |
| 414 |
let _ = std::process::Command::new(opener) |
| 415 |
.arg(url) |
| 416 |
.stdout(std::process::Stdio::null()) |
| 417 |
.stderr(std::process::Stdio::null()) |
| 418 |
.spawn(); |
| 419 |
} |
| 420 |
|
| 421 |
#[cfg(test)] |
| 422 |
mod tests { |
| 423 |
use super::*; |
| 424 |
|
| 425 |
fn base_flags() -> Vec<String> { |
| 426 |
[ |
| 427 |
"--slug", "goingson", |
| 428 |
"--version", "0.4.1", |
| 429 |
"--target", "darwin", |
| 430 |
"--arch", "aarch64", |
| 431 |
"--artifact", "/tmp/x", |
| 432 |
"--email", "me@example.com", |
| 433 |
"--password", "pw", |
| 434 |
"--api-key", "ak", |
| 435 |
"--key", "sdk", |
| 436 |
"--server", "https://example.test", |
| 437 |
] |
| 438 |
.iter() |
| 439 |
.map(|s| s.to_string()) |
| 440 |
.collect() |
| 441 |
} |
| 442 |
|
| 443 |
#[test] |
| 444 |
fn parses_full_flag_set() { |
| 445 |
let a = parse_args(&base_flags()).unwrap(); |
| 446 |
assert_eq!(a.slug, "goingson"); |
| 447 |
assert_eq!(a.version, "0.4.1"); |
| 448 |
assert_eq!(a.target, "darwin"); |
| 449 |
assert_eq!(a.arch, "aarch64"); |
| 450 |
assert_eq!(a.server, "https://example.test"); |
| 451 |
assert!(a.notes.is_empty()); |
| 452 |
} |
| 453 |
|
| 454 |
#[test] |
| 455 |
fn rejects_invalid_target() { |
| 456 |
let mut flags = base_flags(); |
| 457 |
let i = flags.iter().position(|f| f == "darwin").unwrap(); |
| 458 |
flags[i] = "macos".to_string(); |
| 459 |
let err = parse_args(&flags).unwrap_err().to_string(); |
| 460 |
assert!(err.contains("invalid --target"), "{err}"); |
| 461 |
} |
| 462 |
|
| 463 |
#[test] |
| 464 |
fn rejects_invalid_arch() { |
| 465 |
let mut flags = base_flags(); |
| 466 |
let i = flags.iter().position(|f| f == "aarch64").unwrap(); |
| 467 |
flags[i] = "arm64".to_string(); |
| 468 |
let err = parse_args(&flags).unwrap_err().to_string(); |
| 469 |
assert!(err.contains("invalid --arch"), "{err}"); |
| 470 |
} |
| 471 |
|
| 472 |
#[test] |
| 473 |
fn missing_required_flag_is_reported() { |
| 474 |
|
| 475 |
let flags: Vec<String> = base_flags().into_iter().skip(2).collect(); |
| 476 |
let err = parse_args(&flags).unwrap_err().to_string(); |
| 477 |
assert!(err.contains("--slug"), "{err}"); |
| 478 |
} |
| 479 |
|
| 480 |
#[test] |
| 481 |
fn flag_without_value_errors() { |
| 482 |
let err = parse_args(&["--slug".to_string()]).unwrap_err().to_string(); |
| 483 |
assert!(err.contains("--slug requires a value"), "{err}"); |
| 484 |
} |
| 485 |
} |
| 486 |
|