manta-cli 2.0.0-beta.63

Another CLI for ALPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Implements the `manta upgrade` command.
//!
//! Fetches the highest `v*` workspace tag from
//! <https://github.com/eth-cscs/manta/releases>, compares against the
//! currently running binary's version (`env!("CARGO_PKG_VERSION")`),
//! and replaces the binary with the platform-appropriate tarball.
//!
//! Workspace releases land under a single `v{{version}}` tag that
//! cargo-release cuts from manta-cli (see
//! `crates/manta-cli/Cargo.toml`'s `[package.metadata.release]
//! tag-name = "v{{version}}"`). manta-shared also cuts its own
//! `manta-shared-v*` tag, but the release.yml workflow trigger
//! filters strictly to `v*` — so for the GitHub-Releases purposes
//! that this command consults, only `v*` tags exist.
//!
//! Legacy per-crate `manta-cli-v*` tags from before the
//! consolidation are deliberately not matched here; an operator on
//! that line picks up the next `v*` release directly without going
//! through stale per-crate tags.
//!
//! Archive format is `.tar.xz` (cargo-dist default for Unix targets),
//! containing `manta-cli-{target}/manta` plus docs/completions. We
//! extract just the binary to a tempfile in the same directory as the
//! current exe (so a same-filesystem rename works), then
//! `fs::rename` it into place. The running process keeps executing
//! via the kept-open inode; subsequent `manta` invocations pick up
//! the new binary.

use std::env;
use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};

use anyhow::{Context, Error, Result, anyhow, bail};
use clap::ArgMatches;
use semver::Version;
use serde_json::{Value, json};
use tar::Archive;
use xz2::read::XzDecoder;

use crate::common::app_context::AppContext;
use crate::common::confirm::confirm;
use crate::output::action_result;

/// Dispatch the `manta upgrade` command.
///
/// Like `gen_autocomplete` and `gen_man`, this handler does NOT call
/// `get_api_token(ctx)` — `manta upgrade` talks to GitHub releases,
/// not the manta server, so there's no token to bootstrap.
pub async fn handle_upgrade(
  cli_upgrade: &ArgMatches,
  _ctx: &AppContext<'_>,
) -> Result<(), Error> {
  let check_only = cli_upgrade.get_flag("check");
  let dry_run = cli_upgrade.get_flag("dry-run");
  let assume_yes = cli_upgrade.get_flag("assume-yes");
  let output_owned: Option<String> =
    cli_upgrade.get_one::<String>("output").cloned();

  // The upgrade flow uses blocking I/O (reqwest::blocking +
  // xz2 + tar + fs::rename); off-load to a blocking thread to
  // keep the Tokio runtime free.
  tokio::task::spawn_blocking(move || {
    exec(check_only, dry_run, assume_yes, output_owned.as_deref())
  })
  .await
  .context("upgrade task panicked")?
}

const REPO_OWNER: &str = "eth-cscs";
const REPO_NAME: &str = "manta";
const TAG_PREFIX: &str = "v";
const USER_AGENT: &str = concat!("manta-cli/", env!("CARGO_PKG_VERSION"));

/// Result of the version check; serialised under `data` when
/// `--output json` is requested.
#[derive(serde::Serialize)]
struct VersionInfo<'a> {
  /// `CARGO_PKG_VERSION` of the running binary.
  current: &'a str,
  /// Highest matching tag on GitHub Releases (same major as `current`).
  latest: String,
  /// `rust target triple` of the running binary (e.g.
  /// `aarch64-apple-darwin`).
  target: &'a str,
  /// Full URL of the tarball that would be downloaded for `target`.
  asset_url: String,
  /// `true` when no upgrade is available (`latest <= current`).
  up_to_date: bool,
}

/// Run the upgrade flow synchronously: probe GitHub Releases for the
/// newest `v*` tag in the same major as the running binary, and (when
/// not in `--check` / `--dry-run`) download the platform tarball,
/// extract the `manta` binary to a same-directory tempfile, and
/// `fs::rename` it over the current executable.
///
/// Called from a `tokio::task::spawn_blocking` because every step
/// (HTTP, XZ decode, tar walk, filesystem rename) is blocking.
///
/// # Errors
///
/// - The current binary's version string is not valid semver.
/// - The host's target triple is not in the supported list (see
///   [`ensure_supported_target`]).
/// - The GitHub Releases API call fails or yields no matching tag.
/// - The user declined the confirmation prompt.
/// - The tarball download or extraction failed, or the rename over
///   the current binary failed (cross-filesystem, permissions, …).
pub fn exec(
  check_only: bool,
  dry_run: bool,
  assume_yes: bool,
  output_opt: Option<&str>,
) -> Result<()> {
  let current_str = env!("CARGO_PKG_VERSION");
  let current = Version::parse(current_str).with_context(|| {
    format!("could not parse current version '{current_str}'")
  })?;

  let target = self_update::get_target();
  ensure_supported_target(target)?;

  let client = reqwest::blocking::Client::new();
  let latest = fetch_latest_cli_version(&client, current.major)?;

  let asset_name = format!("manta-cli-{target}.tar.xz");
  let asset_url = format!(
    "https://github.com/{REPO_OWNER}/{REPO_NAME}/releases/download/\
     {TAG_PREFIX}{latest}/{asset_name}"
  );

  let up_to_date = latest <= current;
  let info = VersionInfo {
    current: current_str,
    latest: latest.to_string(),
    target,
    asset_url: asset_url.clone(),
    up_to_date,
  };

  if up_to_date {
    render_version_info(
      &format!("Already up to date (v{current_str})."),
      &info,
      output_opt,
    )?;
    return Ok(());
  }

  let message =
    format!("A newer manta is available: v{current_str} → v{latest}");
  render_version_info(&message, &info, output_opt)?;

  if check_only || dry_run {
    return Ok(());
  }

  // Warn (don't block) when the binary path looks brew-managed; brew
  // will simply overwrite our replacement on its next `brew upgrade`.
  let exe_path =
    env::current_exe().context("could not locate the running manta binary")?;
  if looks_like_homebrew_path(&exe_path) {
    eprintln!(
      "warning: this `manta` binary appears to be Homebrew-managed \
       ({}); consider `brew upgrade manta-cli` instead. Continuing anyway.",
      exe_path.display()
    );
  }

  if !confirm(
    &format!("Replace {} with v{latest}?", exe_path.display()),
    assume_yes,
  ) {
    bail!("upgrade cancelled by user");
  }

  let new_bin = download_and_extract(&client, &asset_url, target, &exe_path)?;
  fs::rename(&new_bin, &exe_path).with_context(|| {
    format!(
      "failed to replace {} with the new binary at {}",
      exe_path.display(),
      new_bin.display()
    )
  })?;

  let success_msg = format!("Replaced {} with v{latest}.", exe_path.display());
  if output_opt == Some("json") {
    action_result::print_with_data(
      &success_msg,
      &json!({"installed_version": latest.to_string()}),
      output_opt,
    )?;
  } else {
    println!("{success_msg}");
  }

  Ok(())
}

/// Print the version-info payload. In `--output json` mode this
/// emits the canonical `action_result` envelope; otherwise it lays
/// the fields out as readable text (the payload is too small for a
/// `comfy_table` to add value).
fn render_version_info(
  message: &str,
  info: &VersionInfo,
  output_opt: Option<&str>,
) -> Result<()> {
  if output_opt == Some("json") {
    return action_result::print_with_data(message, info, output_opt);
  }
  println!("{message}");
  println!("  current: v{}", info.current);
  println!("  latest:  v{}", info.latest);
  println!("  target:  {}", info.target);
  if !info.up_to_date {
    println!("  asset:   {}", info.asset_url);
  }
  Ok(())
}

/// Return an error if the rust target triple isn't one we publish
/// release tarballs for.
fn ensure_supported_target(target: &str) -> Result<()> {
  const SUPPORTED: &[&str] = &[
    "aarch64-apple-darwin",
    "aarch64-unknown-linux-gnu",
    "x86_64-apple-darwin",
    "x86_64-unknown-linux-gnu",
  ];
  if !SUPPORTED.contains(&target) {
    bail!(
      "no published release for target '{target}'. \
       Supported: {:?}. Build from source or open an issue.",
      SUPPORTED
    );
  }
  Ok(())
}

/// Hit the GitHub releases API, filter to `v*` tags in the same
/// major as `current_major`, and return the highest semver.
fn fetch_latest_cli_version(
  client: &reqwest::blocking::Client,
  current_major: u64,
) -> Result<Version> {
  let url =
    format!("https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/releases");
  // Need a `User-Agent` — GitHub rejects API requests without one.
  let resp: Vec<Value> = client
    .get(&url)
    .header(reqwest::header::USER_AGENT, USER_AGENT)
    .header(reqwest::header::ACCEPT, "application/vnd.github+json")
    .send()
    .with_context(|| format!("failed to GET {url}"))?
    .error_for_status()
    .with_context(|| format!("GitHub returned an error for {url}"))?
    .json()
    .context("failed to parse GitHub releases response as JSON")?;

  // Anchor the search to the current major so historical `v0.X` /
  // `v1.X` tags from before the consolidated tag scheme don't get
  // picked as "latest" while we wait for the next same-major bump
  // to ship. Auto-adapts when v3 lands.
  let mut versions: Vec<Version> = resp
    .iter()
    .filter_map(|r| r.get("tag_name").and_then(Value::as_str))
    .filter_map(|tag| tag.strip_prefix(TAG_PREFIX))
    .filter_map(|ver| Version::parse(ver).ok())
    .filter(|v| v.major >= current_major)
    .collect();

  versions.sort();
  versions.pop().ok_or_else(|| {
    anyhow!(
      "no '{TAG_PREFIX}*' releases with major >= {current_major} found at {url}"
    )
  })
}

/// Download the tarball, extract the `manta` binary into a tempfile
/// in the same directory as `exe_path` (so we can rename across the
/// same filesystem), set it executable, and return its path.
fn download_and_extract(
  client: &reqwest::blocking::Client,
  asset_url: &str,
  target: &str,
  exe_path: &Path,
) -> Result<PathBuf> {
  eprintln!("Downloading {asset_url}");
  let bytes = client
    .get(asset_url)
    .header(reqwest::header::USER_AGENT, USER_AGENT)
    .send()
    .with_context(|| format!("failed to GET {asset_url}"))?
    .error_for_status()
    .with_context(|| format!("download failed for {asset_url}"))?
    .bytes()
    .context("failed to read tarball bytes")?;

  let mut archive = Archive::new(XzDecoder::new(bytes.as_ref()));
  let inner_path = format!("manta-cli-{target}/manta");

  for entry in archive.entries().context("failed to iterate tar entries")? {
    let mut entry = entry.context("failed to read a tar entry")?;
    let path = entry.path().context("tar entry has no path")?.to_path_buf();
    if path.to_str() == Some(&inner_path) {
      let parent = exe_path.parent().ok_or_else(|| {
        anyhow!(
          "current exe path {} has no parent directory",
          exe_path.display()
        )
      })?;
      let tmp =
        parent.join(format!(".manta.upgrade.{}.tmp", std::process::id()));
      let mut out = File::create(&tmp).with_context(|| {
        format!("failed to create temp file {}", tmp.display())
      })?;
      let mut buf = Vec::new();
      entry
        .read_to_end(&mut buf)
        .context("failed to read tar entry bytes")?;
      out
        .write_all(&buf)
        .with_context(|| format!("failed to write {}", tmp.display()))?;
      out.sync_all().ok();
      drop(out);
      set_executable(&tmp)?;
      return Ok(tmp);
    }
  }

  bail!("tarball did not contain expected file '{inner_path}'")
}

#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
  use std::os::unix::fs::PermissionsExt;
  let mut perms = fs::metadata(path)
    .with_context(|| format!("failed to stat {}", path.display()))?
    .permissions();
  perms.set_mode(0o755);
  fs::set_permissions(path, perms)
    .with_context(|| format!("failed to chmod 755 {}", path.display()))
}

#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
  Ok(())
}

fn looks_like_homebrew_path(p: &Path) -> bool {
  let s = p.to_string_lossy();
  s.contains("/Cellar/")
    || s.starts_with("/opt/homebrew/")
    || s.starts_with("/usr/local/bin/")
}

#[cfg(test)]
mod tests {
  use super::*;
  use std::path::PathBuf;

  #[test]
  fn brew_path_detection_matches_arm_cellar() {
    let p =
      PathBuf::from("/opt/homebrew/Cellar/manta-cli/2.0.0-beta.27/bin/manta");
    assert!(looks_like_homebrew_path(&p));
  }

  #[test]
  fn brew_path_detection_matches_intel_cellar() {
    let p =
      PathBuf::from("/usr/local/Cellar/manta-cli/2.0.0-beta.27/bin/manta");
    assert!(looks_like_homebrew_path(&p));
  }

  #[test]
  fn brew_path_detection_matches_opt_homebrew_bin() {
    let p = PathBuf::from("/opt/homebrew/bin/manta");
    assert!(looks_like_homebrew_path(&p));
  }

  #[test]
  fn brew_path_detection_does_not_fire_for_cargo_home() {
    let p = PathBuf::from("/Users/alice/.cargo/bin/manta");
    assert!(!looks_like_homebrew_path(&p));
  }

  #[test]
  fn ensure_supported_target_accepts_known_targets() {
    assert!(ensure_supported_target("x86_64-apple-darwin").is_ok());
    assert!(ensure_supported_target("aarch64-apple-darwin").is_ok());
    assert!(ensure_supported_target("x86_64-unknown-linux-gnu").is_ok());
    assert!(ensure_supported_target("aarch64-unknown-linux-gnu").is_ok());
  }

  #[test]
  fn ensure_supported_target_rejects_unknown_target() {
    assert!(ensure_supported_target("riscv64-unknown-linux-gnu").is_err());
  }
}