reallink-cli 0.1.9 → 0.1.11
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 +8 -2
- package/bin/reallink.cjs +23 -4
- package/package.json +4 -1
- package/prebuilt/win32-x64/reallink-cli.exe +0 -0
- package/rust/Cargo.lock +1 -1
- package/rust/Cargo.toml +2 -2
- package/rust/src/main.rs +324 -50
- package/scripts/postinstall.cjs +15 -2
- package/scripts/prepare-prebuilt.cjs +49 -0
package/README.md
CHANGED
|
@@ -8,7 +8,9 @@ Rust-based CLI for Reallink authentication, token workflows, and workspace file
|
|
|
8
8
|
npm install -g reallink-cli
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
On supported published platforms, npm installs a bundled prebuilt binary so install/update is fast.
|
|
12
|
+
If a prebuilt binary is not available for your OS/arch, npm falls back to a one-time Rust release build during postinstall.
|
|
13
|
+
After install, `reallink` runs the compiled binary directly (no per-command cargo compile step).
|
|
12
14
|
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.
|
|
13
15
|
|
|
14
16
|
## Commands
|
|
@@ -34,6 +36,9 @@ reallink token revoke --token-id tok_xxx
|
|
|
34
36
|
|
|
35
37
|
reallink file list --project-id prj_xxx
|
|
36
38
|
reallink file get --asset-id ast_xxx
|
|
39
|
+
reallink file stat --asset-id ast_xxx
|
|
40
|
+
reallink file download --asset-id ast_xxx --output ./downloads/report.json
|
|
41
|
+
reallink file download --asset-id ast_xxx --output ./downloads/report.json --resume
|
|
37
42
|
reallink file upload --project-id prj_xxx --source ./local/report.json --path reports/daily
|
|
38
43
|
reallink file mkdir --project-id prj_xxx --path reports/archive
|
|
39
44
|
reallink file move --asset-id ast_xxx --file-name reports/archive/report.json
|
|
@@ -60,4 +65,5 @@ reallink tool get-run --run-id trun_xxx
|
|
|
60
65
|
|
|
61
66
|
## Requirements
|
|
62
67
|
|
|
63
|
-
-
|
|
68
|
+
- Prebuilt platforms: no Rust toolchain required.
|
|
69
|
+
- Fallback build path (when no prebuilt binary exists): Rust toolchain (`cargo`) required.
|
package/bin/reallink.cjs
CHANGED
|
@@ -7,7 +7,25 @@ const packageRoot = path.resolve(__dirname, "..");
|
|
|
7
7
|
const manifestPath = path.join(packageRoot, "rust", "Cargo.toml");
|
|
8
8
|
const args = process.argv.slice(2);
|
|
9
9
|
const binaryName = process.platform === "win32" ? "reallink-cli.exe" : "reallink-cli";
|
|
10
|
-
const
|
|
10
|
+
const prebuiltBinaryPath = path.join(
|
|
11
|
+
packageRoot,
|
|
12
|
+
"prebuilt",
|
|
13
|
+
`${process.platform}-${process.arch}`,
|
|
14
|
+
binaryName
|
|
15
|
+
);
|
|
16
|
+
const releaseBinaryCandidates = [
|
|
17
|
+
path.resolve(packageRoot, "..", "..", "target", "release", binaryName),
|
|
18
|
+
path.join(packageRoot, "rust", "target", "release", binaryName)
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function resolveExistingReleaseBinary() {
|
|
22
|
+
for (const candidate of releaseBinaryCandidates) {
|
|
23
|
+
if (fs.existsSync(candidate)) {
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
11
29
|
|
|
12
30
|
function runBinary(binaryPath) {
|
|
13
31
|
return spawnSync(binaryPath, args, {
|
|
@@ -17,7 +35,7 @@ function runBinary(binaryPath) {
|
|
|
17
35
|
}
|
|
18
36
|
|
|
19
37
|
function ensureBinary() {
|
|
20
|
-
if (fs.existsSync(
|
|
38
|
+
if (resolveExistingReleaseBinary() || fs.existsSync(prebuiltBinaryPath)) {
|
|
21
39
|
return true;
|
|
22
40
|
}
|
|
23
41
|
|
|
@@ -38,14 +56,15 @@ function ensureBinary() {
|
|
|
38
56
|
return false;
|
|
39
57
|
}
|
|
40
58
|
|
|
41
|
-
return build.status === 0 &&
|
|
59
|
+
return build.status === 0 && !!resolveExistingReleaseBinary();
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
if (!ensureBinary()) {
|
|
45
63
|
process.exit(1);
|
|
46
64
|
}
|
|
47
65
|
|
|
48
|
-
const
|
|
66
|
+
const binaryPath = resolveExistingReleaseBinary() || prebuiltBinaryPath;
|
|
67
|
+
const result = runBinary(binaryPath);
|
|
49
68
|
if (result.error) {
|
|
50
69
|
console.error(`Failed to run reallink binary: ${result.error.message}`);
|
|
51
70
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reallink-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
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
|
+
"prebuilt",
|
|
10
11
|
"scripts/postinstall.cjs",
|
|
12
|
+
"scripts/prepare-prebuilt.cjs",
|
|
11
13
|
"rust/Cargo.toml",
|
|
12
14
|
"rust/Cargo.lock",
|
|
13
15
|
"rust/src",
|
|
14
16
|
"README.md"
|
|
15
17
|
],
|
|
16
18
|
"scripts": {
|
|
19
|
+
"prepack": "node ./scripts/prepare-prebuilt.cjs",
|
|
17
20
|
"postinstall": "node ./scripts/postinstall.cjs",
|
|
18
21
|
"build": "cargo build --manifest-path ./rust/Cargo.toml --release",
|
|
19
22
|
"dev": "node ./bin/reallink.cjs --help",
|
|
Binary file
|
package/rust/Cargo.lock
CHANGED
package/rust/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "reallink-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.11"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "CLI for Reallink auth and token workflows"
|
|
6
6
|
license = "MIT"
|
|
@@ -13,5 +13,5 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
|
|
13
13
|
serde = { version = "1.0", features = ["derive"] }
|
|
14
14
|
serde_json = "1.0"
|
|
15
15
|
json5 = "0.4"
|
|
16
|
-
tokio = { version = "1.42", features = ["macros", "rt-multi-thread", "time"] }
|
|
16
|
+
tokio = { version = "1.42", features = ["macros", "rt-multi-thread", "time", "fs", "io-util"] }
|
|
17
17
|
webbrowser = "1.0"
|
package/rust/src/main.rs
CHANGED
|
@@ -7,6 +7,8 @@ use std::io::{self, Write};
|
|
|
7
7
|
use std::path::{Path, PathBuf};
|
|
8
8
|
use std::process::Command;
|
|
9
9
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
10
|
+
use tokio::fs as tokio_fs;
|
|
11
|
+
use tokio::io::AsyncWriteExt;
|
|
10
12
|
use tokio::time::sleep;
|
|
11
13
|
|
|
12
14
|
const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
|
|
@@ -18,7 +20,12 @@ const VERSION_CHECK_INTERVAL_MS: u128 = 24 * 60 * 60 * 1000;
|
|
|
18
20
|
const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
|
|
19
21
|
|
|
20
22
|
#[derive(Parser)]
|
|
21
|
-
#[command(
|
|
23
|
+
#[command(
|
|
24
|
+
name = "reallink",
|
|
25
|
+
bin_name = "reallink",
|
|
26
|
+
version,
|
|
27
|
+
about = "Reallink CLI"
|
|
28
|
+
)]
|
|
22
29
|
struct Cli {
|
|
23
30
|
#[command(subcommand)]
|
|
24
31
|
command: Commands,
|
|
@@ -92,6 +99,8 @@ enum ProjectCommands {
|
|
|
92
99
|
enum FileCommands {
|
|
93
100
|
List(FileListArgs),
|
|
94
101
|
Get(FileGetArgs),
|
|
102
|
+
Stat(FileStatArgs),
|
|
103
|
+
Download(FileDownloadArgs),
|
|
95
104
|
Upload(FileUploadArgs),
|
|
96
105
|
Mkdir(FileMkdirArgs),
|
|
97
106
|
Move(FileMoveArgs),
|
|
@@ -151,6 +160,29 @@ struct FileGetArgs {
|
|
|
151
160
|
base_url: Option<String>,
|
|
152
161
|
}
|
|
153
162
|
|
|
163
|
+
#[derive(Args)]
|
|
164
|
+
struct FileStatArgs {
|
|
165
|
+
#[arg(long)]
|
|
166
|
+
asset_id: String,
|
|
167
|
+
#[arg(long)]
|
|
168
|
+
base_url: Option<String>,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#[derive(Args)]
|
|
172
|
+
struct FileDownloadArgs {
|
|
173
|
+
#[arg(long)]
|
|
174
|
+
asset_id: String,
|
|
175
|
+
#[arg(long)]
|
|
176
|
+
output: Option<PathBuf>,
|
|
177
|
+
#[arg(
|
|
178
|
+
long,
|
|
179
|
+
help = "Resume download from existing output file size using HTTP Range"
|
|
180
|
+
)]
|
|
181
|
+
resume: bool,
|
|
182
|
+
#[arg(long)]
|
|
183
|
+
base_url: Option<String>,
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
#[derive(Args)]
|
|
155
187
|
struct FileUploadArgs {
|
|
156
188
|
#[arg(long)]
|
|
@@ -508,6 +540,20 @@ struct AssetResponse {
|
|
|
508
540
|
asset: AssetRecord,
|
|
509
541
|
}
|
|
510
542
|
|
|
543
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
544
|
+
#[serde(rename_all = "camelCase")]
|
|
545
|
+
struct AssetContentMetadata {
|
|
546
|
+
part_count: i64,
|
|
547
|
+
content_length: i64,
|
|
548
|
+
accept_ranges: String,
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
552
|
+
struct AssetMetadataResponse {
|
|
553
|
+
asset: AssetRecord,
|
|
554
|
+
content: AssetContentMetadata,
|
|
555
|
+
}
|
|
556
|
+
|
|
511
557
|
#[derive(Debug, Serialize)]
|
|
512
558
|
#[serde(rename_all = "camelCase")]
|
|
513
559
|
struct UploadIntentRequest {
|
|
@@ -598,7 +644,10 @@ fn load_jsonc_file(path: &Path, label: &str) -> Result<serde_json::Value> {
|
|
|
598
644
|
parse_jsonc_str(&raw, &format!("{} file {}", label, path.display()))
|
|
599
645
|
}
|
|
600
646
|
|
|
601
|
-
fn parse_object_from_value(
|
|
647
|
+
fn parse_object_from_value(
|
|
648
|
+
value: serde_json::Value,
|
|
649
|
+
context: &str,
|
|
650
|
+
) -> Result<serde_json::Map<String, serde_json::Value>> {
|
|
602
651
|
match value {
|
|
603
652
|
serde_json::Value::Object(map) => Ok(map),
|
|
604
653
|
_ => Err(anyhow!("{} must be a JSON object", context)),
|
|
@@ -659,7 +708,8 @@ fn write_atomic(path: &Path, payload: &[u8]) -> Result<()> {
|
|
|
659
708
|
fn save_session(session: &SessionConfig) -> Result<()> {
|
|
660
709
|
let path = config_path()?;
|
|
661
710
|
if let Some(parent) = path.parent() {
|
|
662
|
-
fs::create_dir_all(parent)
|
|
711
|
+
fs::create_dir_all(parent)
|
|
712
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
663
713
|
}
|
|
664
714
|
let payload = serde_json::to_vec_pretty(session)?;
|
|
665
715
|
write_atomic(&path, &payload)?;
|
|
@@ -697,7 +747,8 @@ fn load_update_cache() -> Option<UpdateCheckCache> {
|
|
|
697
747
|
fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
|
|
698
748
|
let path = update_cache_path()?;
|
|
699
749
|
if let Some(parent) = path.parent() {
|
|
700
|
-
fs::create_dir_all(parent)
|
|
750
|
+
fs::create_dir_all(parent)
|
|
751
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
701
752
|
}
|
|
702
753
|
let payload = serde_json::to_vec_pretty(cache)?;
|
|
703
754
|
write_atomic(&path, &payload)?;
|
|
@@ -755,7 +806,11 @@ async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
|
|
|
755
806
|
.filter(|value| !value.is_empty())
|
|
756
807
|
}
|
|
757
808
|
|
|
758
|
-
async fn maybe_notify_update(
|
|
809
|
+
async fn maybe_notify_update(
|
|
810
|
+
client: &reqwest::Client,
|
|
811
|
+
force_refresh: bool,
|
|
812
|
+
allow_network_fetch: bool,
|
|
813
|
+
) {
|
|
759
814
|
if std::env::var("REALLINK_DISABLE_AUTO_UPDATE_CHECK")
|
|
760
815
|
.map(|value| value == "1")
|
|
761
816
|
.unwrap_or(false)
|
|
@@ -824,16 +879,43 @@ fn join_remote_path(prefix: Option<&str>, file_name: &str) -> String {
|
|
|
824
879
|
format!("{}/{}", prefix_clean, file_clean)
|
|
825
880
|
}
|
|
826
881
|
|
|
882
|
+
fn base_name_from_virtual_path(path: &str) -> String {
|
|
883
|
+
let normalized = clean_virtual_path(path);
|
|
884
|
+
normalized
|
|
885
|
+
.split('/')
|
|
886
|
+
.filter(|segment| !segment.is_empty())
|
|
887
|
+
.next_back()
|
|
888
|
+
.unwrap_or("download.bin")
|
|
889
|
+
.to_string()
|
|
890
|
+
}
|
|
891
|
+
|
|
827
892
|
async fn authed_request(
|
|
828
893
|
client: &reqwest::Client,
|
|
829
894
|
session: &mut SessionConfig,
|
|
830
895
|
method: Method,
|
|
831
896
|
path: &str,
|
|
832
897
|
body: Option<serde_json::Value>,
|
|
898
|
+
) -> Result<reqwest::Response> {
|
|
899
|
+
authed_request_with_headers(client, session, method, path, body, &[]).await
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async fn authed_request_with_headers(
|
|
903
|
+
client: &reqwest::Client,
|
|
904
|
+
session: &mut SessionConfig,
|
|
905
|
+
method: Method,
|
|
906
|
+
path: &str,
|
|
907
|
+
body: Option<serde_json::Value>,
|
|
908
|
+
extra_headers: &[(String, String)],
|
|
833
909
|
) -> Result<reqwest::Response> {
|
|
834
910
|
let url = format!("{}{}", normalize_base_url(&session.base_url), path);
|
|
835
|
-
let mut request =
|
|
836
|
-
|
|
911
|
+
let mut request = with_cli_headers(
|
|
912
|
+
client
|
|
913
|
+
.request(method.clone(), &url)
|
|
914
|
+
.bearer_auth(&session.access_token),
|
|
915
|
+
);
|
|
916
|
+
for (key, value) in extra_headers {
|
|
917
|
+
request = request.header(key, value);
|
|
918
|
+
}
|
|
837
919
|
if let Some(ref body_value) = body {
|
|
838
920
|
request = request.json(body_value);
|
|
839
921
|
}
|
|
@@ -844,7 +926,14 @@ async fn authed_request(
|
|
|
844
926
|
|
|
845
927
|
refresh_session(client, session).await?;
|
|
846
928
|
|
|
847
|
-
let mut retry = with_cli_headers(
|
|
929
|
+
let mut retry = with_cli_headers(
|
|
930
|
+
client
|
|
931
|
+
.request(method, &url)
|
|
932
|
+
.bearer_auth(&session.access_token),
|
|
933
|
+
);
|
|
934
|
+
for (key, value) in extra_headers {
|
|
935
|
+
retry = retry.header(key, value);
|
|
936
|
+
}
|
|
848
937
|
if let Some(ref body_value) = body {
|
|
849
938
|
retry = retry.json(body_value);
|
|
850
939
|
}
|
|
@@ -857,7 +946,9 @@ async fn refresh_session(client: &reqwest::Client, session: &mut SessionConfig)
|
|
|
857
946
|
refresh_token: session.refresh_token.clone(),
|
|
858
947
|
session_id: session.session_id.clone(),
|
|
859
948
|
};
|
|
860
|
-
let response = with_cli_headers(client.post(url).json(&payload))
|
|
949
|
+
let response = with_cli_headers(client.post(url).json(&payload))
|
|
950
|
+
.send()
|
|
951
|
+
.await?;
|
|
861
952
|
if !response.status().is_success() {
|
|
862
953
|
let body = read_error_body(response).await;
|
|
863
954
|
return Err(anyhow!("Refresh failed: {}", body));
|
|
@@ -924,13 +1015,14 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
924
1015
|
args.scope
|
|
925
1016
|
};
|
|
926
1017
|
|
|
927
|
-
let device_code_response =
|
|
928
|
-
.
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1018
|
+
let device_code_response =
|
|
1019
|
+
with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
|
|
1020
|
+
.json(&DeviceCodeRequest {
|
|
1021
|
+
client_id: args.client_id.clone(),
|
|
1022
|
+
scope,
|
|
1023
|
+
})
|
|
1024
|
+
.send()
|
|
1025
|
+
.await?;
|
|
934
1026
|
|
|
935
1027
|
if !device_code_response.status().is_success() {
|
|
936
1028
|
let body = read_error_body(device_code_response).await;
|
|
@@ -961,14 +1053,15 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
961
1053
|
|
|
962
1054
|
sleep(poll_interval).await;
|
|
963
1055
|
|
|
964
|
-
let token_response =
|
|
965
|
-
.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1056
|
+
let token_response =
|
|
1057
|
+
with_cli_headers(client.post(format!("{}/auth/device/token", base_url)))
|
|
1058
|
+
.json(&DeviceTokenRequest {
|
|
1059
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
|
|
1060
|
+
device_code: device_code.device_code.clone(),
|
|
1061
|
+
client_id: args.client_id.clone(),
|
|
1062
|
+
})
|
|
1063
|
+
.send()
|
|
1064
|
+
.await?;
|
|
972
1065
|
|
|
973
1066
|
if token_response.status().is_success() {
|
|
974
1067
|
let tokens: DeviceTokenSuccess = token_response.json().await?;
|
|
@@ -988,10 +1081,8 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
988
1081
|
return Ok(());
|
|
989
1082
|
}
|
|
990
1083
|
|
|
991
|
-
let error_payload: DeviceTokenError =
|
|
992
|
-
.json()
|
|
993
|
-
.await
|
|
994
|
-
.unwrap_or(DeviceTokenError {
|
|
1084
|
+
let error_payload: DeviceTokenError =
|
|
1085
|
+
token_response.json().await.unwrap_or(DeviceTokenError {
|
|
995
1086
|
error: "unknown_error".to_string(),
|
|
996
1087
|
error_description: Some("Could not parse auth error".to_string()),
|
|
997
1088
|
});
|
|
@@ -1018,7 +1109,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1018
1109
|
"Device login failed: {} ({})",
|
|
1019
1110
|
error_payload.error,
|
|
1020
1111
|
error_payload.error_description.unwrap_or_default()
|
|
1021
|
-
))
|
|
1112
|
+
));
|
|
1022
1113
|
}
|
|
1023
1114
|
}
|
|
1024
1115
|
}
|
|
@@ -1060,7 +1151,10 @@ async fn logout_command(client: &reqwest::Client) -> Result<()> {
|
|
|
1060
1151
|
}
|
|
1061
1152
|
|
|
1062
1153
|
if remote_revoked {
|
|
1063
|
-
println!(
|
|
1154
|
+
println!(
|
|
1155
|
+
"Logged out. Server session revoked and local session removed from {}.",
|
|
1156
|
+
path_display
|
|
1157
|
+
);
|
|
1064
1158
|
} else if remote_unavailable {
|
|
1065
1159
|
println!("Logged out locally. Removed session from {}.", path_display);
|
|
1066
1160
|
println!("Server logout endpoint is not available on this API deployment yet.");
|
|
@@ -1119,7 +1213,14 @@ async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -
|
|
|
1119
1213
|
expires_in_days: args.expires_in_days,
|
|
1120
1214
|
})?;
|
|
1121
1215
|
|
|
1122
|
-
let response = authed_request(
|
|
1216
|
+
let response = authed_request(
|
|
1217
|
+
client,
|
|
1218
|
+
&mut session,
|
|
1219
|
+
Method::POST,
|
|
1220
|
+
"/auth/tokens",
|
|
1221
|
+
Some(body),
|
|
1222
|
+
)
|
|
1223
|
+
.await?;
|
|
1123
1224
|
if !response.status().is_success() {
|
|
1124
1225
|
let body_text = read_error_body(response).await;
|
|
1125
1226
|
return Err(anyhow!("token create failed: {}", body_text));
|
|
@@ -1151,7 +1252,9 @@ async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -
|
|
|
1151
1252
|
apply_base_url_override(&mut session, args.base_url);
|
|
1152
1253
|
|
|
1153
1254
|
let path = match args.org_id {
|
|
1154
|
-
Some(org_id) if !org_id.trim().is_empty() =>
|
|
1255
|
+
Some(org_id) if !org_id.trim().is_empty() => {
|
|
1256
|
+
format!("/core/projects?orgId={}", org_id.trim())
|
|
1257
|
+
}
|
|
1155
1258
|
_ => "/core/projects".to_string(),
|
|
1156
1259
|
};
|
|
1157
1260
|
|
|
@@ -1303,11 +1406,23 @@ async fn upload_asset_via_intent(
|
|
|
1303
1406
|
let complete_payload = if strategy == "multipart" {
|
|
1304
1407
|
let part_size = intent
|
|
1305
1408
|
.part_size_bytes
|
|
1306
|
-
.and_then(|value|
|
|
1409
|
+
.and_then(|value| {
|
|
1410
|
+
if value > 0 {
|
|
1411
|
+
Some(value as usize)
|
|
1412
|
+
} else {
|
|
1413
|
+
None
|
|
1414
|
+
}
|
|
1415
|
+
})
|
|
1307
1416
|
.ok_or_else(|| anyhow!("multipart upload intent is missing partSizeBytes"))?;
|
|
1308
1417
|
let part_count = intent
|
|
1309
1418
|
.part_count
|
|
1310
|
-
.and_then(|value|
|
|
1419
|
+
.and_then(|value| {
|
|
1420
|
+
if value > 0 {
|
|
1421
|
+
Some(value as usize)
|
|
1422
|
+
} else {
|
|
1423
|
+
None
|
|
1424
|
+
}
|
|
1425
|
+
})
|
|
1311
1426
|
.ok_or_else(|| anyhow!("multipart upload intent is missing partCount"))?;
|
|
1312
1427
|
let session_id = intent
|
|
1313
1428
|
.session_id
|
|
@@ -1459,6 +1574,144 @@ async fn file_get_command(client: &reqwest::Client, args: FileGetArgs) -> Result
|
|
|
1459
1574
|
Ok(())
|
|
1460
1575
|
}
|
|
1461
1576
|
|
|
1577
|
+
async fn file_stat_command(client: &reqwest::Client, args: FileStatArgs) -> Result<()> {
|
|
1578
|
+
let mut session = load_session()?;
|
|
1579
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1580
|
+
|
|
1581
|
+
let path = format!("/assets/{}/metadata", args.asset_id);
|
|
1582
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1583
|
+
if !response.status().is_success() {
|
|
1584
|
+
let body = read_error_body(response).await;
|
|
1585
|
+
return Err(anyhow!("file stat failed: {}", body));
|
|
1586
|
+
}
|
|
1587
|
+
let payload: AssetMetadataResponse = response.json().await?;
|
|
1588
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1589
|
+
save_session(&session)?;
|
|
1590
|
+
Ok(())
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs) -> Result<()> {
|
|
1594
|
+
let mut session = load_session()?;
|
|
1595
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1596
|
+
|
|
1597
|
+
let metadata_path = format!("/assets/{}/metadata", args.asset_id);
|
|
1598
|
+
let metadata_response =
|
|
1599
|
+
authed_request(client, &mut session, Method::GET, &metadata_path, None).await?;
|
|
1600
|
+
if !metadata_response.status().is_success() {
|
|
1601
|
+
let body = read_error_body(metadata_response).await;
|
|
1602
|
+
return Err(anyhow!("file metadata fetch failed: {}", body));
|
|
1603
|
+
}
|
|
1604
|
+
let metadata_payload: AssetMetadataResponse = metadata_response.json().await?;
|
|
1605
|
+
let fallback_name = base_name_from_virtual_path(&metadata_payload.asset.file_name);
|
|
1606
|
+
|
|
1607
|
+
let mut output_path = args
|
|
1608
|
+
.output
|
|
1609
|
+
.unwrap_or_else(|| PathBuf::from(fallback_name.as_str()));
|
|
1610
|
+
if output_path.exists() && output_path.is_dir() {
|
|
1611
|
+
output_path = output_path.join(fallback_name.as_str());
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
if let Some(parent) = output_path.parent() {
|
|
1615
|
+
if !parent.as_os_str().is_empty() {
|
|
1616
|
+
tokio_fs::create_dir_all(parent).await.with_context(|| {
|
|
1617
|
+
format!("Failed to create output directory {}", parent.display())
|
|
1618
|
+
})?;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
let mut resume_from: Option<u64> = None;
|
|
1623
|
+
if args.resume && output_path.exists() && output_path.is_file() {
|
|
1624
|
+
let existing_size = tokio_fs::metadata(&output_path)
|
|
1625
|
+
.await
|
|
1626
|
+
.with_context(|| {
|
|
1627
|
+
format!(
|
|
1628
|
+
"Failed to read output file metadata {}",
|
|
1629
|
+
output_path.display()
|
|
1630
|
+
)
|
|
1631
|
+
})?
|
|
1632
|
+
.len();
|
|
1633
|
+
if existing_size > 0 {
|
|
1634
|
+
let remote_size = metadata_payload.content.content_length.max(0) as u64;
|
|
1635
|
+
if existing_size >= remote_size && remote_size > 0 {
|
|
1636
|
+
println!(
|
|
1637
|
+
"{}",
|
|
1638
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
1639
|
+
"assetId": metadata_payload.asset.id,
|
|
1640
|
+
"fileName": metadata_payload.asset.file_name,
|
|
1641
|
+
"output": output_path.display().to_string(),
|
|
1642
|
+
"bytesWritten": 0,
|
|
1643
|
+
"resumedFrom": existing_size,
|
|
1644
|
+
"alreadyComplete": true
|
|
1645
|
+
}))?
|
|
1646
|
+
);
|
|
1647
|
+
save_session(&session)?;
|
|
1648
|
+
return Ok(());
|
|
1649
|
+
}
|
|
1650
|
+
resume_from = Some(existing_size);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
let download_path = format!("/assets/{}/download", args.asset_id);
|
|
1655
|
+
let mut headers = Vec::new();
|
|
1656
|
+
if let Some(offset) = resume_from {
|
|
1657
|
+
headers.push(("range".to_string(), format!("bytes={}-", offset)));
|
|
1658
|
+
}
|
|
1659
|
+
let mut response = authed_request_with_headers(
|
|
1660
|
+
client,
|
|
1661
|
+
&mut session,
|
|
1662
|
+
Method::GET,
|
|
1663
|
+
&download_path,
|
|
1664
|
+
None,
|
|
1665
|
+
&headers,
|
|
1666
|
+
)
|
|
1667
|
+
.await?;
|
|
1668
|
+
if !(response.status().is_success() || response.status() == StatusCode::PARTIAL_CONTENT) {
|
|
1669
|
+
let body = read_error_body(response).await;
|
|
1670
|
+
return Err(anyhow!("file download failed: {}", body));
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
let append_mode = resume_from.is_some() && response.status() == StatusCode::PARTIAL_CONTENT;
|
|
1674
|
+
let mut output_file = if append_mode {
|
|
1675
|
+
tokio_fs::OpenOptions::new()
|
|
1676
|
+
.append(true)
|
|
1677
|
+
.open(&output_path)
|
|
1678
|
+
.await
|
|
1679
|
+
.with_context(|| {
|
|
1680
|
+
format!(
|
|
1681
|
+
"Failed to open output file for append {}",
|
|
1682
|
+
output_path.display()
|
|
1683
|
+
)
|
|
1684
|
+
})?
|
|
1685
|
+
} else {
|
|
1686
|
+
tokio_fs::File::create(&output_path)
|
|
1687
|
+
.await
|
|
1688
|
+
.with_context(|| format!("Failed to create output file {}", output_path.display()))?
|
|
1689
|
+
};
|
|
1690
|
+
let mut bytes_written: u64 = 0;
|
|
1691
|
+
while let Some(chunk) = response.chunk().await? {
|
|
1692
|
+
output_file
|
|
1693
|
+
.write_all(&chunk)
|
|
1694
|
+
.await
|
|
1695
|
+
.with_context(|| format!("Failed to write to {}", output_path.display()))?;
|
|
1696
|
+
bytes_written = bytes_written.saturating_add(chunk.len() as u64);
|
|
1697
|
+
}
|
|
1698
|
+
output_file.flush().await?;
|
|
1699
|
+
|
|
1700
|
+
println!(
|
|
1701
|
+
"{}",
|
|
1702
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
1703
|
+
"assetId": metadata_payload.asset.id,
|
|
1704
|
+
"fileName": metadata_payload.asset.file_name,
|
|
1705
|
+
"output": output_path.display().to_string(),
|
|
1706
|
+
"bytesWritten": bytes_written,
|
|
1707
|
+
"resumedFrom": resume_from,
|
|
1708
|
+
"partialContent": response.status() == StatusCode::PARTIAL_CONTENT
|
|
1709
|
+
}))?
|
|
1710
|
+
);
|
|
1711
|
+
save_session(&session)?;
|
|
1712
|
+
Ok(())
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1462
1715
|
async fn file_upload_command(client: &reqwest::Client, args: FileUploadArgs) -> Result<()> {
|
|
1463
1716
|
let mut session = load_session()?;
|
|
1464
1717
|
apply_base_url_override(&mut session, args.base_url);
|
|
@@ -1589,12 +1842,16 @@ async fn tool_register_command(client: &reqwest::Client, args: ToolRegisterArgs)
|
|
|
1589
1842
|
apply_base_url_override(&mut session, args.base_url);
|
|
1590
1843
|
|
|
1591
1844
|
let manifest = load_jsonc_file(&args.manifest, "tool manifest")?;
|
|
1592
|
-
let body =
|
|
1593
|
-
manifest,
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1845
|
+
let body =
|
|
1846
|
+
serde_json::Value::Object(parse_object_from_value(manifest, "tool manifest payload")?);
|
|
1847
|
+
let response = authed_request(
|
|
1848
|
+
client,
|
|
1849
|
+
&mut session,
|
|
1850
|
+
Method::POST,
|
|
1851
|
+
"/tools/definitions",
|
|
1852
|
+
Some(body),
|
|
1853
|
+
)
|
|
1854
|
+
.await?;
|
|
1598
1855
|
if !response.status().is_success() {
|
|
1599
1856
|
let body = read_error_body(response).await;
|
|
1600
1857
|
return Err(anyhow!("tool register failed: {}", body));
|
|
@@ -1637,13 +1894,19 @@ async fn tool_set_entitlement_command(
|
|
|
1637
1894
|
body.insert("orgId".to_string(), serde_json::Value::String(org_id));
|
|
1638
1895
|
}
|
|
1639
1896
|
if let Some(project_id) = project_id {
|
|
1640
|
-
body.insert(
|
|
1897
|
+
body.insert(
|
|
1898
|
+
"projectId".to_string(),
|
|
1899
|
+
serde_json::Value::String(project_id),
|
|
1900
|
+
);
|
|
1641
1901
|
}
|
|
1642
1902
|
if let Some(user_id) = user_id {
|
|
1643
1903
|
body.insert("userId".to_string(), serde_json::Value::String(user_id));
|
|
1644
1904
|
}
|
|
1645
1905
|
if let Some(expires_at) = expires_at {
|
|
1646
|
-
body.insert(
|
|
1906
|
+
body.insert(
|
|
1907
|
+
"expiresAt".to_string(),
|
|
1908
|
+
serde_json::Value::String(expires_at),
|
|
1909
|
+
);
|
|
1647
1910
|
}
|
|
1648
1911
|
if let Some(path) = metadata_file {
|
|
1649
1912
|
let metadata = load_jsonc_file(&path, "tool entitlement metadata")?;
|
|
@@ -1731,19 +1994,23 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
|
|
|
1731
1994
|
serde_json::Value::Object(serde_json::Map::new())
|
|
1732
1995
|
};
|
|
1733
1996
|
|
|
1734
|
-
let input_object =
|
|
1735
|
-
input_value,
|
|
1736
|
-
"tool run input",
|
|
1737
|
-
)?);
|
|
1997
|
+
let input_object =
|
|
1998
|
+
serde_json::Value::Object(parse_object_from_value(input_value, "tool run input")?);
|
|
1738
1999
|
|
|
1739
2000
|
let mut body = serde_json::Map::new();
|
|
1740
|
-
body.insert(
|
|
2001
|
+
body.insert(
|
|
2002
|
+
"toolId".to_string(),
|
|
2003
|
+
serde_json::Value::String(args.tool_id),
|
|
2004
|
+
);
|
|
1741
2005
|
body.insert("input".to_string(), input_object);
|
|
1742
2006
|
if let Some(org_id) = args.org_id {
|
|
1743
2007
|
body.insert("orgId".to_string(), serde_json::Value::String(org_id));
|
|
1744
2008
|
}
|
|
1745
2009
|
if let Some(project_id) = args.project_id {
|
|
1746
|
-
body.insert(
|
|
2010
|
+
body.insert(
|
|
2011
|
+
"projectId".to_string(),
|
|
2012
|
+
serde_json::Value::String(project_id),
|
|
2013
|
+
);
|
|
1747
2014
|
}
|
|
1748
2015
|
let mut metadata_map = if let Some(path) = args.metadata_file {
|
|
1749
2016
|
let metadata = load_jsonc_file(&path, "tool run metadata")?;
|
|
@@ -1761,7 +2028,10 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
|
|
|
1761
2028
|
}
|
|
1762
2029
|
}
|
|
1763
2030
|
if !metadata_map.is_empty() {
|
|
1764
|
-
body.insert(
|
|
2031
|
+
body.insert(
|
|
2032
|
+
"metadata".to_string(),
|
|
2033
|
+
serde_json::Value::Object(metadata_map),
|
|
2034
|
+
);
|
|
1765
2035
|
}
|
|
1766
2036
|
|
|
1767
2037
|
let response = authed_request(
|
|
@@ -1886,7 +2156,9 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
|
|
|
1886
2156
|
},
|
|
1887
2157
|
"npm self-update",
|
|
1888
2158
|
)?;
|
|
1889
|
-
println!(
|
|
2159
|
+
println!(
|
|
2160
|
+
"Updated via npm. Restart your shell if `reallink --version` still shows old version."
|
|
2161
|
+
);
|
|
1890
2162
|
return Ok(());
|
|
1891
2163
|
}
|
|
1892
2164
|
|
|
@@ -1952,6 +2224,8 @@ async fn main() -> Result<()> {
|
|
|
1952
2224
|
Commands::File { command } => match command {
|
|
1953
2225
|
FileCommands::List(args) => file_list_command(&client, args).await?,
|
|
1954
2226
|
FileCommands::Get(args) => file_get_command(&client, args).await?,
|
|
2227
|
+
FileCommands::Stat(args) => file_stat_command(&client, args).await?,
|
|
2228
|
+
FileCommands::Download(args) => file_download_command(&client, args).await?,
|
|
1955
2229
|
FileCommands::Upload(args) => file_upload_command(&client, args).await?,
|
|
1956
2230
|
FileCommands::Mkdir(args) => file_mkdir_command(&client, args).await?,
|
|
1957
2231
|
FileCommands::Move(args) => file_move_command(&client, args).await?,
|
package/scripts/postinstall.cjs
CHANGED
|
@@ -6,13 +6,26 @@ const path = require("node:path");
|
|
|
6
6
|
const packageRoot = path.resolve(__dirname, "..");
|
|
7
7
|
const manifestPath = path.join(packageRoot, "rust", "Cargo.toml");
|
|
8
8
|
const binaryName = process.platform === "win32" ? "reallink-cli.exe" : "reallink-cli";
|
|
9
|
-
const
|
|
9
|
+
const prebuiltBinaryPath = path.join(
|
|
10
|
+
packageRoot,
|
|
11
|
+
"prebuilt",
|
|
12
|
+
`${process.platform}-${process.arch}`,
|
|
13
|
+
binaryName
|
|
14
|
+
);
|
|
15
|
+
const releaseBinaryCandidates = [
|
|
16
|
+
path.resolve(packageRoot, "..", "..", "target", "release", binaryName),
|
|
17
|
+
path.join(packageRoot, "rust", "target", "release", binaryName)
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function hasReleaseBinary() {
|
|
21
|
+
return releaseBinaryCandidates.some((candidate) => fs.existsSync(candidate));
|
|
22
|
+
}
|
|
10
23
|
|
|
11
24
|
if (process.env.REALLINK_SKIP_BUILD === "1") {
|
|
12
25
|
process.exit(0);
|
|
13
26
|
}
|
|
14
27
|
|
|
15
|
-
if (fs.existsSync(
|
|
28
|
+
if (hasReleaseBinary() || fs.existsSync(prebuiltBinaryPath)) {
|
|
16
29
|
process.exit(0);
|
|
17
30
|
}
|
|
18
31
|
|
|
@@ -0,0 +1,49 @@
|
|
|
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 releaseBinaryCandidates = [
|
|
10
|
+
path.resolve(packageRoot, "..", "..", "target", "release", binaryName),
|
|
11
|
+
path.join(packageRoot, "rust", "target", "release", binaryName)
|
|
12
|
+
];
|
|
13
|
+
const prebuiltDir = path.join(packageRoot, "prebuilt", `${process.platform}-${process.arch}`);
|
|
14
|
+
const prebuiltBinaryPath = path.join(prebuiltDir, binaryName);
|
|
15
|
+
|
|
16
|
+
function resolveNewestReleaseBinary() {
|
|
17
|
+
const existing = releaseBinaryCandidates
|
|
18
|
+
.filter((candidate) => fs.existsSync(candidate))
|
|
19
|
+
.map((candidate) => ({
|
|
20
|
+
candidate,
|
|
21
|
+
mtimeMs: fs.statSync(candidate).mtimeMs
|
|
22
|
+
}))
|
|
23
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
24
|
+
return existing.length > 0 ? existing[0].candidate : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const build = spawnSync(
|
|
28
|
+
"cargo",
|
|
29
|
+
["build", "--release", "--manifest-path", manifestPath],
|
|
30
|
+
{
|
|
31
|
+
stdio: "inherit",
|
|
32
|
+
shell: process.platform === "win32"
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (build.error) {
|
|
37
|
+
console.error(`prepare-prebuilt failed: ${build.error.message}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const releaseBinaryPath = resolveNewestReleaseBinary();
|
|
42
|
+
if (build.status !== 0 || !releaseBinaryPath) {
|
|
43
|
+
console.error("prepare-prebuilt failed: release binary was not produced");
|
|
44
|
+
process.exit(build.status ?? 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fs.mkdirSync(prebuiltDir, { recursive: true });
|
|
48
|
+
fs.copyFileSync(releaseBinaryPath, prebuiltBinaryPath);
|
|
49
|
+
console.log(`Prepared prebuilt binary: ${prebuiltBinaryPath}`);
|