reallink-cli 0.1.6 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,12 +9,15 @@ npm install -g reallink-cli
9
9
  ```
10
10
 
11
11
  `npm install` performs a one-time Rust release build during postinstall. After that, `reallink` runs the compiled binary directly (no per-command cargo compile step).
12
+ CLI caches update metadata daily. To keep command startup fast, network update checks run during `reallink login` (or explicitly via `reallink self-update --check`) rather than every command.
12
13
 
13
14
  ## Commands
14
15
 
15
16
  ```bash
16
17
  reallink login --base-url https://api.real-agent.link
17
18
  reallink login --force # replace current saved session
19
+ reallink self-update --check
20
+ reallink self-update
18
21
  reallink whoami
19
22
  reallink logout
20
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reallink-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.9",
4
4
  "description": "Rust-based CLI for Reallink auth and API operations",
5
5
  "bin": {
6
6
  "reallink": "bin/reallink.cjs"
package/rust/Cargo.lock CHANGED
@@ -910,7 +910,7 @@ dependencies = [
910
910
 
911
911
  [[package]]
912
912
  name = "reallink-cli"
913
- version = "0.1.6"
913
+ version = "0.1.9"
914
914
  dependencies = [
915
915
  "anyhow",
916
916
  "clap",
package/rust/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "reallink-cli"
3
- version = "0.1.6"
3
+ version = "0.1.9"
4
4
  edition = "2021"
5
5
  description = "CLI for Reallink auth and token workflows"
6
6
  license = "MIT"
package/rust/src/main.rs CHANGED
@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
5
5
  use std::fs;
6
6
  use std::io::{self, Write};
7
7
  use std::path::{Path, PathBuf};
8
+ use std::process::Command;
8
9
  use std::time::{Duration, SystemTime, UNIX_EPOCH};
9
10
  use tokio::time::sleep;
10
11
 
@@ -12,9 +13,12 @@ const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
12
13
  const CONFIG_DIR_ENV: &str = "REALLINK_CONFIG_DIR";
13
14
  const SESSION_DIR_NAME: &str = "reallink";
14
15
  const SESSION_FILE_NAME: &str = "session.json";
16
+ const UPDATE_CACHE_FILE_NAME: &str = "update-check.json";
17
+ const VERSION_CHECK_INTERVAL_MS: u128 = 24 * 60 * 60 * 1000;
18
+ const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
15
19
 
16
20
  #[derive(Parser)]
17
- #[command(name = "reallink", version, about = "Reallink CLI")]
21
+ #[command(name = "reallink", bin_name = "reallink", version, about = "Reallink CLI")]
18
22
  struct Cli {
19
23
  #[command(subcommand)]
20
24
  command: Commands,
@@ -25,6 +29,7 @@ enum Commands {
25
29
  Login(LoginArgs),
26
30
  Whoami(BaseArgs),
27
31
  Logout,
32
+ SelfUpdate(SelfUpdateArgs),
28
33
  Project {
29
34
  #[command(subcommand)]
30
35
  command: ProjectCommands,
@@ -49,6 +54,12 @@ struct BaseArgs {
49
54
  base_url: Option<String>,
50
55
  }
51
56
 
57
+ #[derive(Args)]
58
+ struct SelfUpdateArgs {
59
+ #[arg(long, help = "Only check for updates; do not install")]
60
+ check: bool,
61
+ }
62
+
52
63
  #[derive(Args)]
53
64
  struct LoginArgs {
54
65
  #[arg(long, default_value = DEFAULT_BASE_URL)]
@@ -337,6 +348,12 @@ struct SessionConfig {
337
348
  updated_at_epoch_ms: u128,
338
349
  }
339
350
 
351
+ #[derive(Debug, Serialize, Deserialize, Clone)]
352
+ struct UpdateCheckCache {
353
+ last_checked_epoch_ms: u128,
354
+ latest_version: Option<String>,
355
+ }
356
+
340
357
  #[derive(Debug, Serialize, Deserialize)]
341
358
  #[serde(rename_all = "camelCase")]
342
359
  struct DeviceCodeRequest {
@@ -611,6 +628,11 @@ fn config_path() -> Result<PathBuf> {
611
628
  Ok(base.join(SESSION_DIR_NAME).join(SESSION_FILE_NAME))
612
629
  }
613
630
 
631
+ fn update_cache_path() -> Result<PathBuf> {
632
+ let base = resolve_config_root()?;
633
+ Ok(base.join(SESSION_DIR_NAME).join(UPDATE_CACHE_FILE_NAME))
634
+ }
635
+
614
636
  fn session_path_display() -> String {
615
637
  config_path()
616
638
  .map(|path| path.display().to_string())
@@ -666,6 +688,22 @@ fn clear_session() -> Result<bool> {
666
688
  Ok(false)
667
689
  }
668
690
 
691
+ fn load_update_cache() -> Option<UpdateCheckCache> {
692
+ let path = update_cache_path().ok()?;
693
+ let raw = fs::read(path).ok()?;
694
+ serde_json::from_slice(&raw).ok()
695
+ }
696
+
697
+ fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
698
+ let path = update_cache_path()?;
699
+ if let Some(parent) = path.parent() {
700
+ fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
701
+ }
702
+ let payload = serde_json::to_vec_pretty(cache)?;
703
+ write_atomic(&path, &payload)?;
704
+ Ok(())
705
+ }
706
+
669
707
  async fn read_error_body(response: reqwest::Response) -> String {
670
708
  match response.text().await {
671
709
  Ok(text) if !text.trim().is_empty() => text,
@@ -673,6 +711,92 @@ async fn read_error_body(response: reqwest::Response) -> String {
673
711
  }
674
712
  }
675
713
 
714
+ fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
715
+ request
716
+ .header("x-reallink-client", "cli")
717
+ .header("x-reallink-cli-version", env!("CARGO_PKG_VERSION"))
718
+ }
719
+
720
+ fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
721
+ let core = version.trim().split('-').next()?;
722
+ let mut parts = core.split('.');
723
+ let major = parts.next()?.parse::<u64>().ok()?;
724
+ let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
725
+ let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
726
+ Some((major, minor, patch))
727
+ }
728
+
729
+ fn is_newer_version(current: &str, latest: &str) -> bool {
730
+ match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
731
+ (Some(current_parts), Some(latest_parts)) => latest_parts > current_parts,
732
+ _ => false,
733
+ }
734
+ }
735
+
736
+ async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
737
+ let timeout_ms = std::env::var("REALLINK_UPDATE_CHECK_TIMEOUT_MS")
738
+ .ok()
739
+ .and_then(|value| value.parse::<u64>().ok())
740
+ .filter(|value| *value > 0)
741
+ .unwrap_or(DEFAULT_VERSION_FETCH_TIMEOUT_MS);
742
+ let response = with_cli_headers(client.get("https://registry.npmjs.org/reallink-cli/latest"))
743
+ .timeout(Duration::from_millis(timeout_ms))
744
+ .send()
745
+ .await
746
+ .ok()?;
747
+ if !response.status().is_success() {
748
+ return None;
749
+ }
750
+ let payload: serde_json::Value = response.json().await.ok()?;
751
+ payload
752
+ .get("version")
753
+ .and_then(|value| value.as_str())
754
+ .map(|value| value.trim().to_string())
755
+ .filter(|value| !value.is_empty())
756
+ }
757
+
758
+ async fn maybe_notify_update(client: &reqwest::Client, force_refresh: bool, allow_network_fetch: bool) {
759
+ if std::env::var("REALLINK_DISABLE_AUTO_UPDATE_CHECK")
760
+ .map(|value| value == "1")
761
+ .unwrap_or(false)
762
+ {
763
+ return;
764
+ }
765
+
766
+ let now = now_epoch_ms();
767
+ let cached = load_update_cache();
768
+ let mut latest_version = if !force_refresh {
769
+ cached.as_ref().and_then(|cache| {
770
+ let age = now.saturating_sub(cache.last_checked_epoch_ms);
771
+ if age < VERSION_CHECK_INTERVAL_MS {
772
+ cache.latest_version.clone()
773
+ } else {
774
+ None
775
+ }
776
+ })
777
+ } else {
778
+ None
779
+ };
780
+
781
+ if latest_version.is_none() && allow_network_fetch {
782
+ latest_version = fetch_latest_cli_version(client).await;
783
+ let _ = save_update_cache(&UpdateCheckCache {
784
+ last_checked_epoch_ms: now,
785
+ latest_version: latest_version.clone(),
786
+ });
787
+ }
788
+
789
+ let current = env!("CARGO_PKG_VERSION");
790
+ if let Some(latest) = latest_version {
791
+ if is_newer_version(current, &latest) {
792
+ eprintln!(
793
+ "Update available: {} -> {}. Run `reallink self-update`.",
794
+ current, latest
795
+ );
796
+ }
797
+ }
798
+ }
799
+
676
800
  fn clean_virtual_path(value: &str) -> String {
677
801
  value
678
802
  .split('/')
@@ -708,9 +832,8 @@ async fn authed_request(
708
832
  body: Option<serde_json::Value>,
709
833
  ) -> Result<reqwest::Response> {
710
834
  let url = format!("{}{}", normalize_base_url(&session.base_url), path);
711
- let mut request = client
712
- .request(method.clone(), &url)
713
- .bearer_auth(&session.access_token);
835
+ let mut request =
836
+ with_cli_headers(client.request(method.clone(), &url).bearer_auth(&session.access_token));
714
837
  if let Some(ref body_value) = body {
715
838
  request = request.json(body_value);
716
839
  }
@@ -721,9 +844,7 @@ async fn authed_request(
721
844
 
722
845
  refresh_session(client, session).await?;
723
846
 
724
- let mut retry = client
725
- .request(method, &url)
726
- .bearer_auth(&session.access_token);
847
+ let mut retry = with_cli_headers(client.request(method, &url).bearer_auth(&session.access_token));
727
848
  if let Some(ref body_value) = body {
728
849
  retry = retry.json(body_value);
729
850
  }
@@ -736,7 +857,7 @@ async fn refresh_session(client: &reqwest::Client, session: &mut SessionConfig)
736
857
  refresh_token: session.refresh_token.clone(),
737
858
  session_id: session.session_id.clone(),
738
859
  };
739
- let response = client.post(url).json(&payload).send().await?;
860
+ let response = with_cli_headers(client.post(url).json(&payload)).send().await?;
740
861
  if !response.status().is_success() {
741
862
  let body = read_error_body(response).await;
742
863
  return Err(anyhow!("Refresh failed: {}", body));
@@ -793,6 +914,9 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
793
914
  "assets:write".to_string(),
794
915
  "trace:read".to_string(),
795
916
  "trace:write".to_string(),
917
+ "tools:read".to_string(),
918
+ "tools:write".to_string(),
919
+ "tools:run".to_string(),
796
920
  "org:admin".to_string(),
797
921
  "project:admin".to_string(),
798
922
  ]
@@ -800,8 +924,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
800
924
  args.scope
801
925
  };
802
926
 
803
- let device_code_response = client
804
- .post(format!("{}/auth/device/code", base_url))
927
+ let device_code_response = with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
805
928
  .json(&DeviceCodeRequest {
806
929
  client_id: args.client_id.clone(),
807
930
  scope,
@@ -838,8 +961,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
838
961
 
839
962
  sleep(poll_interval).await;
840
963
 
841
- let token_response = client
842
- .post(format!("{}/auth/device/token", base_url))
964
+ let token_response = with_cli_headers(client.post(format!("{}/auth/device/token", base_url)))
843
965
  .json(&DeviceTokenRequest {
844
966
  grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
845
967
  device_code: device_code.device_code.clone(),
@@ -1719,6 +1841,85 @@ async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) ->
1719
1841
  Ok(())
1720
1842
  }
1721
1843
 
1844
+ fn run_and_check_status(mut command: Command, context: &str) -> Result<()> {
1845
+ let status = command
1846
+ .status()
1847
+ .with_context(|| format!("Failed to execute {}", context))?;
1848
+ if !status.success() {
1849
+ return Err(anyhow!("{} exited with status {}", context, status));
1850
+ }
1851
+ Ok(())
1852
+ }
1853
+
1854
+ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) -> Result<()> {
1855
+ let current = env!("CARGO_PKG_VERSION");
1856
+ let latest = fetch_latest_cli_version(client).await;
1857
+
1858
+ let Some(latest_version) = latest else {
1859
+ println!("Could not check latest version right now.");
1860
+ return Ok(());
1861
+ };
1862
+
1863
+ if !is_newer_version(current, &latest_version) {
1864
+ println!("reallink is up to date ({})", current);
1865
+ return Ok(());
1866
+ }
1867
+
1868
+ println!("Update available: {} -> {}", current, latest_version);
1869
+ if args.check {
1870
+ println!("Run `reallink self-update` to install the update.");
1871
+ return Ok(());
1872
+ }
1873
+
1874
+ let npm_available = Command::new("npm")
1875
+ .arg("--version")
1876
+ .status()
1877
+ .map(|status| status.success())
1878
+ .unwrap_or(false);
1879
+
1880
+ if npm_available {
1881
+ run_and_check_status(
1882
+ {
1883
+ let mut command = Command::new("npm");
1884
+ command.args(["install", "-g", "reallink-cli@latest"]);
1885
+ command
1886
+ },
1887
+ "npm self-update",
1888
+ )?;
1889
+ println!("Updated via npm. Restart your shell if `reallink --version` still shows old version.");
1890
+ return Ok(());
1891
+ }
1892
+
1893
+ if cfg!(windows) {
1894
+ run_and_check_status(
1895
+ {
1896
+ let mut command = Command::new("powershell");
1897
+ command.args([
1898
+ "-NoProfile",
1899
+ "-ExecutionPolicy",
1900
+ "Bypass",
1901
+ "-Command",
1902
+ "irm https://real-agent.link/install.ps1 | iex",
1903
+ ]);
1904
+ command
1905
+ },
1906
+ "PowerShell installer update",
1907
+ )?;
1908
+ } else {
1909
+ run_and_check_status(
1910
+ {
1911
+ let mut command = Command::new("sh");
1912
+ command.args(["-c", "curl -fsSL https://real-agent.link/install.sh | bash"]);
1913
+ command
1914
+ },
1915
+ "shell installer update",
1916
+ )?;
1917
+ }
1918
+
1919
+ println!("Update installed. Verify with `reallink --version`.");
1920
+ Ok(())
1921
+ }
1922
+
1722
1923
  #[tokio::main]
1723
1924
  async fn main() -> Result<()> {
1724
1925
  let cli = Cli::parse();
@@ -1726,10 +1927,16 @@ async fn main() -> Result<()> {
1726
1927
  .user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
1727
1928
  .build()?;
1728
1929
 
1930
+ if !matches!(&cli.command, Commands::SelfUpdate(_)) {
1931
+ let allow_update_fetch = matches!(&cli.command, Commands::Login(_));
1932
+ maybe_notify_update(&client, false, allow_update_fetch).await;
1933
+ }
1934
+
1729
1935
  match cli.command {
1730
1936
  Commands::Login(args) => login_command(&client, args).await?,
1731
1937
  Commands::Whoami(args) => whoami_command(&client, args).await?,
1732
1938
  Commands::Logout => logout_command(&client).await?,
1939
+ Commands::SelfUpdate(args) => self_update_command(&client, args).await?,
1733
1940
  Commands::Project { command } => match command {
1734
1941
  ProjectCommands::List(args) => project_list_command(&client, args).await?,
1735
1942
  ProjectCommands::Create(args) => project_create_command(&client, args).await?,