reallink-cli 0.1.5 → 0.1.7

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
@@ -8,11 +8,16 @@ Rust-based CLI for Reallink authentication, token workflows, and workspace file
8
8
  npm install -g reallink-cli
9
9
  ```
10
10
 
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 checks for updates in the background (cached daily) and prints a hint when a newer version is available.
13
+
11
14
  ## Commands
12
15
 
13
16
  ```bash
14
17
  reallink login --base-url https://api.real-agent.link
15
18
  reallink login --force # replace current saved session
19
+ reallink self-update --check
20
+ reallink self-update
16
21
  reallink whoami
17
22
  reallink logout
18
23
 
package/bin/reallink.cjs CHANGED
@@ -1,18 +1,53 @@
1
1
  #!/usr/bin/env node
2
2
  const { spawnSync } = require("node:child_process");
3
+ const fs = require("node:fs");
3
4
  const path = require("node:path");
4
5
 
5
6
  const packageRoot = path.resolve(__dirname, "..");
6
7
  const manifestPath = path.join(packageRoot, "rust", "Cargo.toml");
7
- const cargoArgs = ["run", "--quiet", "--manifest-path", manifestPath, "--", ...process.argv.slice(2)];
8
+ const args = process.argv.slice(2);
9
+ const binaryName = process.platform === "win32" ? "reallink-cli.exe" : "reallink-cli";
10
+ const releaseBinaryPath = path.join(packageRoot, "rust", "target", "release", binaryName);
8
11
 
9
- const result = spawnSync("cargo", cargoArgs, {
10
- stdio: "inherit",
11
- shell: process.platform === "win32"
12
- });
12
+ function runBinary(binaryPath) {
13
+ return spawnSync(binaryPath, args, {
14
+ stdio: "inherit",
15
+ shell: false
16
+ });
17
+ }
18
+
19
+ function ensureBinary() {
20
+ if (fs.existsSync(releaseBinaryPath)) {
21
+ return true;
22
+ }
23
+
24
+ const build = spawnSync(
25
+ "cargo",
26
+ ["build", "--release", "--manifest-path", manifestPath],
27
+ {
28
+ stdio: "inherit",
29
+ shell: process.platform === "win32"
30
+ }
31
+ );
32
+
33
+ if (build.error) {
34
+ console.error(
35
+ `Failed to build reallink CLI with cargo: ${build.error.message}\n` +
36
+ "Install Rust (cargo) or reinstall using the one-line installer from real-agent.link."
37
+ );
38
+ return false;
39
+ }
40
+
41
+ return build.status === 0 && fs.existsSync(releaseBinaryPath);
42
+ }
43
+
44
+ if (!ensureBinary()) {
45
+ process.exit(1);
46
+ }
13
47
 
48
+ const result = runBinary(releaseBinaryPath);
14
49
  if (result.error) {
15
- console.error(`Failed to run cargo: ${result.error.message}`);
50
+ console.error(`Failed to run reallink binary: ${result.error.message}`);
16
51
  process.exit(1);
17
52
  }
18
53
 
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "reallink-cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Rust-based CLI for Reallink auth and API operations",
5
5
  "bin": {
6
6
  "reallink": "bin/reallink.cjs"
7
7
  },
8
8
  "files": [
9
9
  "bin/reallink.cjs",
10
+ "scripts/postinstall.cjs",
10
11
  "rust/Cargo.toml",
11
12
  "rust/Cargo.lock",
12
13
  "rust/src",
13
14
  "README.md"
14
15
  ],
15
16
  "scripts": {
17
+ "postinstall": "node ./scripts/postinstall.cjs",
16
18
  "build": "cargo build --manifest-path ./rust/Cargo.toml --release",
17
19
  "dev": "node ./bin/reallink.cjs --help",
18
20
  "pack:local": "npm pack --json"
package/rust/Cargo.lock CHANGED
@@ -910,7 +910,7 @@ dependencies = [
910
910
 
911
911
  [[package]]
912
912
  name = "reallink-cli"
913
- version = "0.1.5"
913
+ version = "0.1.7"
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.5"
3
+ version = "0.1.7"
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,6 +13,8 @@ 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;
15
18
 
16
19
  #[derive(Parser)]
17
20
  #[command(name = "reallink", version, about = "Reallink CLI")]
@@ -25,6 +28,7 @@ enum Commands {
25
28
  Login(LoginArgs),
26
29
  Whoami(BaseArgs),
27
30
  Logout,
31
+ SelfUpdate(SelfUpdateArgs),
28
32
  Project {
29
33
  #[command(subcommand)]
30
34
  command: ProjectCommands,
@@ -49,6 +53,12 @@ struct BaseArgs {
49
53
  base_url: Option<String>,
50
54
  }
51
55
 
56
+ #[derive(Args)]
57
+ struct SelfUpdateArgs {
58
+ #[arg(long, help = "Only check for updates; do not install")]
59
+ check: bool,
60
+ }
61
+
52
62
  #[derive(Args)]
53
63
  struct LoginArgs {
54
64
  #[arg(long, default_value = DEFAULT_BASE_URL)]
@@ -337,6 +347,12 @@ struct SessionConfig {
337
347
  updated_at_epoch_ms: u128,
338
348
  }
339
349
 
350
+ #[derive(Debug, Serialize, Deserialize, Clone)]
351
+ struct UpdateCheckCache {
352
+ last_checked_epoch_ms: u128,
353
+ latest_version: Option<String>,
354
+ }
355
+
340
356
  #[derive(Debug, Serialize, Deserialize)]
341
357
  #[serde(rename_all = "camelCase")]
342
358
  struct DeviceCodeRequest {
@@ -611,6 +627,11 @@ fn config_path() -> Result<PathBuf> {
611
627
  Ok(base.join(SESSION_DIR_NAME).join(SESSION_FILE_NAME))
612
628
  }
613
629
 
630
+ fn update_cache_path() -> Result<PathBuf> {
631
+ let base = resolve_config_root()?;
632
+ Ok(base.join(SESSION_DIR_NAME).join(UPDATE_CACHE_FILE_NAME))
633
+ }
634
+
614
635
  fn session_path_display() -> String {
615
636
  config_path()
616
637
  .map(|path| path.display().to_string())
@@ -666,6 +687,22 @@ fn clear_session() -> Result<bool> {
666
687
  Ok(false)
667
688
  }
668
689
 
690
+ fn load_update_cache() -> Option<UpdateCheckCache> {
691
+ let path = update_cache_path().ok()?;
692
+ let raw = fs::read(path).ok()?;
693
+ serde_json::from_slice(&raw).ok()
694
+ }
695
+
696
+ fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
697
+ let path = update_cache_path()?;
698
+ if let Some(parent) = path.parent() {
699
+ fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
700
+ }
701
+ let payload = serde_json::to_vec_pretty(cache)?;
702
+ write_atomic(&path, &payload)?;
703
+ Ok(())
704
+ }
705
+
669
706
  async fn read_error_body(response: reqwest::Response) -> String {
670
707
  match response.text().await {
671
708
  Ok(text) if !text.trim().is_empty() => text,
@@ -673,6 +710,87 @@ async fn read_error_body(response: reqwest::Response) -> String {
673
710
  }
674
711
  }
675
712
 
713
+ fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
714
+ request
715
+ .header("x-reallink-client", "cli")
716
+ .header("x-reallink-cli-version", env!("CARGO_PKG_VERSION"))
717
+ }
718
+
719
+ fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
720
+ let core = version.trim().split('-').next()?;
721
+ let mut parts = core.split('.');
722
+ let major = parts.next()?.parse::<u64>().ok()?;
723
+ let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
724
+ let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
725
+ Some((major, minor, patch))
726
+ }
727
+
728
+ fn is_newer_version(current: &str, latest: &str) -> bool {
729
+ match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
730
+ (Some(current_parts), Some(latest_parts)) => latest_parts > current_parts,
731
+ _ => false,
732
+ }
733
+ }
734
+
735
+ async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
736
+ let response = with_cli_headers(client.get("https://registry.npmjs.org/reallink-cli/latest"))
737
+ .timeout(Duration::from_secs(2))
738
+ .send()
739
+ .await
740
+ .ok()?;
741
+ if !response.status().is_success() {
742
+ return None;
743
+ }
744
+ let payload: serde_json::Value = response.json().await.ok()?;
745
+ payload
746
+ .get("version")
747
+ .and_then(|value| value.as_str())
748
+ .map(|value| value.trim().to_string())
749
+ .filter(|value| !value.is_empty())
750
+ }
751
+
752
+ async fn maybe_notify_update(client: &reqwest::Client, force_refresh: bool) {
753
+ if std::env::var("REALLINK_DISABLE_AUTO_UPDATE_CHECK")
754
+ .map(|value| value == "1")
755
+ .unwrap_or(false)
756
+ {
757
+ return;
758
+ }
759
+
760
+ let now = now_epoch_ms();
761
+ let cached = load_update_cache();
762
+ let mut latest_version = if !force_refresh {
763
+ cached.as_ref().and_then(|cache| {
764
+ let age = now.saturating_sub(cache.last_checked_epoch_ms);
765
+ if age < VERSION_CHECK_INTERVAL_MS {
766
+ cache.latest_version.clone()
767
+ } else {
768
+ None
769
+ }
770
+ })
771
+ } else {
772
+ None
773
+ };
774
+
775
+ if latest_version.is_none() {
776
+ latest_version = fetch_latest_cli_version(client).await;
777
+ let _ = save_update_cache(&UpdateCheckCache {
778
+ last_checked_epoch_ms: now,
779
+ latest_version: latest_version.clone(),
780
+ });
781
+ }
782
+
783
+ let current = env!("CARGO_PKG_VERSION");
784
+ if let Some(latest) = latest_version {
785
+ if is_newer_version(current, &latest) {
786
+ eprintln!(
787
+ "Update available: {} -> {}. Run `reallink self-update`.",
788
+ current, latest
789
+ );
790
+ }
791
+ }
792
+ }
793
+
676
794
  fn clean_virtual_path(value: &str) -> String {
677
795
  value
678
796
  .split('/')
@@ -708,9 +826,8 @@ async fn authed_request(
708
826
  body: Option<serde_json::Value>,
709
827
  ) -> Result<reqwest::Response> {
710
828
  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);
829
+ let mut request =
830
+ with_cli_headers(client.request(method.clone(), &url).bearer_auth(&session.access_token));
714
831
  if let Some(ref body_value) = body {
715
832
  request = request.json(body_value);
716
833
  }
@@ -721,9 +838,7 @@ async fn authed_request(
721
838
 
722
839
  refresh_session(client, session).await?;
723
840
 
724
- let mut retry = client
725
- .request(method, &url)
726
- .bearer_auth(&session.access_token);
841
+ let mut retry = with_cli_headers(client.request(method, &url).bearer_auth(&session.access_token));
727
842
  if let Some(ref body_value) = body {
728
843
  retry = retry.json(body_value);
729
844
  }
@@ -736,7 +851,7 @@ async fn refresh_session(client: &reqwest::Client, session: &mut SessionConfig)
736
851
  refresh_token: session.refresh_token.clone(),
737
852
  session_id: session.session_id.clone(),
738
853
  };
739
- let response = client.post(url).json(&payload).send().await?;
854
+ let response = with_cli_headers(client.post(url).json(&payload)).send().await?;
740
855
  if !response.status().is_success() {
741
856
  let body = read_error_body(response).await;
742
857
  return Err(anyhow!("Refresh failed: {}", body));
@@ -793,6 +908,9 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
793
908
  "assets:write".to_string(),
794
909
  "trace:read".to_string(),
795
910
  "trace:write".to_string(),
911
+ "tools:read".to_string(),
912
+ "tools:write".to_string(),
913
+ "tools:run".to_string(),
796
914
  "org:admin".to_string(),
797
915
  "project:admin".to_string(),
798
916
  ]
@@ -800,8 +918,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
800
918
  args.scope
801
919
  };
802
920
 
803
- let device_code_response = client
804
- .post(format!("{}/auth/device/code", base_url))
921
+ let device_code_response = with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
805
922
  .json(&DeviceCodeRequest {
806
923
  client_id: args.client_id.clone(),
807
924
  scope,
@@ -838,8 +955,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
838
955
 
839
956
  sleep(poll_interval).await;
840
957
 
841
- let token_response = client
842
- .post(format!("{}/auth/device/token", base_url))
958
+ let token_response = with_cli_headers(client.post(format!("{}/auth/device/token", base_url)))
843
959
  .json(&DeviceTokenRequest {
844
960
  grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
845
961
  device_code: device_code.device_code.clone(),
@@ -1719,6 +1835,85 @@ async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) ->
1719
1835
  Ok(())
1720
1836
  }
1721
1837
 
1838
+ fn run_and_check_status(mut command: Command, context: &str) -> Result<()> {
1839
+ let status = command
1840
+ .status()
1841
+ .with_context(|| format!("Failed to execute {}", context))?;
1842
+ if !status.success() {
1843
+ return Err(anyhow!("{} exited with status {}", context, status));
1844
+ }
1845
+ Ok(())
1846
+ }
1847
+
1848
+ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) -> Result<()> {
1849
+ let current = env!("CARGO_PKG_VERSION");
1850
+ let latest = fetch_latest_cli_version(client).await;
1851
+
1852
+ let Some(latest_version) = latest else {
1853
+ println!("Could not check latest version right now.");
1854
+ return Ok(());
1855
+ };
1856
+
1857
+ if !is_newer_version(current, &latest_version) {
1858
+ println!("reallink is up to date ({})", current);
1859
+ return Ok(());
1860
+ }
1861
+
1862
+ println!("Update available: {} -> {}", current, latest_version);
1863
+ if args.check {
1864
+ println!("Run `reallink self-update` to install the update.");
1865
+ return Ok(());
1866
+ }
1867
+
1868
+ let npm_available = Command::new("npm")
1869
+ .arg("--version")
1870
+ .status()
1871
+ .map(|status| status.success())
1872
+ .unwrap_or(false);
1873
+
1874
+ if npm_available {
1875
+ run_and_check_status(
1876
+ {
1877
+ let mut command = Command::new("npm");
1878
+ command.args(["install", "-g", "reallink-cli@latest"]);
1879
+ command
1880
+ },
1881
+ "npm self-update",
1882
+ )?;
1883
+ println!("Updated via npm. Restart your shell if `reallink --version` still shows old version.");
1884
+ return Ok(());
1885
+ }
1886
+
1887
+ if cfg!(windows) {
1888
+ run_and_check_status(
1889
+ {
1890
+ let mut command = Command::new("powershell");
1891
+ command.args([
1892
+ "-NoProfile",
1893
+ "-ExecutionPolicy",
1894
+ "Bypass",
1895
+ "-Command",
1896
+ "irm https://real-agent.link/install.ps1 | iex",
1897
+ ]);
1898
+ command
1899
+ },
1900
+ "PowerShell installer update",
1901
+ )?;
1902
+ } else {
1903
+ run_and_check_status(
1904
+ {
1905
+ let mut command = Command::new("sh");
1906
+ command.args(["-c", "curl -fsSL https://real-agent.link/install.sh | bash"]);
1907
+ command
1908
+ },
1909
+ "shell installer update",
1910
+ )?;
1911
+ }
1912
+
1913
+ println!("Update installed. Verify with `reallink --version`.");
1914
+ Ok(())
1915
+ }
1916
+
1722
1917
  #[tokio::main]
1723
1918
  async fn main() -> Result<()> {
1724
1919
  let cli = Cli::parse();
@@ -1726,10 +1921,15 @@ async fn main() -> Result<()> {
1726
1921
  .user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
1727
1922
  .build()?;
1728
1923
 
1924
+ if !matches!(&cli.command, Commands::SelfUpdate(_)) {
1925
+ maybe_notify_update(&client, false).await;
1926
+ }
1927
+
1729
1928
  match cli.command {
1730
1929
  Commands::Login(args) => login_command(&client, args).await?,
1731
1930
  Commands::Whoami(args) => whoami_command(&client, args).await?,
1732
1931
  Commands::Logout => logout_command(&client).await?,
1932
+ Commands::SelfUpdate(args) => self_update_command(&client, args).await?,
1733
1933
  Commands::Project { command } => match command {
1734
1934
  ProjectCommands::List(args) => project_list_command(&client, args).await?,
1735
1935
  ProjectCommands::Create(args) => project_create_command(&client, args).await?,
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ const { spawnSync } = require("node:child_process");
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const packageRoot = path.resolve(__dirname, "..");
7
+ const manifestPath = path.join(packageRoot, "rust", "Cargo.toml");
8
+ const binaryName = process.platform === "win32" ? "reallink-cli.exe" : "reallink-cli";
9
+ const releaseBinaryPath = path.join(packageRoot, "rust", "target", "release", binaryName);
10
+
11
+ if (process.env.REALLINK_SKIP_BUILD === "1") {
12
+ process.exit(0);
13
+ }
14
+
15
+ if (fs.existsSync(releaseBinaryPath)) {
16
+ process.exit(0);
17
+ }
18
+
19
+ const build = spawnSync(
20
+ "cargo",
21
+ ["build", "--release", "--manifest-path", manifestPath],
22
+ {
23
+ stdio: "inherit",
24
+ shell: process.platform === "win32"
25
+ }
26
+ );
27
+
28
+ if (build.error) {
29
+ console.error(
30
+ `reallink-cli postinstall failed: ${build.error.message}\n` +
31
+ "Rust toolchain is required for npm installation. " +
32
+ "Install Rust (https://rustup.rs) or use the hosted installer at https://real-agent.link/install.sh."
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ process.exit(build.status ?? 1);