Skip to main content

max / makenotwork

17.8 KB · 486 lines History Blame Raw
1 //! `mnw-cli ota publish` — typed OTA release publisher.
2 //!
3 //! Replaces the old `server/deploy/ota-publish.sh`. Authenticates against the
4 //! MNW SyncKit API, creates a release, registers the artifact (which returns a
5 //! presigned S3 PUT URL), uploads the bytes, and verifies the public Tauri
6 //! updater endpoint now serves it.
7 //!
8 //! Invoked as `mnw-cli ota publish [flags]` — `main()` routes here before the
9 //! SSH daemon starts when the first argument is `ota`.
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 /// Entry point for the `ota` subcommand. `rest` is everything after `ota`.
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 // When set, attach the artifact to this existing release instead of creating
75 // one (resume a failed upload; create would 409 on a duplicate version).
76 release_id: Option<String>,
77 // email + password drive the legacy password auth path. When absent, the
78 // publisher uses the interactive MNW OAuth flow (the default; required for
79 // accounts with 2FA, which the password endpoint rejects).
80 email: Option<String>,
81 password: Option<String>,
82 api_key: String,
83 key: String,
84 server: String,
85 }
86
87 // Manual Debug that redacts the credentials so they never reach logs or a
88 // failing-test backtrace.
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 /// Parse flags with environment-variable fallbacks for the credentials.
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, // optional: only used by the password auth path
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 // Default to MNW OAuth; fall back to password auth only when a password is
214 // supplied. OAuth is required for accounts with 2FA (the password endpoint
215 // rejects them) and keeps the password out of env/argv.
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 // With --release-id, attach to an existing release (resume a failed upload)
240 // instead of creating one — create would 409 on a duplicate version.
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 /// Drive the MNW OAuth2 PKCE flow: bind a localhost redirect listener, open the
314 /// browser to the authorize URL, capture the returned code, and exchange it for
315 /// a session token. The browser must run on the same machine as this command
316 /// (the redirect targets `http://127.0.0.1:<port>/`).
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 /// Accept localhost connections until one carries an OAuth `code` (ignoring
350 /// incidental requests like `/favicon.ico`), reply with a small page, and return
351 /// `(code, state)`.
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 // `code` (hex) and `state` (base64url) are URL-safe — no decode needed.
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 // Incidental request (favicon, etc.) — answer and keep waiting.
392 let _ = respond(&mut sock, "Waiting for authorization...").await;
393 }
394 }
395
396 /// Write a minimal HTML 200 response and close the connection.
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 /// Best-effort browser open; the URL is also printed for manual use (e.g. over SSH).
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 // Drop the trailing --server pair and the --slug pair.
475 let flags: Vec<String> = base_flags().into_iter().skip(2).collect(); // skip --slug goingson
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