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 +3 -0
- package/package.json +1 -1
- package/rust/Cargo.lock +1 -1
- package/rust/Cargo.toml +1 -1
- package/rust/src/main.rs +219 -12
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
package/rust/Cargo.lock
CHANGED
package/rust/Cargo.toml
CHANGED
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 =
|
|
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?,
|