reallink-cli 0.1.10 → 0.1.12
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 +128 -1
- package/package.json +4 -3
- package/prebuilt/win32-x64/reallink-cli.exe +0 -0
- package/rust/Cargo.lock +121 -1
- package/rust/Cargo.toml +4 -2
- package/rust/src/generated/contract.rs +52 -0
- package/rust/src/generated/mod.rs +1 -0
- package/rust/src/main.rs +2882 -246
- package/rust/src/unreal.rs +266 -0
package/rust/src/main.rs
CHANGED
|
@@ -1,35 +1,74 @@
|
|
|
1
1
|
use anyhow::{anyhow, Context, Result};
|
|
2
|
-
use clap::{ArgAction, Args, Parser, Subcommand};
|
|
2
|
+
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
|
|
3
3
|
use reqwest::{Method, StatusCode};
|
|
4
4
|
use serde::{Deserialize, Serialize};
|
|
5
|
+
use sha2::Digest;
|
|
5
6
|
use std::fs;
|
|
6
|
-
use std::io::{self, Write};
|
|
7
|
+
use std::io::{self, Read, Write};
|
|
7
8
|
use std::path::{Path, PathBuf};
|
|
8
9
|
use std::process::Command;
|
|
9
10
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
11
|
+
use tokio::fs as tokio_fs;
|
|
12
|
+
use tokio::io::AsyncWriteExt;
|
|
10
13
|
use tokio::time::sleep;
|
|
11
14
|
|
|
15
|
+
mod unreal;
|
|
16
|
+
mod generated;
|
|
17
|
+
use unreal::{
|
|
18
|
+
LinkDoctorArgs, LinkOpenArgs, LinkPathsArgs, LinkPluginInstallArgs, LinkPluginListArgs,
|
|
19
|
+
LinkRemoveArgs, LinkRunArgs, LinkUnrealArgs, LinkUseArgs, PluginIndexFile, UnrealLinkRecord,
|
|
20
|
+
UnrealLinksConfig,
|
|
21
|
+
};
|
|
22
|
+
|
|
12
23
|
const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
|
|
13
24
|
const CONFIG_DIR_ENV: &str = "REALLINK_CONFIG_DIR";
|
|
14
25
|
const SESSION_DIR_NAME: &str = "reallink";
|
|
15
26
|
const SESSION_FILE_NAME: &str = "session.json";
|
|
27
|
+
const UNREAL_LINKS_FILE_NAME: &str = "unreal-links.json";
|
|
16
28
|
const UPDATE_CACHE_FILE_NAME: &str = "update-check.json";
|
|
17
29
|
const VERSION_CHECK_INTERVAL_MS: u128 = 24 * 60 * 60 * 1000;
|
|
18
30
|
const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
|
|
19
31
|
|
|
20
32
|
#[derive(Parser)]
|
|
21
|
-
#[command(
|
|
33
|
+
#[command(
|
|
34
|
+
name = "reallink",
|
|
35
|
+
bin_name = "reallink",
|
|
36
|
+
version,
|
|
37
|
+
about = "Reallink CLI"
|
|
38
|
+
)]
|
|
22
39
|
struct Cli {
|
|
40
|
+
#[arg(
|
|
41
|
+
long = "format",
|
|
42
|
+
global = true,
|
|
43
|
+
value_enum,
|
|
44
|
+
default_value_t = OutputFormat::Json,
|
|
45
|
+
help = "CLI output format (json is agent-friendly)"
|
|
46
|
+
)]
|
|
47
|
+
output: OutputFormat,
|
|
23
48
|
#[command(subcommand)]
|
|
24
49
|
command: Commands,
|
|
25
50
|
}
|
|
26
51
|
|
|
52
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
53
|
+
enum OutputFormat {
|
|
54
|
+
Json,
|
|
55
|
+
Text,
|
|
56
|
+
}
|
|
57
|
+
|
|
27
58
|
#[derive(Subcommand)]
|
|
28
59
|
enum Commands {
|
|
29
60
|
Login(LoginArgs),
|
|
30
61
|
Whoami(BaseArgs),
|
|
31
62
|
Logout,
|
|
32
63
|
SelfUpdate(SelfUpdateArgs),
|
|
64
|
+
Org {
|
|
65
|
+
#[command(subcommand)]
|
|
66
|
+
command: OrgCommands,
|
|
67
|
+
},
|
|
68
|
+
Link {
|
|
69
|
+
#[command(subcommand)]
|
|
70
|
+
command: unreal::LinkCommands,
|
|
71
|
+
},
|
|
33
72
|
Project {
|
|
34
73
|
#[command(subcommand)]
|
|
35
74
|
command: ProjectCommands,
|
|
@@ -86,12 +125,33 @@ enum ProjectCommands {
|
|
|
86
125
|
Get(ProjectGetArgs),
|
|
87
126
|
Update(ProjectUpdateArgs),
|
|
88
127
|
Delete(ProjectDeleteArgs),
|
|
128
|
+
Members(ProjectMembersArgs),
|
|
129
|
+
AddMember(ProjectAddMemberArgs),
|
|
130
|
+
UpdateMember(ProjectUpdateMemberArgs),
|
|
131
|
+
RemoveMember(ProjectRemoveMemberArgs),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[derive(Subcommand)]
|
|
135
|
+
enum OrgCommands {
|
|
136
|
+
List(BaseArgs),
|
|
137
|
+
Create(OrgCreateArgs),
|
|
138
|
+
Get(OrgGetArgs),
|
|
139
|
+
Update(OrgUpdateArgs),
|
|
140
|
+
Delete(OrgDeleteArgs),
|
|
141
|
+
Invites(OrgInvitesArgs),
|
|
142
|
+
Invite(OrgInviteArgs),
|
|
143
|
+
Members(OrgMembersArgs),
|
|
144
|
+
AddMember(OrgAddMemberArgs),
|
|
145
|
+
UpdateMember(OrgUpdateMemberArgs),
|
|
146
|
+
RemoveMember(OrgRemoveMemberArgs),
|
|
89
147
|
}
|
|
90
148
|
|
|
91
149
|
#[derive(Subcommand)]
|
|
92
150
|
enum FileCommands {
|
|
93
151
|
List(FileListArgs),
|
|
94
152
|
Get(FileGetArgs),
|
|
153
|
+
Stat(FileStatArgs),
|
|
154
|
+
Download(FileDownloadArgs),
|
|
95
155
|
Upload(FileUploadArgs),
|
|
96
156
|
Mkdir(FileMkdirArgs),
|
|
97
157
|
Move(FileMoveArgs),
|
|
@@ -151,6 +211,29 @@ struct FileGetArgs {
|
|
|
151
211
|
base_url: Option<String>,
|
|
152
212
|
}
|
|
153
213
|
|
|
214
|
+
#[derive(Args)]
|
|
215
|
+
struct FileStatArgs {
|
|
216
|
+
#[arg(long)]
|
|
217
|
+
asset_id: String,
|
|
218
|
+
#[arg(long)]
|
|
219
|
+
base_url: Option<String>,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#[derive(Args)]
|
|
223
|
+
struct FileDownloadArgs {
|
|
224
|
+
#[arg(long)]
|
|
225
|
+
asset_id: String,
|
|
226
|
+
#[arg(long = "output")]
|
|
227
|
+
output_path: Option<PathBuf>,
|
|
228
|
+
#[arg(
|
|
229
|
+
long,
|
|
230
|
+
help = "Resume download from existing output file size using HTTP Range"
|
|
231
|
+
)]
|
|
232
|
+
resume: bool,
|
|
233
|
+
#[arg(long)]
|
|
234
|
+
base_url: Option<String>,
|
|
235
|
+
}
|
|
236
|
+
|
|
154
237
|
#[derive(Args)]
|
|
155
238
|
struct FileUploadArgs {
|
|
156
239
|
#[arg(long)]
|
|
@@ -339,6 +422,146 @@ struct ProjectDeleteArgs {
|
|
|
339
422
|
base_url: Option<String>,
|
|
340
423
|
}
|
|
341
424
|
|
|
425
|
+
#[derive(Args)]
|
|
426
|
+
struct OrgCreateArgs {
|
|
427
|
+
#[arg(long)]
|
|
428
|
+
name: String,
|
|
429
|
+
#[arg(long)]
|
|
430
|
+
base_url: Option<String>,
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#[derive(Args)]
|
|
434
|
+
struct OrgGetArgs {
|
|
435
|
+
#[arg(long)]
|
|
436
|
+
org_id: String,
|
|
437
|
+
#[arg(long)]
|
|
438
|
+
base_url: Option<String>,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
#[derive(Args)]
|
|
442
|
+
struct OrgUpdateArgs {
|
|
443
|
+
#[arg(long)]
|
|
444
|
+
org_id: String,
|
|
445
|
+
#[arg(long)]
|
|
446
|
+
name: String,
|
|
447
|
+
#[arg(long)]
|
|
448
|
+
base_url: Option<String>,
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#[derive(Args)]
|
|
452
|
+
struct OrgDeleteArgs {
|
|
453
|
+
#[arg(long)]
|
|
454
|
+
org_id: String,
|
|
455
|
+
#[arg(long)]
|
|
456
|
+
base_url: Option<String>,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#[derive(Args)]
|
|
460
|
+
struct OrgMembersArgs {
|
|
461
|
+
#[arg(long)]
|
|
462
|
+
org_id: String,
|
|
463
|
+
#[arg(long)]
|
|
464
|
+
base_url: Option<String>,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#[derive(Args)]
|
|
468
|
+
struct OrgAddMemberArgs {
|
|
469
|
+
#[arg(long)]
|
|
470
|
+
org_id: String,
|
|
471
|
+
#[arg(long)]
|
|
472
|
+
email: String,
|
|
473
|
+
#[arg(long, default_value = "member")]
|
|
474
|
+
role: String,
|
|
475
|
+
#[arg(long)]
|
|
476
|
+
base_url: Option<String>,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
#[derive(Args)]
|
|
480
|
+
struct OrgUpdateMemberArgs {
|
|
481
|
+
#[arg(long)]
|
|
482
|
+
org_id: String,
|
|
483
|
+
#[arg(long)]
|
|
484
|
+
user_id: String,
|
|
485
|
+
#[arg(long)]
|
|
486
|
+
role: String,
|
|
487
|
+
#[arg(long)]
|
|
488
|
+
base_url: Option<String>,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#[derive(Args)]
|
|
492
|
+
struct OrgRemoveMemberArgs {
|
|
493
|
+
#[arg(long)]
|
|
494
|
+
org_id: String,
|
|
495
|
+
#[arg(long)]
|
|
496
|
+
user_id: String,
|
|
497
|
+
#[arg(long)]
|
|
498
|
+
base_url: Option<String>,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#[derive(Args)]
|
|
502
|
+
struct OrgInvitesArgs {
|
|
503
|
+
#[arg(long)]
|
|
504
|
+
org_id: String,
|
|
505
|
+
#[arg(long)]
|
|
506
|
+
base_url: Option<String>,
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
#[derive(Args)]
|
|
510
|
+
struct OrgInviteArgs {
|
|
511
|
+
#[arg(long)]
|
|
512
|
+
org_id: String,
|
|
513
|
+
#[arg(long)]
|
|
514
|
+
email: String,
|
|
515
|
+
#[arg(long, default_value = "member")]
|
|
516
|
+
role: String,
|
|
517
|
+
#[arg(long, default_value_t = 7)]
|
|
518
|
+
expires_in_days: u32,
|
|
519
|
+
#[arg(long)]
|
|
520
|
+
base_url: Option<String>,
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#[derive(Args)]
|
|
524
|
+
struct ProjectMembersArgs {
|
|
525
|
+
#[arg(long)]
|
|
526
|
+
project_id: String,
|
|
527
|
+
#[arg(long)]
|
|
528
|
+
base_url: Option<String>,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#[derive(Args)]
|
|
532
|
+
struct ProjectAddMemberArgs {
|
|
533
|
+
#[arg(long)]
|
|
534
|
+
project_id: String,
|
|
535
|
+
#[arg(long)]
|
|
536
|
+
email: String,
|
|
537
|
+
#[arg(long, default_value = "viewer")]
|
|
538
|
+
role: String,
|
|
539
|
+
#[arg(long)]
|
|
540
|
+
base_url: Option<String>,
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#[derive(Args)]
|
|
544
|
+
struct ProjectUpdateMemberArgs {
|
|
545
|
+
#[arg(long)]
|
|
546
|
+
project_id: String,
|
|
547
|
+
#[arg(long)]
|
|
548
|
+
user_id: String,
|
|
549
|
+
#[arg(long)]
|
|
550
|
+
role: String,
|
|
551
|
+
#[arg(long)]
|
|
552
|
+
base_url: Option<String>,
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
#[derive(Args)]
|
|
556
|
+
struct ProjectRemoveMemberArgs {
|
|
557
|
+
#[arg(long)]
|
|
558
|
+
project_id: String,
|
|
559
|
+
#[arg(long)]
|
|
560
|
+
user_id: String,
|
|
561
|
+
#[arg(long)]
|
|
562
|
+
base_url: Option<String>,
|
|
563
|
+
}
|
|
564
|
+
|
|
342
565
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
343
566
|
struct SessionConfig {
|
|
344
567
|
base_url: String,
|
|
@@ -481,6 +704,95 @@ struct ProjectResponse {
|
|
|
481
704
|
access_level: Option<String>,
|
|
482
705
|
}
|
|
483
706
|
|
|
707
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
708
|
+
#[serde(rename_all = "camelCase")]
|
|
709
|
+
struct OrgRecord {
|
|
710
|
+
id: String,
|
|
711
|
+
slug: String,
|
|
712
|
+
name: String,
|
|
713
|
+
owner_user_id: Option<String>,
|
|
714
|
+
created_at: Option<String>,
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
718
|
+
struct ListOrgsResponse {
|
|
719
|
+
orgs: Vec<OrgRecord>,
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
723
|
+
#[serde(rename_all = "camelCase")]
|
|
724
|
+
struct OrgResponse {
|
|
725
|
+
org: OrgRecord,
|
|
726
|
+
role: Option<String>,
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
730
|
+
#[serde(rename_all = "camelCase")]
|
|
731
|
+
struct OrgMemberRecord {
|
|
732
|
+
org_id: String,
|
|
733
|
+
user_id: String,
|
|
734
|
+
role: String,
|
|
735
|
+
created_at: Option<String>,
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
739
|
+
struct ListOrgMembersResponse {
|
|
740
|
+
members: Vec<OrgMemberRecord>,
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
744
|
+
struct OrgMemberResponse {
|
|
745
|
+
member: OrgMemberRecord,
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
749
|
+
#[serde(rename_all = "camelCase")]
|
|
750
|
+
struct OrgInviteRecordApi {
|
|
751
|
+
id: String,
|
|
752
|
+
org_id: String,
|
|
753
|
+
email: String,
|
|
754
|
+
role: String,
|
|
755
|
+
invited_by_user_id: Option<String>,
|
|
756
|
+
status: String,
|
|
757
|
+
expires_at: String,
|
|
758
|
+
accepted_by_user_id: Option<String>,
|
|
759
|
+
accepted_at: Option<String>,
|
|
760
|
+
created_at: Option<String>,
|
|
761
|
+
updated_at: Option<String>,
|
|
762
|
+
url: Option<String>,
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
766
|
+
struct ListOrgInvitesResponse {
|
|
767
|
+
invites: Vec<OrgInviteRecordApi>,
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
771
|
+
struct OrgInviteResponse {
|
|
772
|
+
invite: OrgInviteRecordApi,
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
776
|
+
#[serde(rename_all = "camelCase")]
|
|
777
|
+
struct ProjectMemberRecordApi {
|
|
778
|
+
project_id: String,
|
|
779
|
+
user_id: String,
|
|
780
|
+
role: String,
|
|
781
|
+
invited_by_user_id: Option<String>,
|
|
782
|
+
created_at: Option<String>,
|
|
783
|
+
updated_at: Option<String>,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
787
|
+
struct ListProjectMembersResponse {
|
|
788
|
+
members: Vec<ProjectMemberRecordApi>,
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
792
|
+
struct ProjectMemberResponse {
|
|
793
|
+
member: ProjectMemberRecordApi,
|
|
794
|
+
}
|
|
795
|
+
|
|
484
796
|
#[derive(Debug, Serialize, Deserialize)]
|
|
485
797
|
#[serde(rename_all = "camelCase")]
|
|
486
798
|
struct AssetRecord {
|
|
@@ -508,6 +820,20 @@ struct AssetResponse {
|
|
|
508
820
|
asset: AssetRecord,
|
|
509
821
|
}
|
|
510
822
|
|
|
823
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
824
|
+
#[serde(rename_all = "camelCase")]
|
|
825
|
+
struct AssetContentMetadata {
|
|
826
|
+
part_count: i64,
|
|
827
|
+
content_length: i64,
|
|
828
|
+
accept_ranges: String,
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
832
|
+
struct AssetMetadataResponse {
|
|
833
|
+
asset: AssetRecord,
|
|
834
|
+
content: AssetContentMetadata,
|
|
835
|
+
}
|
|
836
|
+
|
|
511
837
|
#[derive(Debug, Serialize)]
|
|
512
838
|
#[serde(rename_all = "camelCase")]
|
|
513
839
|
struct UploadIntentRequest {
|
|
@@ -598,13 +924,29 @@ fn load_jsonc_file(path: &Path, label: &str) -> Result<serde_json::Value> {
|
|
|
598
924
|
parse_jsonc_str(&raw, &format!("{} file {}", label, path.display()))
|
|
599
925
|
}
|
|
600
926
|
|
|
601
|
-
fn parse_object_from_value(
|
|
927
|
+
fn parse_object_from_value(
|
|
928
|
+
value: serde_json::Value,
|
|
929
|
+
context: &str,
|
|
930
|
+
) -> Result<serde_json::Map<String, serde_json::Value>> {
|
|
602
931
|
match value {
|
|
603
932
|
serde_json::Value::Object(map) => Ok(map),
|
|
604
933
|
_ => Err(anyhow!("{} must be a JSON object", context)),
|
|
605
934
|
}
|
|
606
935
|
}
|
|
607
936
|
|
|
937
|
+
fn print_json(value: &serde_json::Value) -> Result<()> {
|
|
938
|
+
println!("{}", serde_json::to_string_pretty(value)?);
|
|
939
|
+
Ok(())
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
fn emit_text_or_json(output: OutputFormat, text: &str, value: serde_json::Value) -> Result<()> {
|
|
943
|
+
match output {
|
|
944
|
+
OutputFormat::Text => println!("{}", text),
|
|
945
|
+
OutputFormat::Json => print_json(&value)?,
|
|
946
|
+
}
|
|
947
|
+
Ok(())
|
|
948
|
+
}
|
|
949
|
+
|
|
608
950
|
fn now_epoch_ms() -> u128 {
|
|
609
951
|
SystemTime::now()
|
|
610
952
|
.duration_since(UNIX_EPOCH)
|
|
@@ -633,6 +975,11 @@ fn update_cache_path() -> Result<PathBuf> {
|
|
|
633
975
|
Ok(base.join(SESSION_DIR_NAME).join(UPDATE_CACHE_FILE_NAME))
|
|
634
976
|
}
|
|
635
977
|
|
|
978
|
+
fn unreal_links_path() -> Result<PathBuf> {
|
|
979
|
+
let base = resolve_config_root()?;
|
|
980
|
+
Ok(base.join(SESSION_DIR_NAME).join(UNREAL_LINKS_FILE_NAME))
|
|
981
|
+
}
|
|
982
|
+
|
|
636
983
|
fn session_path_display() -> String {
|
|
637
984
|
config_path()
|
|
638
985
|
.map(|path| path.display().to_string())
|
|
@@ -659,72 +1006,329 @@ fn write_atomic(path: &Path, payload: &[u8]) -> Result<()> {
|
|
|
659
1006
|
fn save_session(session: &SessionConfig) -> Result<()> {
|
|
660
1007
|
let path = config_path()?;
|
|
661
1008
|
if let Some(parent) = path.parent() {
|
|
662
|
-
fs::create_dir_all(parent)
|
|
1009
|
+
fs::create_dir_all(parent)
|
|
1010
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
663
1011
|
}
|
|
664
1012
|
let payload = serde_json::to_vec_pretty(session)?;
|
|
665
1013
|
write_atomic(&path, &payload)?;
|
|
666
1014
|
Ok(())
|
|
667
1015
|
}
|
|
668
1016
|
|
|
669
|
-
fn
|
|
670
|
-
let path =
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
"No active session at {}. Run `reallink login` first.",
|
|
674
|
-
path.display()
|
|
675
|
-
)
|
|
676
|
-
})?;
|
|
677
|
-
let session: SessionConfig = serde_json::from_slice(&raw)
|
|
678
|
-
.with_context(|| format!("Invalid session format in {}", path.display()))?;
|
|
679
|
-
Ok(session)
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
fn clear_session() -> Result<bool> {
|
|
683
|
-
let path = config_path()?;
|
|
684
|
-
if path.exists() {
|
|
685
|
-
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
|
686
|
-
return Ok(true);
|
|
1017
|
+
fn load_unreal_links() -> Result<UnrealLinksConfig> {
|
|
1018
|
+
let path = unreal_links_path()?;
|
|
1019
|
+
if !path.exists() {
|
|
1020
|
+
return Ok(UnrealLinksConfig::default());
|
|
687
1021
|
}
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1022
|
+
let raw = fs::read(&path)
|
|
1023
|
+
.with_context(|| format!("Failed to read unreal links {}", path.display()))?;
|
|
1024
|
+
let mut config: UnrealLinksConfig = serde_json::from_slice(&raw)
|
|
1025
|
+
.with_context(|| format!("Invalid unreal links format in {}", path.display()))?;
|
|
1026
|
+
if config.version == 0 {
|
|
1027
|
+
config.version = 1;
|
|
1028
|
+
}
|
|
1029
|
+
Ok(config)
|
|
695
1030
|
}
|
|
696
1031
|
|
|
697
|
-
fn
|
|
698
|
-
let path =
|
|
1032
|
+
fn save_unreal_links(config: &UnrealLinksConfig) -> Result<()> {
|
|
1033
|
+
let path = unreal_links_path()?;
|
|
699
1034
|
if let Some(parent) = path.parent() {
|
|
700
|
-
fs::create_dir_all(parent)
|
|
1035
|
+
fs::create_dir_all(parent)
|
|
1036
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
701
1037
|
}
|
|
702
|
-
let payload = serde_json::to_vec_pretty(
|
|
1038
|
+
let payload = serde_json::to_vec_pretty(config)?;
|
|
703
1039
|
write_atomic(&path, &payload)?;
|
|
704
1040
|
Ok(())
|
|
705
1041
|
}
|
|
706
1042
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1043
|
+
fn normalize_path_for_compare(path: &Path) -> String {
|
|
1044
|
+
let normalized = path.to_string_lossy().replace('\\', "/");
|
|
1045
|
+
if cfg!(windows) {
|
|
1046
|
+
normalized.to_ascii_lowercase()
|
|
1047
|
+
} else {
|
|
1048
|
+
normalized
|
|
711
1049
|
}
|
|
712
1050
|
}
|
|
713
1051
|
|
|
714
|
-
fn
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
.
|
|
1052
|
+
fn normalize_path_string_for_compare(path: &str) -> String {
|
|
1053
|
+
let normalized = path.replace('\\', "/");
|
|
1054
|
+
if cfg!(windows) {
|
|
1055
|
+
normalized.to_ascii_lowercase()
|
|
1056
|
+
} else {
|
|
1057
|
+
normalized
|
|
1058
|
+
}
|
|
718
1059
|
}
|
|
719
1060
|
|
|
720
|
-
fn
|
|
721
|
-
let
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
}
|
|
1061
|
+
fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf> {
|
|
1062
|
+
let absolute = if path.is_absolute() {
|
|
1063
|
+
path.to_path_buf()
|
|
1064
|
+
} else {
|
|
1065
|
+
std::env::current_dir()
|
|
1066
|
+
.with_context(|| "Failed to resolve current working directory")?
|
|
1067
|
+
.join(path)
|
|
1068
|
+
};
|
|
1069
|
+
fs::canonicalize(&absolute)
|
|
1070
|
+
.with_context(|| format!("Failed to resolve {} path {}", label, absolute.display()))
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
fn resolve_uproject_path(path: &Path) -> Result<PathBuf> {
|
|
1074
|
+
let canonical = canonicalize_existing_path(path, "uproject")?;
|
|
1075
|
+
if canonical.is_file() {
|
|
1076
|
+
let is_uproject = canonical
|
|
1077
|
+
.extension()
|
|
1078
|
+
.and_then(|value| value.to_str())
|
|
1079
|
+
.map(|value| value.eq_ignore_ascii_case("uproject"))
|
|
1080
|
+
.unwrap_or(false);
|
|
1081
|
+
if !is_uproject {
|
|
1082
|
+
return Err(anyhow!(
|
|
1083
|
+
"Expected a .uproject file, got {}",
|
|
1084
|
+
canonical.display()
|
|
1085
|
+
));
|
|
1086
|
+
}
|
|
1087
|
+
return Ok(canonical);
|
|
1088
|
+
}
|
|
1089
|
+
if canonical.is_dir() {
|
|
1090
|
+
let mut entries: Vec<PathBuf> = fs::read_dir(&canonical)
|
|
1091
|
+
.with_context(|| format!("Failed to read directory {}", canonical.display()))?
|
|
1092
|
+
.filter_map(|entry| entry.ok().map(|item| item.path()))
|
|
1093
|
+
.filter(|entry| {
|
|
1094
|
+
entry
|
|
1095
|
+
.extension()
|
|
1096
|
+
.and_then(|value| value.to_str())
|
|
1097
|
+
.map(|value| value.eq_ignore_ascii_case("uproject"))
|
|
1098
|
+
.unwrap_or(false)
|
|
1099
|
+
})
|
|
1100
|
+
.collect();
|
|
1101
|
+
entries.sort();
|
|
1102
|
+
if entries.len() == 1 {
|
|
1103
|
+
return Ok(entries.remove(0));
|
|
1104
|
+
}
|
|
1105
|
+
if entries.is_empty() {
|
|
1106
|
+
return Err(anyhow!(
|
|
1107
|
+
"No .uproject file found in {}",
|
|
1108
|
+
canonical.display()
|
|
1109
|
+
));
|
|
1110
|
+
}
|
|
1111
|
+
return Err(anyhow!(
|
|
1112
|
+
"Multiple .uproject files found in {}; pass --uproject with explicit file path",
|
|
1113
|
+
canonical.display()
|
|
1114
|
+
));
|
|
1115
|
+
}
|
|
1116
|
+
Err(anyhow!(
|
|
1117
|
+
"Path {} is neither a file nor directory",
|
|
1118
|
+
canonical.display()
|
|
1119
|
+
))
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
fn candidate_editor_paths_from_engine_root(engine_root: &Path) -> Vec<PathBuf> {
|
|
1123
|
+
vec![
|
|
1124
|
+
engine_root
|
|
1125
|
+
.join("Engine")
|
|
1126
|
+
.join("Binaries")
|
|
1127
|
+
.join("Win64")
|
|
1128
|
+
.join("UnrealEditor.exe"),
|
|
1129
|
+
engine_root
|
|
1130
|
+
.join("Engine")
|
|
1131
|
+
.join("Binaries")
|
|
1132
|
+
.join("Win64")
|
|
1133
|
+
.join("UE4Editor.exe"),
|
|
1134
|
+
engine_root
|
|
1135
|
+
.join("Engine")
|
|
1136
|
+
.join("Binaries")
|
|
1137
|
+
.join("Linux")
|
|
1138
|
+
.join("UnrealEditor"),
|
|
1139
|
+
engine_root
|
|
1140
|
+
.join("Engine")
|
|
1141
|
+
.join("Binaries")
|
|
1142
|
+
.join("Linux")
|
|
1143
|
+
.join("UE4Editor"),
|
|
1144
|
+
engine_root
|
|
1145
|
+
.join("Engine")
|
|
1146
|
+
.join("Binaries")
|
|
1147
|
+
.join("Mac")
|
|
1148
|
+
.join("UnrealEditor.app")
|
|
1149
|
+
.join("Contents")
|
|
1150
|
+
.join("MacOS")
|
|
1151
|
+
.join("UnrealEditor"),
|
|
1152
|
+
engine_root
|
|
1153
|
+
.join("Engine")
|
|
1154
|
+
.join("Binaries")
|
|
1155
|
+
.join("Mac")
|
|
1156
|
+
.join("UE4Editor.app")
|
|
1157
|
+
.join("Contents")
|
|
1158
|
+
.join("MacOS")
|
|
1159
|
+
.join("UE4Editor"),
|
|
1160
|
+
]
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
fn resolve_editor_from_engine_root(engine_root: &Path) -> Result<PathBuf> {
|
|
1164
|
+
let root = canonicalize_existing_path(engine_root, "engine root")?;
|
|
1165
|
+
if !root.is_dir() {
|
|
1166
|
+
return Err(anyhow!(
|
|
1167
|
+
"Engine root must be a directory: {}",
|
|
1168
|
+
root.display()
|
|
1169
|
+
));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
for candidate in candidate_editor_paths_from_engine_root(&root) {
|
|
1173
|
+
if candidate.exists() {
|
|
1174
|
+
return Ok(candidate);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
Err(anyhow!(
|
|
1179
|
+
"Could not find Unreal editor binary under {}. Pass --editor explicitly.",
|
|
1180
|
+
root.display()
|
|
1181
|
+
))
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
fn detect_engine_root_from_editor(editor_path: &Path) -> Option<PathBuf> {
|
|
1185
|
+
let parts: Vec<String> = editor_path
|
|
1186
|
+
.components()
|
|
1187
|
+
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
|
1188
|
+
.collect();
|
|
1189
|
+
let mut engine_index = None;
|
|
1190
|
+
for (index, value) in parts.iter().enumerate() {
|
|
1191
|
+
if value.eq_ignore_ascii_case("Engine") {
|
|
1192
|
+
engine_index = Some(index);
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
let index = engine_index?;
|
|
1197
|
+
let mut root = PathBuf::new();
|
|
1198
|
+
for segment in &parts[..index] {
|
|
1199
|
+
root.push(segment);
|
|
1200
|
+
}
|
|
1201
|
+
if root.as_os_str().is_empty() {
|
|
1202
|
+
None
|
|
1203
|
+
} else {
|
|
1204
|
+
Some(root)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
fn resolve_editor_and_engine(
|
|
1209
|
+
explicit_editor: Option<PathBuf>,
|
|
1210
|
+
explicit_engine_root: Option<PathBuf>,
|
|
1211
|
+
) -> Result<(PathBuf, Option<PathBuf>)> {
|
|
1212
|
+
if let Some(editor) = explicit_editor {
|
|
1213
|
+
let editor_path = canonicalize_existing_path(&editor, "editor")?;
|
|
1214
|
+
if !editor_path.is_file() {
|
|
1215
|
+
return Err(anyhow!(
|
|
1216
|
+
"Editor path must be a file: {}",
|
|
1217
|
+
editor_path.display()
|
|
1218
|
+
));
|
|
1219
|
+
}
|
|
1220
|
+
let engine_root = if let Some(root) = explicit_engine_root {
|
|
1221
|
+
Some(canonicalize_existing_path(&root, "engine root")?)
|
|
1222
|
+
} else {
|
|
1223
|
+
detect_engine_root_from_editor(&editor_path)
|
|
1224
|
+
};
|
|
1225
|
+
return Ok((editor_path, engine_root));
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if let Some(engine_root) = explicit_engine_root {
|
|
1229
|
+
let root = canonicalize_existing_path(&engine_root, "engine root")?;
|
|
1230
|
+
let editor_path = resolve_editor_from_engine_root(&root)?;
|
|
1231
|
+
return Ok((editor_path, Some(root)));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if let Ok(editor_env) = std::env::var("REALLINK_UNREAL_EDITOR") {
|
|
1235
|
+
let trimmed = editor_env.trim();
|
|
1236
|
+
if !trimmed.is_empty() {
|
|
1237
|
+
let editor_path = canonicalize_existing_path(Path::new(trimmed), "editor")?;
|
|
1238
|
+
let engine_root = detect_engine_root_from_editor(&editor_path);
|
|
1239
|
+
return Ok((editor_path, engine_root));
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
if let Ok(editor_env) = std::env::var("UE_EDITOR_PATH") {
|
|
1243
|
+
let trimmed = editor_env.trim();
|
|
1244
|
+
if !trimmed.is_empty() {
|
|
1245
|
+
let editor_path = canonicalize_existing_path(Path::new(trimmed), "editor")?;
|
|
1246
|
+
let engine_root = detect_engine_root_from_editor(&editor_path);
|
|
1247
|
+
return Ok((editor_path, engine_root));
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if let Ok(engine_env) = std::env::var("REALLINK_UNREAL_ENGINE_ROOT") {
|
|
1251
|
+
let trimmed = engine_env.trim();
|
|
1252
|
+
if !trimmed.is_empty() {
|
|
1253
|
+
let root = canonicalize_existing_path(Path::new(trimmed), "engine root")?;
|
|
1254
|
+
let editor_path = resolve_editor_from_engine_root(&root)?;
|
|
1255
|
+
return Ok((editor_path, Some(root)));
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if let Ok(engine_env) = std::env::var("UE_ENGINE_ROOT") {
|
|
1259
|
+
let trimmed = engine_env.trim();
|
|
1260
|
+
if !trimmed.is_empty() {
|
|
1261
|
+
let root = canonicalize_existing_path(Path::new(trimmed), "engine root")?;
|
|
1262
|
+
let editor_path = resolve_editor_from_engine_root(&root)?;
|
|
1263
|
+
return Ok((editor_path, Some(root)));
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
Err(anyhow!(
|
|
1268
|
+
"Unable to resolve Unreal editor. Pass --editor or --engine-root, or set REALLINK_UNREAL_EDITOR/REALLINK_UNREAL_ENGINE_ROOT."
|
|
1269
|
+
))
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
fn load_session() -> Result<SessionConfig> {
|
|
1273
|
+
let path = config_path()?;
|
|
1274
|
+
let raw = fs::read(&path).with_context(|| {
|
|
1275
|
+
format!(
|
|
1276
|
+
"No active session at {}. Run `reallink login` first.",
|
|
1277
|
+
path.display()
|
|
1278
|
+
)
|
|
1279
|
+
})?;
|
|
1280
|
+
let session: SessionConfig = serde_json::from_slice(&raw)
|
|
1281
|
+
.with_context(|| format!("Invalid session format in {}", path.display()))?;
|
|
1282
|
+
Ok(session)
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
fn clear_session() -> Result<bool> {
|
|
1286
|
+
let path = config_path()?;
|
|
1287
|
+
if path.exists() {
|
|
1288
|
+
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
|
1289
|
+
return Ok(true);
|
|
1290
|
+
}
|
|
1291
|
+
Ok(false)
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
fn load_update_cache() -> Option<UpdateCheckCache> {
|
|
1295
|
+
let path = update_cache_path().ok()?;
|
|
1296
|
+
let raw = fs::read(path).ok()?;
|
|
1297
|
+
serde_json::from_slice(&raw).ok()
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
|
|
1301
|
+
let path = update_cache_path()?;
|
|
1302
|
+
if let Some(parent) = path.parent() {
|
|
1303
|
+
fs::create_dir_all(parent)
|
|
1304
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
1305
|
+
}
|
|
1306
|
+
let payload = serde_json::to_vec_pretty(cache)?;
|
|
1307
|
+
write_atomic(&path, &payload)?;
|
|
1308
|
+
Ok(())
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
async fn read_error_body(response: reqwest::Response) -> String {
|
|
1312
|
+
match response.text().await {
|
|
1313
|
+
Ok(text) if !text.trim().is_empty() => text,
|
|
1314
|
+
_ => "No response body".to_string(),
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
1319
|
+
request
|
|
1320
|
+
.header("x-reallink-client", "cli")
|
|
1321
|
+
.header("x-reallink-cli-version", env!("CARGO_PKG_VERSION"))
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
|
|
1325
|
+
let core = version.trim().split('-').next()?;
|
|
1326
|
+
let mut parts = core.split('.');
|
|
1327
|
+
let major = parts.next()?.parse::<u64>().ok()?;
|
|
1328
|
+
let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
|
|
1329
|
+
let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
|
|
1330
|
+
Some((major, minor, patch))
|
|
1331
|
+
}
|
|
728
1332
|
|
|
729
1333
|
fn is_newer_version(current: &str, latest: &str) -> bool {
|
|
730
1334
|
match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
|
|
@@ -755,7 +1359,12 @@ async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
|
|
|
755
1359
|
.filter(|value| !value.is_empty())
|
|
756
1360
|
}
|
|
757
1361
|
|
|
758
|
-
async fn maybe_notify_update(
|
|
1362
|
+
async fn maybe_notify_update(
|
|
1363
|
+
client: &reqwest::Client,
|
|
1364
|
+
force_refresh: bool,
|
|
1365
|
+
allow_network_fetch: bool,
|
|
1366
|
+
output: OutputFormat,
|
|
1367
|
+
) {
|
|
759
1368
|
if std::env::var("REALLINK_DISABLE_AUTO_UPDATE_CHECK")
|
|
760
1369
|
.map(|value| value == "1")
|
|
761
1370
|
.unwrap_or(false)
|
|
@@ -789,10 +1398,12 @@ async fn maybe_notify_update(client: &reqwest::Client, force_refresh: bool, allo
|
|
|
789
1398
|
let current = env!("CARGO_PKG_VERSION");
|
|
790
1399
|
if let Some(latest) = latest_version {
|
|
791
1400
|
if is_newer_version(current, &latest) {
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
1401
|
+
if output == OutputFormat::Text {
|
|
1402
|
+
eprintln!(
|
|
1403
|
+
"Update available: {} -> {}. Run `reallink self-update`.",
|
|
1404
|
+
current, latest
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
796
1407
|
}
|
|
797
1408
|
}
|
|
798
1409
|
}
|
|
@@ -824,16 +1435,43 @@ fn join_remote_path(prefix: Option<&str>, file_name: &str) -> String {
|
|
|
824
1435
|
format!("{}/{}", prefix_clean, file_clean)
|
|
825
1436
|
}
|
|
826
1437
|
|
|
1438
|
+
fn base_name_from_virtual_path(path: &str) -> String {
|
|
1439
|
+
let normalized = clean_virtual_path(path);
|
|
1440
|
+
normalized
|
|
1441
|
+
.split('/')
|
|
1442
|
+
.filter(|segment| !segment.is_empty())
|
|
1443
|
+
.next_back()
|
|
1444
|
+
.unwrap_or("download.bin")
|
|
1445
|
+
.to_string()
|
|
1446
|
+
}
|
|
1447
|
+
|
|
827
1448
|
async fn authed_request(
|
|
828
1449
|
client: &reqwest::Client,
|
|
829
1450
|
session: &mut SessionConfig,
|
|
830
1451
|
method: Method,
|
|
831
1452
|
path: &str,
|
|
832
1453
|
body: Option<serde_json::Value>,
|
|
1454
|
+
) -> Result<reqwest::Response> {
|
|
1455
|
+
authed_request_with_headers(client, session, method, path, body, &[]).await
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
async fn authed_request_with_headers(
|
|
1459
|
+
client: &reqwest::Client,
|
|
1460
|
+
session: &mut SessionConfig,
|
|
1461
|
+
method: Method,
|
|
1462
|
+
path: &str,
|
|
1463
|
+
body: Option<serde_json::Value>,
|
|
1464
|
+
extra_headers: &[(String, String)],
|
|
833
1465
|
) -> Result<reqwest::Response> {
|
|
834
1466
|
let url = format!("{}{}", normalize_base_url(&session.base_url), path);
|
|
835
|
-
let mut request =
|
|
836
|
-
|
|
1467
|
+
let mut request = with_cli_headers(
|
|
1468
|
+
client
|
|
1469
|
+
.request(method.clone(), &url)
|
|
1470
|
+
.bearer_auth(&session.access_token),
|
|
1471
|
+
);
|
|
1472
|
+
for (key, value) in extra_headers {
|
|
1473
|
+
request = request.header(key, value);
|
|
1474
|
+
}
|
|
837
1475
|
if let Some(ref body_value) = body {
|
|
838
1476
|
request = request.json(body_value);
|
|
839
1477
|
}
|
|
@@ -844,7 +1482,14 @@ async fn authed_request(
|
|
|
844
1482
|
|
|
845
1483
|
refresh_session(client, session).await?;
|
|
846
1484
|
|
|
847
|
-
let mut retry = with_cli_headers(
|
|
1485
|
+
let mut retry = with_cli_headers(
|
|
1486
|
+
client
|
|
1487
|
+
.request(method, &url)
|
|
1488
|
+
.bearer_auth(&session.access_token),
|
|
1489
|
+
);
|
|
1490
|
+
for (key, value) in extra_headers {
|
|
1491
|
+
retry = retry.header(key, value);
|
|
1492
|
+
}
|
|
848
1493
|
if let Some(ref body_value) = body {
|
|
849
1494
|
retry = retry.json(body_value);
|
|
850
1495
|
}
|
|
@@ -857,7 +1502,9 @@ async fn refresh_session(client: &reqwest::Client, session: &mut SessionConfig)
|
|
|
857
1502
|
refresh_token: session.refresh_token.clone(),
|
|
858
1503
|
session_id: session.session_id.clone(),
|
|
859
1504
|
};
|
|
860
|
-
let response = with_cli_headers(client.post(url).json(&payload))
|
|
1505
|
+
let response = with_cli_headers(client.post(url).json(&payload))
|
|
1506
|
+
.send()
|
|
1507
|
+
.await?;
|
|
861
1508
|
if !response.status().is_success() {
|
|
862
1509
|
let body = read_error_body(response).await;
|
|
863
1510
|
return Err(anyhow!("Refresh failed: {}", body));
|
|
@@ -896,64 +1543,130 @@ async fn existing_session_identity_for_base_url(
|
|
|
896
1543
|
.map(|email| email.to_string())
|
|
897
1544
|
}
|
|
898
1545
|
|
|
899
|
-
|
|
1546
|
+
fn default_login_scopes() -> Vec<String> {
|
|
1547
|
+
generated::contract::CLI_DEFAULT_LOGIN_SCOPES
|
|
1548
|
+
.iter()
|
|
1549
|
+
.map(|value| (*value).to_string())
|
|
1550
|
+
.collect()
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
fn default_login_scopes_with_tools() -> Vec<String> {
|
|
1554
|
+
generated::contract::CLI_DEFAULT_LOGIN_SCOPES_WITH_TOOLS
|
|
1555
|
+
.iter()
|
|
1556
|
+
.map(|value| (*value).to_string())
|
|
1557
|
+
.collect()
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
async fn login_command(
|
|
1561
|
+
client: &reqwest::Client,
|
|
1562
|
+
args: LoginArgs,
|
|
1563
|
+
output: OutputFormat,
|
|
1564
|
+
) -> Result<()> {
|
|
900
1565
|
let base_url = normalize_base_url(&args.base_url);
|
|
901
1566
|
if !args.force {
|
|
902
1567
|
if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
|
|
903
|
-
|
|
904
|
-
|
|
1568
|
+
let payload = serde_json::json!({
|
|
1569
|
+
"ok": true,
|
|
1570
|
+
"alreadyLoggedIn": true,
|
|
1571
|
+
"baseUrl": base_url,
|
|
1572
|
+
"email": email,
|
|
1573
|
+
"message": "Use `reallink logout` to sign out or `reallink login --force` to replace this session."
|
|
1574
|
+
});
|
|
1575
|
+
emit_text_or_json(
|
|
1576
|
+
output,
|
|
1577
|
+
&format!(
|
|
1578
|
+
"Already logged in. Use `reallink logout` to sign out or `reallink login --force` to replace this session."
|
|
1579
|
+
),
|
|
1580
|
+
payload,
|
|
1581
|
+
)?;
|
|
905
1582
|
return Ok(());
|
|
906
1583
|
}
|
|
907
1584
|
}
|
|
908
1585
|
|
|
909
|
-
let
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
"assets:write".to_string(),
|
|
915
|
-
"trace:read".to_string(),
|
|
916
|
-
"trace:write".to_string(),
|
|
917
|
-
"tools:read".to_string(),
|
|
918
|
-
"tools:write".to_string(),
|
|
919
|
-
"tools:run".to_string(),
|
|
920
|
-
"org:admin".to_string(),
|
|
921
|
-
"project:admin".to_string(),
|
|
922
|
-
]
|
|
1586
|
+
let (initial_scope, fallback_scope) = if args.scope.is_empty() {
|
|
1587
|
+
(
|
|
1588
|
+
default_login_scopes_with_tools(),
|
|
1589
|
+
Some(default_login_scopes()),
|
|
1590
|
+
)
|
|
923
1591
|
} else {
|
|
924
|
-
args.scope
|
|
1592
|
+
(args.scope, None)
|
|
925
1593
|
};
|
|
926
1594
|
|
|
927
|
-
let
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1595
|
+
let mut selected_scope = initial_scope;
|
|
1596
|
+
let mut device_code_response =
|
|
1597
|
+
with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
|
|
1598
|
+
.json(&DeviceCodeRequest {
|
|
1599
|
+
client_id: args.client_id.clone(),
|
|
1600
|
+
scope: selected_scope.clone(),
|
|
1601
|
+
})
|
|
1602
|
+
.send()
|
|
1603
|
+
.await?;
|
|
934
1604
|
|
|
935
1605
|
if !device_code_response.status().is_success() {
|
|
936
1606
|
let body = read_error_body(device_code_response).await;
|
|
937
|
-
|
|
1607
|
+
let can_fallback = fallback_scope.is_some()
|
|
1608
|
+
&& body.contains("VALIDATION_ERROR")
|
|
1609
|
+
&& body.contains("tools:");
|
|
1610
|
+
if can_fallback {
|
|
1611
|
+
if output == OutputFormat::Text {
|
|
1612
|
+
eprintln!(
|
|
1613
|
+
"Server rejected tool scopes for device-flow bootstrap; retrying with compatibility scopes."
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
selected_scope = fallback_scope.unwrap_or_default();
|
|
1617
|
+
device_code_response =
|
|
1618
|
+
with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
|
|
1619
|
+
.json(&DeviceCodeRequest {
|
|
1620
|
+
client_id: args.client_id.clone(),
|
|
1621
|
+
scope: selected_scope.clone(),
|
|
1622
|
+
})
|
|
1623
|
+
.send()
|
|
1624
|
+
.await?;
|
|
1625
|
+
if !device_code_response.status().is_success() {
|
|
1626
|
+
let retry_body = read_error_body(device_code_response).await;
|
|
1627
|
+
return Err(anyhow!("Failed to start device flow: {}", retry_body));
|
|
1628
|
+
}
|
|
1629
|
+
} else {
|
|
1630
|
+
return Err(anyhow!("Failed to start device flow: {}", body));
|
|
1631
|
+
}
|
|
938
1632
|
}
|
|
939
1633
|
|
|
940
1634
|
let device_code: DeviceCodeResponse = device_code_response.json().await?;
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1635
|
+
let browser_opened = webbrowser::open(&device_code.verification_uri_complete).is_ok();
|
|
1636
|
+
if output == OutputFormat::Text {
|
|
1637
|
+
println!("Open this URL in your browser and approve the login:");
|
|
1638
|
+
println!("{}", device_code.verification_uri_complete);
|
|
1639
|
+
println!("User code: {}", device_code.user_code);
|
|
1640
|
+
if browser_opened {
|
|
1641
|
+
println!("Browser opened for device approval.");
|
|
1642
|
+
} else {
|
|
1643
|
+
println!("Could not open browser automatically. Open the URL manually.");
|
|
1644
|
+
}
|
|
1645
|
+
} else {
|
|
1646
|
+
print_json(&serde_json::json!({
|
|
1647
|
+
"ok": true,
|
|
1648
|
+
"phase": "authorization_pending",
|
|
1649
|
+
"baseUrl": base_url,
|
|
1650
|
+
"verificationUri": device_code.verification_uri,
|
|
1651
|
+
"verificationUriComplete": device_code.verification_uri_complete,
|
|
1652
|
+
"userCode": device_code.user_code,
|
|
1653
|
+
"browserOpened": browser_opened,
|
|
1654
|
+
"expiresInSeconds": device_code.expires_in,
|
|
1655
|
+
"pollIntervalSeconds": device_code.interval,
|
|
1656
|
+
"scope": selected_scope
|
|
1657
|
+
}))?;
|
|
947
1658
|
}
|
|
948
1659
|
|
|
949
1660
|
let expires_at = std::time::Instant::now() + Duration::from_secs(device_code.expires_in);
|
|
950
1661
|
let mut poll_interval = Duration::from_secs(device_code.interval.max(1));
|
|
951
1662
|
let mut pending_polls = 0u32;
|
|
952
|
-
|
|
1663
|
+
if output == OutputFormat::Text {
|
|
1664
|
+
println!("Waiting for approval (press Ctrl+C to cancel)");
|
|
1665
|
+
}
|
|
953
1666
|
|
|
954
1667
|
loop {
|
|
955
1668
|
if std::time::Instant::now() >= expires_at {
|
|
956
|
-
if pending_polls > 0 {
|
|
1669
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
957
1670
|
println!();
|
|
958
1671
|
}
|
|
959
1672
|
return Err(anyhow!("Device code expired before approval"));
|
|
@@ -961,18 +1674,19 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
961
1674
|
|
|
962
1675
|
sleep(poll_interval).await;
|
|
963
1676
|
|
|
964
|
-
let token_response =
|
|
965
|
-
.
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1677
|
+
let token_response =
|
|
1678
|
+
with_cli_headers(client.post(format!("{}/auth/device/token", base_url)))
|
|
1679
|
+
.json(&DeviceTokenRequest {
|
|
1680
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
|
|
1681
|
+
device_code: device_code.device_code.clone(),
|
|
1682
|
+
client_id: args.client_id.clone(),
|
|
1683
|
+
})
|
|
1684
|
+
.send()
|
|
1685
|
+
.await?;
|
|
972
1686
|
|
|
973
1687
|
if token_response.status().is_success() {
|
|
974
1688
|
let tokens: DeviceTokenSuccess = token_response.json().await?;
|
|
975
|
-
if pending_polls > 0 {
|
|
1689
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
976
1690
|
println!();
|
|
977
1691
|
}
|
|
978
1692
|
let session = SessionConfig {
|
|
@@ -983,15 +1697,25 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
983
1697
|
updated_at_epoch_ms: now_epoch_ms(),
|
|
984
1698
|
};
|
|
985
1699
|
save_session(&session)?;
|
|
986
|
-
|
|
987
|
-
|
|
1700
|
+
let path = session_path_display();
|
|
1701
|
+
let payload = serde_json::json!({
|
|
1702
|
+
"ok": true,
|
|
1703
|
+
"phase": "authenticated",
|
|
1704
|
+
"baseUrl": base_url,
|
|
1705
|
+
"sessionPath": path,
|
|
1706
|
+
"sessionId": session.session_id
|
|
1707
|
+
});
|
|
1708
|
+
if output == OutputFormat::Text {
|
|
1709
|
+
println!("Login successful.");
|
|
1710
|
+
println!("Session stored at {}", session_path_display());
|
|
1711
|
+
} else {
|
|
1712
|
+
print_json(&payload)?;
|
|
1713
|
+
}
|
|
988
1714
|
return Ok(());
|
|
989
1715
|
}
|
|
990
1716
|
|
|
991
|
-
let error_payload: DeviceTokenError =
|
|
992
|
-
.json()
|
|
993
|
-
.await
|
|
994
|
-
.unwrap_or(DeviceTokenError {
|
|
1717
|
+
let error_payload: DeviceTokenError =
|
|
1718
|
+
token_response.json().await.unwrap_or(DeviceTokenError {
|
|
995
1719
|
error: "unknown_error".to_string(),
|
|
996
1720
|
error_description: Some("Could not parse auth error".to_string()),
|
|
997
1721
|
});
|
|
@@ -999,38 +1723,46 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
999
1723
|
match error_payload.error.as_str() {
|
|
1000
1724
|
"authorization_pending" => {
|
|
1001
1725
|
pending_polls = pending_polls.saturating_add(1);
|
|
1002
|
-
|
|
1003
|
-
|
|
1726
|
+
if output == OutputFormat::Text {
|
|
1727
|
+
print!(".");
|
|
1728
|
+
let _ = io::stdout().flush();
|
|
1729
|
+
}
|
|
1004
1730
|
continue;
|
|
1005
1731
|
}
|
|
1006
1732
|
"slow_down" => {
|
|
1007
1733
|
poll_interval += Duration::from_secs(1);
|
|
1008
1734
|
pending_polls = pending_polls.saturating_add(1);
|
|
1009
|
-
|
|
1010
|
-
|
|
1735
|
+
if output == OutputFormat::Text {
|
|
1736
|
+
print!("+");
|
|
1737
|
+
let _ = io::stdout().flush();
|
|
1738
|
+
}
|
|
1011
1739
|
continue;
|
|
1012
1740
|
}
|
|
1013
1741
|
_ => {
|
|
1014
|
-
if pending_polls > 0 {
|
|
1742
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
1015
1743
|
println!();
|
|
1016
1744
|
}
|
|
1017
1745
|
return Err(anyhow!(
|
|
1018
1746
|
"Device login failed: {} ({})",
|
|
1019
1747
|
error_payload.error,
|
|
1020
1748
|
error_payload.error_description.unwrap_or_default()
|
|
1021
|
-
))
|
|
1749
|
+
));
|
|
1022
1750
|
}
|
|
1023
1751
|
}
|
|
1024
1752
|
}
|
|
1025
1753
|
}
|
|
1026
1754
|
|
|
1027
|
-
async fn logout_command(client: &reqwest::Client) -> Result<()> {
|
|
1755
|
+
async fn logout_command(client: &reqwest::Client, output: OutputFormat) -> Result<()> {
|
|
1028
1756
|
let path_display = session_path_display();
|
|
1029
1757
|
let mut session = match load_session() {
|
|
1030
1758
|
Ok(session) => session,
|
|
1031
1759
|
Err(_) => {
|
|
1032
|
-
|
|
1033
|
-
|
|
1760
|
+
let payload = serde_json::json!({
|
|
1761
|
+
"ok": true,
|
|
1762
|
+
"alreadyLoggedOut": true,
|
|
1763
|
+
"sessionPath": path_display
|
|
1764
|
+
});
|
|
1765
|
+
emit_text_or_json(output, "You are already logged out.", payload)?;
|
|
1034
1766
|
return Ok(());
|
|
1035
1767
|
}
|
|
1036
1768
|
};
|
|
@@ -1046,26 +1778,54 @@ async fn logout_command(client: &reqwest::Client) -> Result<()> {
|
|
|
1046
1778
|
}
|
|
1047
1779
|
Ok(response) => {
|
|
1048
1780
|
let body = read_error_body(response).await;
|
|
1049
|
-
|
|
1781
|
+
if output == OutputFormat::Text {
|
|
1782
|
+
eprintln!("Warning: remote logout request failed: {}", body);
|
|
1783
|
+
}
|
|
1050
1784
|
}
|
|
1051
1785
|
Err(error) => {
|
|
1052
|
-
|
|
1786
|
+
if output == OutputFormat::Text {
|
|
1787
|
+
eprintln!("Warning: remote logout request failed: {}", error);
|
|
1788
|
+
}
|
|
1053
1789
|
}
|
|
1054
1790
|
}
|
|
1055
1791
|
|
|
1056
1792
|
let removed = clear_session()?;
|
|
1057
1793
|
if !removed {
|
|
1058
|
-
|
|
1794
|
+
let payload = serde_json::json!({
|
|
1795
|
+
"ok": true,
|
|
1796
|
+
"alreadyLoggedOut": true,
|
|
1797
|
+
"sessionPath": path_display
|
|
1798
|
+
});
|
|
1799
|
+
emit_text_or_json(output, "Local session was already cleared.", payload)?;
|
|
1059
1800
|
return Ok(());
|
|
1060
1801
|
}
|
|
1061
1802
|
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1803
|
+
let payload = serde_json::json!({
|
|
1804
|
+
"ok": true,
|
|
1805
|
+
"sessionPath": path_display,
|
|
1806
|
+
"remoteRevoked": remote_revoked,
|
|
1807
|
+
"remoteUnavailable": remote_unavailable
|
|
1808
|
+
});
|
|
1809
|
+
if output == OutputFormat::Text {
|
|
1810
|
+
if remote_revoked {
|
|
1811
|
+
println!(
|
|
1812
|
+
"Logged out. Server session revoked and local session removed from {}.",
|
|
1813
|
+
session_path_display()
|
|
1814
|
+
);
|
|
1815
|
+
} else if remote_unavailable {
|
|
1816
|
+
println!(
|
|
1817
|
+
"Logged out locally. Removed session from {}.",
|
|
1818
|
+
session_path_display()
|
|
1819
|
+
);
|
|
1820
|
+
println!("Server logout endpoint is not available on this API deployment yet.");
|
|
1821
|
+
} else {
|
|
1822
|
+
println!(
|
|
1823
|
+
"Logged out locally. Removed session from {}.",
|
|
1824
|
+
session_path_display()
|
|
1825
|
+
);
|
|
1826
|
+
}
|
|
1067
1827
|
} else {
|
|
1068
|
-
|
|
1828
|
+
print_json(&payload)?;
|
|
1069
1829
|
}
|
|
1070
1830
|
|
|
1071
1831
|
Ok(())
|
|
@@ -1119,7 +1879,14 @@ async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -
|
|
|
1119
1879
|
expires_in_days: args.expires_in_days,
|
|
1120
1880
|
})?;
|
|
1121
1881
|
|
|
1122
|
-
let response = authed_request(
|
|
1882
|
+
let response = authed_request(
|
|
1883
|
+
client,
|
|
1884
|
+
&mut session,
|
|
1885
|
+
Method::POST,
|
|
1886
|
+
"/auth/tokens",
|
|
1887
|
+
Some(body),
|
|
1888
|
+
)
|
|
1889
|
+
.await?;
|
|
1123
1890
|
if !response.status().is_success() {
|
|
1124
1891
|
let body_text = read_error_body(response).await;
|
|
1125
1892
|
return Err(anyhow!("token create failed: {}", body_text));
|
|
@@ -1146,123 +1913,1609 @@ async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -
|
|
|
1146
1913
|
Ok(())
|
|
1147
1914
|
}
|
|
1148
1915
|
|
|
1149
|
-
async fn
|
|
1916
|
+
async fn org_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
|
|
1917
|
+
let mut session = load_session()?;
|
|
1918
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1919
|
+
|
|
1920
|
+
let response = authed_request(client, &mut session, Method::GET, "/core/orgs", None).await?;
|
|
1921
|
+
if !response.status().is_success() {
|
|
1922
|
+
let body = read_error_body(response).await;
|
|
1923
|
+
return Err(anyhow!("org list failed: {}", body));
|
|
1924
|
+
}
|
|
1925
|
+
let payload: ListOrgsResponse = response.json().await?;
|
|
1926
|
+
println!("{}", serde_json::to_string_pretty(&payload.orgs)?);
|
|
1927
|
+
save_session(&session)?;
|
|
1928
|
+
Ok(())
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
async fn org_create_command(client: &reqwest::Client, args: OrgCreateArgs) -> Result<()> {
|
|
1932
|
+
let mut session = load_session()?;
|
|
1933
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1934
|
+
|
|
1935
|
+
let response = authed_request(
|
|
1936
|
+
client,
|
|
1937
|
+
&mut session,
|
|
1938
|
+
Method::POST,
|
|
1939
|
+
"/core/orgs",
|
|
1940
|
+
Some(serde_json::json!({
|
|
1941
|
+
"name": args.name
|
|
1942
|
+
})),
|
|
1943
|
+
)
|
|
1944
|
+
.await?;
|
|
1945
|
+
if !response.status().is_success() {
|
|
1946
|
+
let body = read_error_body(response).await;
|
|
1947
|
+
return Err(anyhow!("org create failed: {}", body));
|
|
1948
|
+
}
|
|
1949
|
+
let payload: OrgResponse = response.json().await?;
|
|
1950
|
+
println!("{}", serde_json::to_string_pretty(&payload.org)?);
|
|
1951
|
+
save_session(&session)?;
|
|
1952
|
+
Ok(())
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
async fn org_get_command(client: &reqwest::Client, args: OrgGetArgs) -> Result<()> {
|
|
1956
|
+
let mut session = load_session()?;
|
|
1957
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1958
|
+
|
|
1959
|
+
let path = format!("/core/orgs/{}", args.org_id);
|
|
1960
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1961
|
+
if !response.status().is_success() {
|
|
1962
|
+
let body = read_error_body(response).await;
|
|
1963
|
+
return Err(anyhow!("org get failed: {}", body));
|
|
1964
|
+
}
|
|
1965
|
+
let payload: OrgResponse = response.json().await?;
|
|
1966
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1967
|
+
save_session(&session)?;
|
|
1968
|
+
Ok(())
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
async fn org_update_command(client: &reqwest::Client, args: OrgUpdateArgs) -> Result<()> {
|
|
1972
|
+
let mut session = load_session()?;
|
|
1973
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1974
|
+
|
|
1975
|
+
let path = format!("/core/orgs/{}", args.org_id);
|
|
1976
|
+
let response = authed_request(
|
|
1977
|
+
client,
|
|
1978
|
+
&mut session,
|
|
1979
|
+
Method::PATCH,
|
|
1980
|
+
&path,
|
|
1981
|
+
Some(serde_json::json!({
|
|
1982
|
+
"name": args.name
|
|
1983
|
+
})),
|
|
1984
|
+
)
|
|
1985
|
+
.await?;
|
|
1986
|
+
if !response.status().is_success() {
|
|
1987
|
+
let body = read_error_body(response).await;
|
|
1988
|
+
return Err(anyhow!("org update failed: {}", body));
|
|
1989
|
+
}
|
|
1990
|
+
let payload: OrgResponse = response.json().await?;
|
|
1991
|
+
println!("{}", serde_json::to_string_pretty(&payload.org)?);
|
|
1992
|
+
save_session(&session)?;
|
|
1993
|
+
Ok(())
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
async fn org_delete_command(client: &reqwest::Client, args: OrgDeleteArgs) -> Result<()> {
|
|
1150
1997
|
let mut session = load_session()?;
|
|
1151
1998
|
apply_base_url_override(&mut session, args.base_url);
|
|
1152
1999
|
|
|
1153
|
-
let path =
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
2000
|
+
let path = format!("/core/orgs/{}", args.org_id);
|
|
2001
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
2002
|
+
if !response.status().is_success() {
|
|
2003
|
+
let body = read_error_body(response).await;
|
|
2004
|
+
return Err(anyhow!("org delete failed: {}", body));
|
|
2005
|
+
}
|
|
2006
|
+
let payload: serde_json::Value = response.json().await?;
|
|
2007
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
2008
|
+
save_session(&session)?;
|
|
2009
|
+
Ok(())
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
async fn org_invites_command(client: &reqwest::Client, args: OrgInvitesArgs) -> Result<()> {
|
|
2013
|
+
let mut session = load_session()?;
|
|
2014
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2015
|
+
|
|
2016
|
+
let path = format!("/core/orgs/{}/invites", args.org_id);
|
|
2017
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2018
|
+
if !response.status().is_success() {
|
|
2019
|
+
let body = read_error_body(response).await;
|
|
2020
|
+
return Err(anyhow!("org invites failed: {}", body));
|
|
2021
|
+
}
|
|
2022
|
+
let payload: ListOrgInvitesResponse = response.json().await?;
|
|
2023
|
+
println!("{}", serde_json::to_string_pretty(&payload.invites)?);
|
|
2024
|
+
save_session(&session)?;
|
|
2025
|
+
Ok(())
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
async fn org_invite_command(client: &reqwest::Client, args: OrgInviteArgs) -> Result<()> {
|
|
2029
|
+
let mut session = load_session()?;
|
|
2030
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2031
|
+
|
|
2032
|
+
let role = args.role.trim().to_lowercase();
|
|
2033
|
+
if role != "member" && role != "admin" {
|
|
2034
|
+
return Err(anyhow!("org invite role must be either 'member' or 'admin'"));
|
|
2035
|
+
}
|
|
2036
|
+
if args.expires_in_days == 0 || args.expires_in_days > 30 {
|
|
2037
|
+
return Err(anyhow!("org invite expires_in_days must be between 1 and 30"));
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
let path = format!("/core/orgs/{}/invites", args.org_id);
|
|
2041
|
+
let response = authed_request(
|
|
2042
|
+
client,
|
|
2043
|
+
&mut session,
|
|
2044
|
+
Method::POST,
|
|
2045
|
+
&path,
|
|
2046
|
+
Some(serde_json::json!({
|
|
2047
|
+
"email": args.email.trim(),
|
|
2048
|
+
"role": role,
|
|
2049
|
+
"expiresInDays": args.expires_in_days
|
|
2050
|
+
})),
|
|
2051
|
+
)
|
|
2052
|
+
.await?;
|
|
2053
|
+
if !response.status().is_success() {
|
|
2054
|
+
let body = read_error_body(response).await;
|
|
2055
|
+
return Err(anyhow!("org invite failed: {}", body));
|
|
2056
|
+
}
|
|
2057
|
+
let payload: OrgInviteResponse = response.json().await?;
|
|
2058
|
+
println!("{}", serde_json::to_string_pretty(&payload.invite)?);
|
|
2059
|
+
save_session(&session)?;
|
|
2060
|
+
Ok(())
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
async fn org_members_command(client: &reqwest::Client, args: OrgMembersArgs) -> Result<()> {
|
|
2064
|
+
let mut session = load_session()?;
|
|
2065
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2066
|
+
|
|
2067
|
+
let path = format!("/core/orgs/{}/members", args.org_id);
|
|
2068
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2069
|
+
if !response.status().is_success() {
|
|
2070
|
+
let body = read_error_body(response).await;
|
|
2071
|
+
return Err(anyhow!("org members failed: {}", body));
|
|
2072
|
+
}
|
|
2073
|
+
let payload: ListOrgMembersResponse = response.json().await?;
|
|
2074
|
+
println!("{}", serde_json::to_string_pretty(&payload.members)?);
|
|
2075
|
+
save_session(&session)?;
|
|
2076
|
+
Ok(())
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
async fn org_add_member_command(client: &reqwest::Client, args: OrgAddMemberArgs) -> Result<()> {
|
|
2080
|
+
let mut session = load_session()?;
|
|
2081
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2082
|
+
|
|
2083
|
+
let path = format!("/core/orgs/{}/members", args.org_id);
|
|
2084
|
+
let response = authed_request(
|
|
2085
|
+
client,
|
|
2086
|
+
&mut session,
|
|
2087
|
+
Method::POST,
|
|
2088
|
+
&path,
|
|
2089
|
+
Some(serde_json::json!({
|
|
2090
|
+
"email": args.email,
|
|
2091
|
+
"role": args.role
|
|
2092
|
+
})),
|
|
2093
|
+
)
|
|
2094
|
+
.await?;
|
|
2095
|
+
if !response.status().is_success() {
|
|
2096
|
+
let body = read_error_body(response).await;
|
|
2097
|
+
return Err(anyhow!("org add member failed: {}", body));
|
|
2098
|
+
}
|
|
2099
|
+
let payload: OrgMemberResponse = response.json().await?;
|
|
2100
|
+
println!("{}", serde_json::to_string_pretty(&payload.member)?);
|
|
2101
|
+
save_session(&session)?;
|
|
2102
|
+
Ok(())
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
async fn org_update_member_command(client: &reqwest::Client, args: OrgUpdateMemberArgs) -> Result<()> {
|
|
2106
|
+
let mut session = load_session()?;
|
|
2107
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2108
|
+
|
|
2109
|
+
let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
|
|
2110
|
+
let response = authed_request(
|
|
2111
|
+
client,
|
|
2112
|
+
&mut session,
|
|
2113
|
+
Method::PATCH,
|
|
2114
|
+
&path,
|
|
2115
|
+
Some(serde_json::json!({
|
|
2116
|
+
"role": args.role
|
|
2117
|
+
})),
|
|
2118
|
+
)
|
|
2119
|
+
.await?;
|
|
2120
|
+
if !response.status().is_success() {
|
|
2121
|
+
let body = read_error_body(response).await;
|
|
2122
|
+
return Err(anyhow!("org update member failed: {}", body));
|
|
2123
|
+
}
|
|
2124
|
+
let payload: OrgMemberResponse = response.json().await?;
|
|
2125
|
+
println!("{}", serde_json::to_string_pretty(&payload.member)?);
|
|
2126
|
+
save_session(&session)?;
|
|
2127
|
+
Ok(())
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
async fn org_remove_member_command(client: &reqwest::Client, args: OrgRemoveMemberArgs) -> Result<()> {
|
|
2131
|
+
let mut session = load_session()?;
|
|
2132
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2133
|
+
|
|
2134
|
+
let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
|
|
2135
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
2136
|
+
if !response.status().is_success() {
|
|
2137
|
+
let body = read_error_body(response).await;
|
|
2138
|
+
return Err(anyhow!("org remove member failed: {}", body));
|
|
2139
|
+
}
|
|
2140
|
+
let payload: serde_json::Value = response.json().await?;
|
|
2141
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
2142
|
+
save_session(&session)?;
|
|
2143
|
+
Ok(())
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -> Result<()> {
|
|
2147
|
+
let mut session = load_session()?;
|
|
2148
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2149
|
+
|
|
2150
|
+
let path = match args.org_id {
|
|
2151
|
+
Some(org_id) if !org_id.trim().is_empty() => {
|
|
2152
|
+
format!("/core/projects?orgId={}", org_id.trim())
|
|
2153
|
+
}
|
|
2154
|
+
_ => "/core/projects".to_string(),
|
|
2155
|
+
};
|
|
2156
|
+
|
|
2157
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2158
|
+
if !response.status().is_success() {
|
|
2159
|
+
let body = read_error_body(response).await;
|
|
2160
|
+
return Err(anyhow!("project list failed: {}", body));
|
|
2161
|
+
}
|
|
2162
|
+
let payload: ListProjectsResponse = response.json().await?;
|
|
2163
|
+
println!("{}", serde_json::to_string_pretty(&payload.projects)?);
|
|
2164
|
+
save_session(&session)?;
|
|
2165
|
+
Ok(())
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArgs) -> Result<()> {
|
|
2169
|
+
let mut session = load_session()?;
|
|
2170
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2171
|
+
|
|
2172
|
+
let response = authed_request(
|
|
2173
|
+
client,
|
|
2174
|
+
&mut session,
|
|
2175
|
+
Method::POST,
|
|
2176
|
+
"/core/projects",
|
|
2177
|
+
Some(serde_json::json!({
|
|
2178
|
+
"orgId": args.org_id,
|
|
2179
|
+
"name": args.name,
|
|
2180
|
+
"description": args.description
|
|
2181
|
+
})),
|
|
2182
|
+
)
|
|
2183
|
+
.await?;
|
|
2184
|
+
if !response.status().is_success() {
|
|
2185
|
+
let body = read_error_body(response).await;
|
|
2186
|
+
return Err(anyhow!("project create failed: {}", body));
|
|
2187
|
+
}
|
|
2188
|
+
let payload: ProjectResponse = response.json().await?;
|
|
2189
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
2190
|
+
save_session(&session)?;
|
|
2191
|
+
Ok(())
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) -> Result<()> {
|
|
2195
|
+
let mut session = load_session()?;
|
|
2196
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2197
|
+
|
|
2198
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
2199
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2200
|
+
if !response.status().is_success() {
|
|
2201
|
+
let body = read_error_body(response).await;
|
|
2202
|
+
return Err(anyhow!("project get failed: {}", body));
|
|
2203
|
+
}
|
|
2204
|
+
let payload: ProjectResponse = response.json().await?;
|
|
2205
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
2206
|
+
save_session(&session)?;
|
|
2207
|
+
Ok(())
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArgs) -> Result<()> {
|
|
2211
|
+
let mut session = load_session()?;
|
|
2212
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2213
|
+
|
|
2214
|
+
let mut body = serde_json::Map::new();
|
|
2215
|
+
if let Some(name) = args.name {
|
|
2216
|
+
body.insert("name".to_string(), serde_json::Value::String(name));
|
|
2217
|
+
}
|
|
2218
|
+
if args.clear_description {
|
|
2219
|
+
body.insert("description".to_string(), serde_json::Value::Null);
|
|
2220
|
+
} else if let Some(description) = args.description {
|
|
2221
|
+
body.insert(
|
|
2222
|
+
"description".to_string(),
|
|
2223
|
+
serde_json::Value::String(description),
|
|
2224
|
+
);
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
if body.is_empty() {
|
|
2228
|
+
return Err(anyhow!(
|
|
2229
|
+
"project update requires at least one field (--name, --description, or --clear-description)"
|
|
2230
|
+
));
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
2234
|
+
let response = authed_request(
|
|
2235
|
+
client,
|
|
2236
|
+
&mut session,
|
|
2237
|
+
Method::PATCH,
|
|
2238
|
+
&path,
|
|
2239
|
+
Some(serde_json::Value::Object(body)),
|
|
2240
|
+
)
|
|
2241
|
+
.await?;
|
|
2242
|
+
if !response.status().is_success() {
|
|
2243
|
+
let body = read_error_body(response).await;
|
|
2244
|
+
return Err(anyhow!("project update failed: {}", body));
|
|
2245
|
+
}
|
|
2246
|
+
let payload: ProjectResponse = response.json().await?;
|
|
2247
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
2248
|
+
save_session(&session)?;
|
|
2249
|
+
Ok(())
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArgs) -> Result<()> {
|
|
2253
|
+
let mut session = load_session()?;
|
|
2254
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2255
|
+
|
|
2256
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
2257
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
2258
|
+
if !response.status().is_success() {
|
|
2259
|
+
let body = read_error_body(response).await;
|
|
2260
|
+
return Err(anyhow!("project delete failed: {}", body));
|
|
2261
|
+
}
|
|
2262
|
+
let payload: serde_json::Value = response.json().await?;
|
|
2263
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
2264
|
+
save_session(&session)?;
|
|
2265
|
+
Ok(())
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
async fn project_members_command(client: &reqwest::Client, args: ProjectMembersArgs) -> Result<()> {
|
|
2269
|
+
let mut session = load_session()?;
|
|
2270
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2271
|
+
|
|
2272
|
+
let path = format!("/core/projects/{}/members", args.project_id);
|
|
2273
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2274
|
+
if !response.status().is_success() {
|
|
2275
|
+
let body = read_error_body(response).await;
|
|
2276
|
+
return Err(anyhow!("project members failed: {}", body));
|
|
2277
|
+
}
|
|
2278
|
+
let payload: ListProjectMembersResponse = response.json().await?;
|
|
2279
|
+
println!("{}", serde_json::to_string_pretty(&payload.members)?);
|
|
2280
|
+
save_session(&session)?;
|
|
2281
|
+
Ok(())
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
async fn project_add_member_command(client: &reqwest::Client, args: ProjectAddMemberArgs) -> Result<()> {
|
|
2285
|
+
let mut session = load_session()?;
|
|
2286
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2287
|
+
|
|
2288
|
+
let path = format!("/core/projects/{}/members", args.project_id);
|
|
2289
|
+
let response = authed_request(
|
|
2290
|
+
client,
|
|
2291
|
+
&mut session,
|
|
2292
|
+
Method::POST,
|
|
2293
|
+
&path,
|
|
2294
|
+
Some(serde_json::json!({
|
|
2295
|
+
"email": args.email,
|
|
2296
|
+
"role": args.role
|
|
2297
|
+
})),
|
|
2298
|
+
)
|
|
2299
|
+
.await?;
|
|
2300
|
+
if !response.status().is_success() {
|
|
2301
|
+
let body = read_error_body(response).await;
|
|
2302
|
+
return Err(anyhow!("project add member failed: {}", body));
|
|
2303
|
+
}
|
|
2304
|
+
let payload: ProjectMemberResponse = response.json().await?;
|
|
2305
|
+
println!("{}", serde_json::to_string_pretty(&payload.member)?);
|
|
2306
|
+
save_session(&session)?;
|
|
2307
|
+
Ok(())
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
async fn project_update_member_command(
|
|
2311
|
+
client: &reqwest::Client,
|
|
2312
|
+
args: ProjectUpdateMemberArgs,
|
|
2313
|
+
) -> Result<()> {
|
|
2314
|
+
let mut session = load_session()?;
|
|
2315
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2316
|
+
|
|
2317
|
+
let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
|
|
2318
|
+
let response = authed_request(
|
|
2319
|
+
client,
|
|
2320
|
+
&mut session,
|
|
2321
|
+
Method::PATCH,
|
|
2322
|
+
&path,
|
|
2323
|
+
Some(serde_json::json!({
|
|
2324
|
+
"role": args.role
|
|
2325
|
+
})),
|
|
2326
|
+
)
|
|
2327
|
+
.await?;
|
|
2328
|
+
if !response.status().is_success() {
|
|
2329
|
+
let body = read_error_body(response).await;
|
|
2330
|
+
return Err(anyhow!("project update member failed: {}", body));
|
|
2331
|
+
}
|
|
2332
|
+
let payload: ProjectMemberResponse = response.json().await?;
|
|
2333
|
+
println!("{}", serde_json::to_string_pretty(&payload.member)?);
|
|
2334
|
+
save_session(&session)?;
|
|
2335
|
+
Ok(())
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
async fn project_remove_member_command(
|
|
2339
|
+
client: &reqwest::Client,
|
|
2340
|
+
args: ProjectRemoveMemberArgs,
|
|
2341
|
+
) -> Result<()> {
|
|
2342
|
+
let mut session = load_session()?;
|
|
2343
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2344
|
+
|
|
2345
|
+
let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
|
|
2346
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
2347
|
+
if !response.status().is_success() {
|
|
2348
|
+
let body = read_error_body(response).await;
|
|
2349
|
+
return Err(anyhow!("project remove member failed: {}", body));
|
|
2350
|
+
}
|
|
2351
|
+
let payload: serde_json::Value = response.json().await?;
|
|
2352
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
2353
|
+
save_session(&session)?;
|
|
2354
|
+
Ok(())
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
async fn verify_project_access(
|
|
2358
|
+
client: &reqwest::Client,
|
|
2359
|
+
session: &mut SessionConfig,
|
|
2360
|
+
project_id: &str,
|
|
2361
|
+
) -> Result<()> {
|
|
2362
|
+
let path = format!("/core/projects/{}", project_id);
|
|
2363
|
+
let response = authed_request(client, session, Method::GET, &path, None).await?;
|
|
2364
|
+
if response.status().is_success() {
|
|
2365
|
+
return Ok(());
|
|
2366
|
+
}
|
|
2367
|
+
let body = read_error_body(response).await;
|
|
2368
|
+
Err(anyhow!(
|
|
2369
|
+
"project verification failed for {}: {}",
|
|
2370
|
+
project_id,
|
|
2371
|
+
body
|
|
2372
|
+
))
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
fn file_name_component(path: &str) -> String {
|
|
2376
|
+
Path::new(path)
|
|
2377
|
+
.file_name()
|
|
2378
|
+
.and_then(|value| value.to_str())
|
|
2379
|
+
.unwrap_or(path)
|
|
2380
|
+
.to_string()
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
fn build_unreal_link_manifest_payload(
|
|
2384
|
+
link: &UnrealLinkRecord,
|
|
2385
|
+
include_local_paths: bool,
|
|
2386
|
+
) -> serde_json::Value {
|
|
2387
|
+
let mut payload = serde_json::json!({
|
|
2388
|
+
"schemaVersion": 1,
|
|
2389
|
+
"provider": "unreal",
|
|
2390
|
+
"projectId": link.project_id,
|
|
2391
|
+
"uprojectFile": file_name_component(&link.uproject_path),
|
|
2392
|
+
"projectRootName": file_name_component(&link.project_root),
|
|
2393
|
+
"editorBinary": file_name_component(&link.editor_path),
|
|
2394
|
+
"engineRootName": link.engine_root.as_ref().map(|value| file_name_component(value)),
|
|
2395
|
+
"updatedAtEpochMs": link.updated_at_epoch_ms
|
|
2396
|
+
});
|
|
2397
|
+
|
|
2398
|
+
if include_local_paths {
|
|
2399
|
+
payload["localPaths"] = serde_json::json!({
|
|
2400
|
+
"uprojectPath": link.uproject_path,
|
|
2401
|
+
"projectRoot": link.project_root,
|
|
2402
|
+
"editorPath": link.editor_path,
|
|
2403
|
+
"engineRoot": link.engine_root
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2406
|
+
payload
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
async fn sync_unreal_link_manifest_asset(
|
|
2410
|
+
client: &reqwest::Client,
|
|
2411
|
+
session: &mut SessionConfig,
|
|
2412
|
+
link: &UnrealLinkRecord,
|
|
2413
|
+
include_local_paths: bool,
|
|
2414
|
+
) -> Result<AssetRecord> {
|
|
2415
|
+
let payload = build_unreal_link_manifest_payload(link, include_local_paths);
|
|
2416
|
+
let bytes = serde_json::to_vec_pretty(&payload)?;
|
|
2417
|
+
upload_asset_via_intent(
|
|
2418
|
+
client,
|
|
2419
|
+
session,
|
|
2420
|
+
&link.project_id,
|
|
2421
|
+
".reallink/link/unreal-link.latest.json",
|
|
2422
|
+
bytes,
|
|
2423
|
+
"application/json",
|
|
2424
|
+
"other",
|
|
2425
|
+
"private",
|
|
2426
|
+
)
|
|
2427
|
+
.await
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
pub(crate) async fn link_unreal_command(client: &reqwest::Client, args: LinkUnrealArgs) -> Result<()> {
|
|
2431
|
+
let uproject_path = resolve_uproject_path(&args.uproject)?;
|
|
2432
|
+
let project_root = uproject_path
|
|
2433
|
+
.parent()
|
|
2434
|
+
.ok_or_else(|| anyhow!("Invalid uproject path {}", uproject_path.display()))?
|
|
2435
|
+
.to_path_buf();
|
|
2436
|
+
let (editor_path, engine_root_path) = resolve_editor_and_engine(args.editor, args.engine_root)?;
|
|
2437
|
+
|
|
2438
|
+
let now = now_epoch_ms();
|
|
2439
|
+
let link_record = UnrealLinkRecord {
|
|
2440
|
+
project_id: args.project_id.clone(),
|
|
2441
|
+
uproject_path: uproject_path.display().to_string(),
|
|
2442
|
+
project_root: project_root.display().to_string(),
|
|
2443
|
+
engine_root: engine_root_path
|
|
2444
|
+
.as_ref()
|
|
2445
|
+
.map(|value| value.display().to_string()),
|
|
2446
|
+
editor_path: editor_path.display().to_string(),
|
|
2447
|
+
created_at_epoch_ms: now,
|
|
2448
|
+
updated_at_epoch_ms: now,
|
|
2449
|
+
};
|
|
2450
|
+
|
|
2451
|
+
let mut synced_asset: Option<AssetRecord> = None;
|
|
2452
|
+
if !args.no_verify_remote || args.sync_project {
|
|
2453
|
+
let mut session = load_session()?;
|
|
2454
|
+
apply_base_url_override(&mut session, args.base_url.clone());
|
|
2455
|
+
if !args.no_verify_remote {
|
|
2456
|
+
verify_project_access(client, &mut session, &args.project_id).await?;
|
|
2457
|
+
}
|
|
2458
|
+
if args.sync_project {
|
|
2459
|
+
let synced = sync_unreal_link_manifest_asset(
|
|
2460
|
+
client,
|
|
2461
|
+
&mut session,
|
|
2462
|
+
&link_record,
|
|
2463
|
+
args.include_local_paths,
|
|
2464
|
+
)
|
|
2465
|
+
.await?;
|
|
2466
|
+
synced_asset = Some(synced);
|
|
2467
|
+
}
|
|
2468
|
+
save_session(&session)?;
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
let uproject_cmp = normalize_path_for_compare(&uproject_path);
|
|
2472
|
+
let mut config = load_unreal_links()?;
|
|
2473
|
+
config.links.retain(|entry| {
|
|
2474
|
+
entry.project_id != args.project_id
|
|
2475
|
+
&& normalize_path_string_for_compare(&entry.uproject_path) != uproject_cmp
|
|
2476
|
+
});
|
|
2477
|
+
config.links.push(link_record.clone());
|
|
2478
|
+
|
|
2479
|
+
if args.set_default || config.default_project_id.is_none() {
|
|
2480
|
+
config.default_project_id = Some(args.project_id.clone());
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
save_unreal_links(&config)?;
|
|
2484
|
+
println!(
|
|
2485
|
+
"{}",
|
|
2486
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
2487
|
+
"ok": true,
|
|
2488
|
+
"defaultProjectId": config.default_project_id,
|
|
2489
|
+
"link": config.links.iter().find(|entry| entry.project_id == args.project_id),
|
|
2490
|
+
"syncedAssetId": synced_asset.as_ref().map(|entry| entry.id.clone()),
|
|
2491
|
+
"syncedManifestPath": if args.sync_project { Some(".reallink/link/unreal-link.latest.json") } else { None }
|
|
2492
|
+
}))?
|
|
2493
|
+
);
|
|
2494
|
+
Ok(())
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
pub(crate) async fn link_list_command() -> Result<()> {
|
|
2498
|
+
let config = load_unreal_links()?;
|
|
2499
|
+
println!("{}", serde_json::to_string_pretty(&config)?);
|
|
2500
|
+
Ok(())
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
pub(crate) async fn link_use_command(args: LinkUseArgs) -> Result<()> {
|
|
2504
|
+
let mut config = load_unreal_links()?;
|
|
2505
|
+
if !config
|
|
2506
|
+
.links
|
|
2507
|
+
.iter()
|
|
2508
|
+
.any(|entry| entry.project_id == args.project_id)
|
|
2509
|
+
{
|
|
2510
|
+
return Err(anyhow!(
|
|
2511
|
+
"No link found for project {}. Run `reallink link unreal ...` first.",
|
|
2512
|
+
args.project_id
|
|
2513
|
+
));
|
|
2514
|
+
}
|
|
2515
|
+
config.default_project_id = Some(args.project_id.clone());
|
|
2516
|
+
save_unreal_links(&config)?;
|
|
2517
|
+
println!(
|
|
2518
|
+
"{}",
|
|
2519
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
2520
|
+
"ok": true,
|
|
2521
|
+
"defaultProjectId": args.project_id
|
|
2522
|
+
}))?
|
|
2523
|
+
);
|
|
2524
|
+
Ok(())
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
fn normalize_match_path(path: &Path) -> String {
|
|
2528
|
+
if let Ok(canonical) = canonicalize_existing_path(path, "uproject") {
|
|
2529
|
+
return normalize_path_for_compare(&canonical);
|
|
2530
|
+
}
|
|
2531
|
+
let absolute = if path.is_absolute() {
|
|
2532
|
+
path.to_path_buf()
|
|
2533
|
+
} else {
|
|
2534
|
+
match std::env::current_dir() {
|
|
2535
|
+
Ok(dir) => dir.join(path),
|
|
2536
|
+
Err(_) => path.to_path_buf(),
|
|
2537
|
+
}
|
|
2538
|
+
};
|
|
2539
|
+
normalize_path_for_compare(&absolute)
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
pub(crate) async fn link_remove_command(args: LinkRemoveArgs) -> Result<()> {
|
|
2543
|
+
if args.project_id.is_none() && args.uproject.is_none() {
|
|
2544
|
+
return Err(anyhow!(
|
|
2545
|
+
"Provide --project-id or --uproject to remove a link."
|
|
2546
|
+
));
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
let mut config = load_unreal_links()?;
|
|
2550
|
+
let before = config.links.len();
|
|
2551
|
+
let target_uproject_cmp = args
|
|
2552
|
+
.uproject
|
|
2553
|
+
.as_ref()
|
|
2554
|
+
.map(|value| normalize_match_path(value));
|
|
2555
|
+
config.links.retain(|entry| {
|
|
2556
|
+
if let Some(project_id) = args.project_id.as_ref() {
|
|
2557
|
+
if &entry.project_id == project_id {
|
|
2558
|
+
return false;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
if let Some(target_cmp) = target_uproject_cmp.as_ref() {
|
|
2562
|
+
if normalize_path_string_for_compare(&entry.uproject_path) == *target_cmp {
|
|
2563
|
+
return false;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
true
|
|
2567
|
+
});
|
|
2568
|
+
let removed = before.saturating_sub(config.links.len());
|
|
2569
|
+
if removed == 0 {
|
|
2570
|
+
return Err(anyhow!("No matching link found."));
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
if let Some(default_id) = config.default_project_id.clone() {
|
|
2574
|
+
let has_default = config
|
|
2575
|
+
.links
|
|
2576
|
+
.iter()
|
|
2577
|
+
.any(|entry| entry.project_id == default_id);
|
|
2578
|
+
if !has_default {
|
|
2579
|
+
config.default_project_id = config.links.first().map(|entry| entry.project_id.clone());
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
save_unreal_links(&config)?;
|
|
2584
|
+
println!(
|
|
2585
|
+
"{}",
|
|
2586
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
2587
|
+
"ok": true,
|
|
2588
|
+
"removed": removed,
|
|
2589
|
+
"defaultProjectId": config.default_project_id
|
|
2590
|
+
}))?
|
|
2591
|
+
);
|
|
2592
|
+
Ok(())
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
fn resolve_link_target(
|
|
2596
|
+
config: &UnrealLinksConfig,
|
|
2597
|
+
project_id: Option<&str>,
|
|
2598
|
+
uproject: Option<&Path>,
|
|
2599
|
+
) -> Result<UnrealLinkRecord> {
|
|
2600
|
+
if let Some(project_id) = project_id {
|
|
2601
|
+
let entry = config
|
|
2602
|
+
.links
|
|
2603
|
+
.iter()
|
|
2604
|
+
.find(|item| item.project_id == project_id)
|
|
2605
|
+
.ok_or_else(|| anyhow!("No link found for project {}", project_id))?;
|
|
2606
|
+
return Ok(entry.clone());
|
|
2607
|
+
}
|
|
2608
|
+
if let Some(uproject) = uproject {
|
|
2609
|
+
let target_cmp = normalize_match_path(uproject);
|
|
2610
|
+
let entry = config
|
|
2611
|
+
.links
|
|
2612
|
+
.iter()
|
|
2613
|
+
.find(|item| normalize_path_string_for_compare(&item.uproject_path) == target_cmp)
|
|
2614
|
+
.ok_or_else(|| anyhow!("No link found for uproject {}", uproject.display()))?;
|
|
2615
|
+
return Ok(entry.clone());
|
|
2616
|
+
}
|
|
2617
|
+
if let Some(default_project_id) = config.default_project_id.as_ref() {
|
|
2618
|
+
if let Some(entry) = config
|
|
2619
|
+
.links
|
|
2620
|
+
.iter()
|
|
2621
|
+
.find(|item| &item.project_id == default_project_id)
|
|
2622
|
+
{
|
|
2623
|
+
return Ok(entry.clone());
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
if config.links.len() == 1 {
|
|
2627
|
+
return Ok(config.links[0].clone());
|
|
2628
|
+
}
|
|
2629
|
+
Err(anyhow!(
|
|
2630
|
+
"No target specified. Use --project-id or set a default link with `reallink link use --project-id ...`."
|
|
2631
|
+
))
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
fn unreal_manifest_local_path(link: &UnrealLinkRecord) -> PathBuf {
|
|
2635
|
+
PathBuf::from(&link.project_root)
|
|
2636
|
+
.join(".reallink")
|
|
2637
|
+
.join("link")
|
|
2638
|
+
.join("unreal-link.latest.json")
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
fn push_doctor_check(
|
|
2642
|
+
checks: &mut Vec<serde_json::Value>,
|
|
2643
|
+
errors: &mut Vec<String>,
|
|
2644
|
+
warnings: &mut Vec<String>,
|
|
2645
|
+
key: &str,
|
|
2646
|
+
ok: bool,
|
|
2647
|
+
detail: impl Into<String>,
|
|
2648
|
+
warning_only: bool,
|
|
2649
|
+
) {
|
|
2650
|
+
let detail_text = detail.into();
|
|
2651
|
+
let status = if ok {
|
|
2652
|
+
"ok"
|
|
2653
|
+
} else if warning_only {
|
|
2654
|
+
"warn"
|
|
2655
|
+
} else {
|
|
2656
|
+
"error"
|
|
2657
|
+
};
|
|
2658
|
+
if !ok {
|
|
2659
|
+
if warning_only {
|
|
2660
|
+
warnings.push(format!("{}: {}", key, detail_text));
|
|
2661
|
+
} else {
|
|
2662
|
+
errors.push(format!("{}: {}", key, detail_text));
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
checks.push(serde_json::json!({
|
|
2666
|
+
"key": key,
|
|
2667
|
+
"status": status,
|
|
2668
|
+
"detail": detail_text
|
|
2669
|
+
}));
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
pub(crate) async fn link_paths_command(args: LinkPathsArgs) -> Result<()> {
|
|
2673
|
+
let config = load_unreal_links()?;
|
|
2674
|
+
if config.links.is_empty() {
|
|
2675
|
+
return Err(anyhow!(
|
|
2676
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
2677
|
+
));
|
|
2678
|
+
}
|
|
2679
|
+
let target = resolve_link_target(
|
|
2680
|
+
&config,
|
|
2681
|
+
args.project_id.as_deref(),
|
|
2682
|
+
args.uproject.as_deref(),
|
|
2683
|
+
)?;
|
|
2684
|
+
let project_plugins_dir = PathBuf::from(&target.project_root).join("Plugins");
|
|
2685
|
+
let engine_plugins_dir = target.engine_root.as_ref().map(|root| {
|
|
2686
|
+
PathBuf::from(root)
|
|
2687
|
+
.join("Engine")
|
|
2688
|
+
.join("Plugins")
|
|
2689
|
+
.join("Marketplace")
|
|
2690
|
+
});
|
|
2691
|
+
let manifest_path = unreal_manifest_local_path(&target);
|
|
2692
|
+
|
|
2693
|
+
print_json(&serde_json::json!({
|
|
2694
|
+
"ok": true,
|
|
2695
|
+
"projectId": target.project_id,
|
|
2696
|
+
"paths": {
|
|
2697
|
+
"uprojectPath": target.uproject_path,
|
|
2698
|
+
"projectRoot": target.project_root,
|
|
2699
|
+
"editorPath": target.editor_path,
|
|
2700
|
+
"engineRoot": target.engine_root,
|
|
2701
|
+
"projectPluginsDir": project_plugins_dir.display().to_string(),
|
|
2702
|
+
"enginePluginsDir": engine_plugins_dir.as_ref().map(|value| value.display().to_string()),
|
|
2703
|
+
"manifestPath": manifest_path.display().to_string(),
|
|
2704
|
+
"localLinkConfigPath": unreal_links_path()?.display().to_string()
|
|
2705
|
+
},
|
|
2706
|
+
"remoteManifestAssetPath": ".reallink/link/unreal-link.latest.json"
|
|
2707
|
+
}))?;
|
|
2708
|
+
Ok(())
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
pub(crate) async fn link_doctor_command(client: &reqwest::Client, args: LinkDoctorArgs) -> Result<()> {
|
|
2712
|
+
let config = load_unreal_links()?;
|
|
2713
|
+
if config.links.is_empty() {
|
|
2714
|
+
return Err(anyhow!(
|
|
2715
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
2716
|
+
));
|
|
2717
|
+
}
|
|
2718
|
+
let target = resolve_link_target(
|
|
2719
|
+
&config,
|
|
2720
|
+
args.project_id.as_deref(),
|
|
2721
|
+
args.uproject.as_deref(),
|
|
2722
|
+
)?;
|
|
2723
|
+
|
|
2724
|
+
let mut checks: Vec<serde_json::Value> = Vec::new();
|
|
2725
|
+
let mut warnings: Vec<String> = Vec::new();
|
|
2726
|
+
let mut errors: Vec<String> = Vec::new();
|
|
2727
|
+
|
|
2728
|
+
let project_root = PathBuf::from(&target.project_root);
|
|
2729
|
+
let uproject_path = PathBuf::from(&target.uproject_path);
|
|
2730
|
+
let editor_path = PathBuf::from(&target.editor_path);
|
|
2731
|
+
let engine_root = target.engine_root.as_ref().map(PathBuf::from);
|
|
2732
|
+
let project_plugins_dir = project_root.join("Plugins");
|
|
2733
|
+
let engine_plugins_dir = engine_root
|
|
2734
|
+
.as_ref()
|
|
2735
|
+
.map(|root| root.join("Engine").join("Plugins").join("Marketplace"));
|
|
2736
|
+
let manifest_path = unreal_manifest_local_path(&target);
|
|
2737
|
+
|
|
2738
|
+
push_doctor_check(
|
|
2739
|
+
&mut checks,
|
|
2740
|
+
&mut errors,
|
|
2741
|
+
&mut warnings,
|
|
2742
|
+
"projectRoot.exists",
|
|
2743
|
+
project_root.exists() && project_root.is_dir(),
|
|
2744
|
+
format!("projectRoot={}", project_root.display()),
|
|
2745
|
+
false,
|
|
2746
|
+
);
|
|
2747
|
+
let uproject_ext_ok = uproject_path
|
|
2748
|
+
.extension()
|
|
2749
|
+
.and_then(|value| value.to_str())
|
|
2750
|
+
.map(|value| value.eq_ignore_ascii_case("uproject"))
|
|
2751
|
+
.unwrap_or(false);
|
|
2752
|
+
push_doctor_check(
|
|
2753
|
+
&mut checks,
|
|
2754
|
+
&mut errors,
|
|
2755
|
+
&mut warnings,
|
|
2756
|
+
"uproject.valid",
|
|
2757
|
+
uproject_path.exists() && uproject_path.is_file() && uproject_ext_ok,
|
|
2758
|
+
format!("uprojectPath={}", uproject_path.display()),
|
|
2759
|
+
false,
|
|
2760
|
+
);
|
|
2761
|
+
push_doctor_check(
|
|
2762
|
+
&mut checks,
|
|
2763
|
+
&mut errors,
|
|
2764
|
+
&mut warnings,
|
|
2765
|
+
"editor.valid",
|
|
2766
|
+
editor_path.exists() && editor_path.is_file(),
|
|
2767
|
+
format!("editorPath={}", editor_path.display()),
|
|
2768
|
+
false,
|
|
2769
|
+
);
|
|
2770
|
+
if let Some(root) = engine_root.as_ref() {
|
|
2771
|
+
let root_ok = root.exists() && root.is_dir();
|
|
2772
|
+
push_doctor_check(
|
|
2773
|
+
&mut checks,
|
|
2774
|
+
&mut errors,
|
|
2775
|
+
&mut warnings,
|
|
2776
|
+
"engineRoot.valid",
|
|
2777
|
+
root_ok,
|
|
2778
|
+
format!("engineRoot={}", root.display()),
|
|
2779
|
+
false,
|
|
2780
|
+
);
|
|
2781
|
+
let binaries_path = root.join("Engine").join("Binaries");
|
|
2782
|
+
push_doctor_check(
|
|
2783
|
+
&mut checks,
|
|
2784
|
+
&mut errors,
|
|
2785
|
+
&mut warnings,
|
|
2786
|
+
"engineRoot.layout",
|
|
2787
|
+
binaries_path.exists() && binaries_path.is_dir(),
|
|
2788
|
+
format!("expected Engine/Binaries under {}", root.display()),
|
|
2789
|
+
true,
|
|
2790
|
+
);
|
|
2791
|
+
} else {
|
|
2792
|
+
push_doctor_check(
|
|
2793
|
+
&mut checks,
|
|
2794
|
+
&mut errors,
|
|
2795
|
+
&mut warnings,
|
|
2796
|
+
"engineRoot.present",
|
|
2797
|
+
false,
|
|
2798
|
+
"engineRoot not set on this link; plugin engine-scope operations are unavailable",
|
|
2799
|
+
true,
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
push_doctor_check(
|
|
2803
|
+
&mut checks,
|
|
2804
|
+
&mut errors,
|
|
2805
|
+
&mut warnings,
|
|
2806
|
+
"plugins.projectDir",
|
|
2807
|
+
project_plugins_dir.exists() && project_plugins_dir.is_dir(),
|
|
2808
|
+
format!("projectPluginsDir={}", project_plugins_dir.display()),
|
|
2809
|
+
true,
|
|
2810
|
+
);
|
|
2811
|
+
if let Some(engine_plugins) = engine_plugins_dir.as_ref() {
|
|
2812
|
+
push_doctor_check(
|
|
2813
|
+
&mut checks,
|
|
2814
|
+
&mut errors,
|
|
2815
|
+
&mut warnings,
|
|
2816
|
+
"plugins.engineDir",
|
|
2817
|
+
engine_plugins.exists() && engine_plugins.is_dir(),
|
|
2818
|
+
format!("enginePluginsDir={}", engine_plugins.display()),
|
|
2819
|
+
true,
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
if manifest_path.exists() {
|
|
2824
|
+
let manifest_ok = fs::read_to_string(&manifest_path)
|
|
2825
|
+
.ok()
|
|
2826
|
+
.and_then(|value| json5::from_str::<serde_json::Value>(&value).ok())
|
|
2827
|
+
.is_some();
|
|
2828
|
+
push_doctor_check(
|
|
2829
|
+
&mut checks,
|
|
2830
|
+
&mut errors,
|
|
2831
|
+
&mut warnings,
|
|
2832
|
+
"manifest.local",
|
|
2833
|
+
manifest_ok,
|
|
2834
|
+
format!("manifestPath={}", manifest_path.display()),
|
|
2835
|
+
true,
|
|
2836
|
+
);
|
|
2837
|
+
} else {
|
|
2838
|
+
push_doctor_check(
|
|
2839
|
+
&mut checks,
|
|
2840
|
+
&mut errors,
|
|
2841
|
+
&mut warnings,
|
|
2842
|
+
"manifest.local",
|
|
2843
|
+
false,
|
|
2844
|
+
format!("manifestPath={} (missing)", manifest_path.display()),
|
|
2845
|
+
true,
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
if args.verify_remote {
|
|
2850
|
+
let (remote_ok, remote_detail) = match load_session() {
|
|
2851
|
+
Ok(mut session) => {
|
|
2852
|
+
apply_base_url_override(&mut session, args.base_url.clone());
|
|
2853
|
+
let outcome = if let Err(error) =
|
|
2854
|
+
verify_project_access(client, &mut session, &target.project_id).await
|
|
2855
|
+
{
|
|
2856
|
+
(false, error.to_string())
|
|
2857
|
+
} else {
|
|
2858
|
+
(
|
|
2859
|
+
true,
|
|
2860
|
+
format!(
|
|
2861
|
+
"projectId={} verified against {}",
|
|
2862
|
+
target.project_id, session.base_url
|
|
2863
|
+
),
|
|
2864
|
+
)
|
|
2865
|
+
};
|
|
2866
|
+
let _ = save_session(&session);
|
|
2867
|
+
outcome
|
|
2868
|
+
}
|
|
2869
|
+
Err(error) => (false, format!("no active session: {}", error)),
|
|
2870
|
+
};
|
|
2871
|
+
push_doctor_check(
|
|
2872
|
+
&mut checks,
|
|
2873
|
+
&mut errors,
|
|
2874
|
+
&mut warnings,
|
|
2875
|
+
"remote.verify",
|
|
2876
|
+
remote_ok,
|
|
2877
|
+
remote_detail,
|
|
2878
|
+
false,
|
|
2879
|
+
);
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
let ok = errors.is_empty();
|
|
2883
|
+
print_json(&serde_json::json!({
|
|
2884
|
+
"ok": ok,
|
|
2885
|
+
"projectId": target.project_id,
|
|
2886
|
+
"checks": checks,
|
|
2887
|
+
"warnings": warnings,
|
|
2888
|
+
"errors": errors,
|
|
2889
|
+
"paths": {
|
|
2890
|
+
"uprojectPath": uproject_path.display().to_string(),
|
|
2891
|
+
"projectRoot": project_root.display().to_string(),
|
|
2892
|
+
"editorPath": editor_path.display().to_string(),
|
|
2893
|
+
"engineRoot": engine_root.as_ref().map(|value| value.display().to_string()),
|
|
2894
|
+
"projectPluginsDir": project_plugins_dir.display().to_string(),
|
|
2895
|
+
"enginePluginsDir": engine_plugins_dir.as_ref().map(|value| value.display().to_string()),
|
|
2896
|
+
"manifestPath": manifest_path.display().to_string()
|
|
2897
|
+
}
|
|
2898
|
+
}))?;
|
|
2899
|
+
Ok(())
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
pub(crate) async fn link_open_command(args: LinkOpenArgs) -> Result<()> {
|
|
2903
|
+
let config = load_unreal_links()?;
|
|
2904
|
+
if config.links.is_empty() {
|
|
2905
|
+
return Err(anyhow!(
|
|
2906
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
2907
|
+
));
|
|
2908
|
+
}
|
|
2909
|
+
let target = resolve_link_target(
|
|
2910
|
+
&config,
|
|
2911
|
+
args.project_id.as_deref(),
|
|
2912
|
+
args.uproject.as_deref(),
|
|
2913
|
+
)?;
|
|
2914
|
+
let editor_path = PathBuf::from(&target.editor_path);
|
|
2915
|
+
let uproject_path = PathBuf::from(&target.uproject_path);
|
|
2916
|
+
|
|
2917
|
+
if !editor_path.exists() {
|
|
2918
|
+
return Err(anyhow!(
|
|
2919
|
+
"Editor path does not exist anymore: {}",
|
|
2920
|
+
editor_path.display()
|
|
2921
|
+
));
|
|
2922
|
+
}
|
|
2923
|
+
if !uproject_path.exists() {
|
|
2924
|
+
return Err(anyhow!(
|
|
2925
|
+
"uproject path does not exist anymore: {}",
|
|
2926
|
+
uproject_path.display()
|
|
2927
|
+
));
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
let mut command = Command::new(&editor_path);
|
|
2931
|
+
command.arg(&uproject_path);
|
|
2932
|
+
for argument in args.extra_arg {
|
|
2933
|
+
command.arg(argument);
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
if args.wait {
|
|
2937
|
+
let status = command
|
|
2938
|
+
.status()
|
|
2939
|
+
.with_context(|| format!("Failed to run Unreal editor {}", editor_path.display()))?;
|
|
2940
|
+
if !status.success() {
|
|
2941
|
+
return Err(anyhow!("Unreal editor exited with {}", status));
|
|
2942
|
+
}
|
|
2943
|
+
println!(
|
|
2944
|
+
"{}",
|
|
2945
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
2946
|
+
"ok": true,
|
|
2947
|
+
"projectId": target.project_id,
|
|
2948
|
+
"waited": true,
|
|
2949
|
+
"status": status.code()
|
|
2950
|
+
}))?
|
|
2951
|
+
);
|
|
2952
|
+
} else {
|
|
2953
|
+
let child = command
|
|
2954
|
+
.spawn()
|
|
2955
|
+
.with_context(|| format!("Failed to launch Unreal editor {}", editor_path.display()))?;
|
|
2956
|
+
println!(
|
|
2957
|
+
"{}",
|
|
2958
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
2959
|
+
"ok": true,
|
|
2960
|
+
"projectId": target.project_id,
|
|
2961
|
+
"waited": false,
|
|
2962
|
+
"pid": child.id()
|
|
2963
|
+
}))?
|
|
2964
|
+
);
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
Ok(())
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
pub(crate) async fn link_run_command(args: LinkRunArgs) -> Result<()> {
|
|
2971
|
+
let config = load_unreal_links()?;
|
|
2972
|
+
if config.links.is_empty() {
|
|
2973
|
+
return Err(anyhow!(
|
|
2974
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
2975
|
+
));
|
|
2976
|
+
}
|
|
2977
|
+
let target = resolve_link_target(
|
|
2978
|
+
&config,
|
|
2979
|
+
args.project_id.as_deref(),
|
|
2980
|
+
args.uproject.as_deref(),
|
|
2981
|
+
)?;
|
|
2982
|
+
let editor_path = PathBuf::from(&target.editor_path);
|
|
2983
|
+
let uproject_path = PathBuf::from(&target.uproject_path);
|
|
2984
|
+
|
|
2985
|
+
if !editor_path.exists() {
|
|
2986
|
+
return Err(anyhow!(
|
|
2987
|
+
"Editor path does not exist anymore: {}",
|
|
2988
|
+
editor_path.display()
|
|
2989
|
+
));
|
|
2990
|
+
}
|
|
2991
|
+
if !uproject_path.exists() {
|
|
2992
|
+
return Err(anyhow!(
|
|
2993
|
+
"uproject path does not exist anymore: {}",
|
|
2994
|
+
uproject_path.display()
|
|
2995
|
+
));
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
let mut command = Command::new(&editor_path);
|
|
2999
|
+
command.arg(&uproject_path);
|
|
3000
|
+
if let Some(commandlet) = args.commandlet.as_ref() {
|
|
3001
|
+
let normalized = commandlet.trim();
|
|
3002
|
+
if normalized.is_empty() {
|
|
3003
|
+
return Err(anyhow!("--commandlet cannot be empty"));
|
|
3004
|
+
}
|
|
3005
|
+
command.arg(format!("-run={}", normalized));
|
|
3006
|
+
}
|
|
3007
|
+
if args.log {
|
|
3008
|
+
command.arg("-log");
|
|
3009
|
+
}
|
|
3010
|
+
if args.headless {
|
|
3011
|
+
command.arg("-unattended");
|
|
3012
|
+
command.arg("-nop4");
|
|
3013
|
+
command.arg("-nosplash");
|
|
3014
|
+
command.arg("-nullrhi");
|
|
3015
|
+
}
|
|
3016
|
+
for argument in args.extra_arg {
|
|
3017
|
+
command.arg(argument);
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
if args.no_wait {
|
|
3021
|
+
let child = command
|
|
3022
|
+
.spawn()
|
|
3023
|
+
.with_context(|| format!("Failed to launch Unreal editor {}", editor_path.display()))?;
|
|
3024
|
+
println!(
|
|
3025
|
+
"{}",
|
|
3026
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3027
|
+
"ok": true,
|
|
3028
|
+
"projectId": target.project_id,
|
|
3029
|
+
"mode": if args.commandlet.is_some() { "commandlet" } else { "editor" },
|
|
3030
|
+
"waited": false,
|
|
3031
|
+
"pid": child.id()
|
|
3032
|
+
}))?
|
|
3033
|
+
);
|
|
3034
|
+
return Ok(());
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
let status = command
|
|
3038
|
+
.status()
|
|
3039
|
+
.with_context(|| format!("Failed to run Unreal editor {}", editor_path.display()))?;
|
|
3040
|
+
if !status.success() {
|
|
3041
|
+
return Err(anyhow!("Unreal editor exited with {}", status));
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
println!(
|
|
3045
|
+
"{}",
|
|
3046
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3047
|
+
"ok": true,
|
|
3048
|
+
"projectId": target.project_id,
|
|
3049
|
+
"mode": if args.commandlet.is_some() { "commandlet" } else { "editor" },
|
|
3050
|
+
"waited": true,
|
|
3051
|
+
"status": status.code()
|
|
3052
|
+
}))?
|
|
3053
|
+
);
|
|
3054
|
+
Ok(())
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
fn normalize_public_bucket_base(base_url: &str) -> Result<String> {
|
|
3058
|
+
let trimmed = base_url.trim().trim_end_matches('/');
|
|
3059
|
+
if trimmed.is_empty() {
|
|
3060
|
+
return Err(anyhow!("Plugin base URL is empty"));
|
|
3061
|
+
}
|
|
3062
|
+
let parsed = reqwest::Url::parse(trimmed)
|
|
3063
|
+
.with_context(|| format!("Invalid plugin base URL {}", trimmed))?;
|
|
3064
|
+
Ok(parsed.to_string().trim_end_matches('/').to_string())
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
fn compose_plugin_archive_url(base_url: &str, name: &str, version: &str) -> Result<String> {
|
|
3068
|
+
let plugin = name.trim();
|
|
3069
|
+
let release = version.trim();
|
|
3070
|
+
if plugin.is_empty() {
|
|
3071
|
+
return Err(anyhow!("Plugin name is required"));
|
|
3072
|
+
}
|
|
3073
|
+
if release.is_empty() {
|
|
3074
|
+
return Err(anyhow!("Plugin version is required"));
|
|
3075
|
+
}
|
|
3076
|
+
if plugin.contains('/') || plugin.contains('\\') {
|
|
3077
|
+
return Err(anyhow!("Plugin name cannot include path separators"));
|
|
3078
|
+
}
|
|
3079
|
+
let base = normalize_public_bucket_base(base_url)?;
|
|
3080
|
+
Ok(format!("{}/{}/{}/{}.zip", base, plugin, release, plugin))
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
fn normalize_sha256_hex(input: &str) -> Result<String> {
|
|
3084
|
+
let cleaned = input.trim().to_ascii_lowercase();
|
|
3085
|
+
if cleaned.len() != 64 {
|
|
3086
|
+
return Err(anyhow!(
|
|
3087
|
+
"SHA-256 must be a 64-character hex string, got length {}",
|
|
3088
|
+
cleaned.len()
|
|
3089
|
+
));
|
|
3090
|
+
}
|
|
3091
|
+
if !cleaned
|
|
3092
|
+
.chars()
|
|
3093
|
+
.all(|value| matches!(value, '0'..='9' | 'a'..='f'))
|
|
3094
|
+
{
|
|
3095
|
+
return Err(anyhow!("SHA-256 contains non-hex characters"));
|
|
3096
|
+
}
|
|
3097
|
+
Ok(cleaned)
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
async fn fetch_plugin_index_value(
|
|
3101
|
+
client: &reqwest::Client,
|
|
3102
|
+
base_url: &str,
|
|
3103
|
+
index_path: &str,
|
|
3104
|
+
) -> Result<serde_json::Value> {
|
|
3105
|
+
let base = normalize_public_bucket_base(base_url)?;
|
|
3106
|
+
let normalized_index_path = index_path.trim().trim_start_matches('/');
|
|
3107
|
+
if normalized_index_path.is_empty() {
|
|
3108
|
+
return Err(anyhow!("index path cannot be empty"));
|
|
3109
|
+
}
|
|
3110
|
+
let url = format!("{}/{}", base, normalized_index_path);
|
|
3111
|
+
let response = with_cli_headers(client.get(url.clone()))
|
|
3112
|
+
.send()
|
|
3113
|
+
.await
|
|
3114
|
+
.with_context(|| format!("Failed to fetch plugin index {}", url))?;
|
|
3115
|
+
if !response.status().is_success() {
|
|
3116
|
+
let body = read_error_body(response).await;
|
|
3117
|
+
return Err(anyhow!("plugin index fetch failed: {}", body));
|
|
3118
|
+
}
|
|
3119
|
+
let body = response
|
|
3120
|
+
.text()
|
|
3121
|
+
.await
|
|
3122
|
+
.with_context(|| format!("Failed to read plugin index {}", url))?;
|
|
3123
|
+
serde_json::from_str(&body)
|
|
3124
|
+
.or_else(|_| json5::from_str(&body))
|
|
3125
|
+
.with_context(|| format!("Plugin index is not valid JSON/JSONC: {}", url))
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
async fn fetch_plugin_index(
|
|
3129
|
+
client: &reqwest::Client,
|
|
3130
|
+
base_url: &str,
|
|
3131
|
+
index_path: &str,
|
|
3132
|
+
) -> Result<PluginIndexFile> {
|
|
3133
|
+
let value = fetch_plugin_index_value(client, base_url, index_path).await?;
|
|
3134
|
+
let parsed: PluginIndexFile = serde_json::from_value(value)
|
|
3135
|
+
.with_context(|| "Plugin index does not match expected schema")?;
|
|
3136
|
+
Ok(parsed)
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
fn resolve_plugin_from_index(
|
|
3140
|
+
index: &PluginIndexFile,
|
|
3141
|
+
name: &str,
|
|
3142
|
+
requested_version: &str,
|
|
3143
|
+
base_url: &str,
|
|
3144
|
+
) -> Result<(String, String, Option<String>)> {
|
|
3145
|
+
let plugin_name = name.trim();
|
|
3146
|
+
let version_request = requested_version.trim();
|
|
3147
|
+
if plugin_name.is_empty() {
|
|
3148
|
+
return Err(anyhow!("Plugin name is required"));
|
|
3149
|
+
}
|
|
3150
|
+
if version_request.is_empty() {
|
|
3151
|
+
return Err(anyhow!("Plugin version is required"));
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
let plugin = index
|
|
3155
|
+
.plugins
|
|
3156
|
+
.iter()
|
|
3157
|
+
.find(|entry| entry.name.eq_ignore_ascii_case(plugin_name))
|
|
3158
|
+
.ok_or_else(|| anyhow!("Plugin {} not found in index", plugin_name))?;
|
|
3159
|
+
|
|
3160
|
+
let resolved_version = if version_request.eq_ignore_ascii_case("latest") {
|
|
3161
|
+
plugin
|
|
3162
|
+
.latest
|
|
3163
|
+
.as_ref()
|
|
3164
|
+
.map(|value| value.trim().to_string())
|
|
3165
|
+
.or_else(|| {
|
|
3166
|
+
plugin
|
|
3167
|
+
.versions
|
|
3168
|
+
.first()
|
|
3169
|
+
.map(|entry| entry.version.trim().to_string())
|
|
3170
|
+
})
|
|
3171
|
+
.ok_or_else(|| anyhow!("Plugin {} has no versions in index", plugin_name))?
|
|
3172
|
+
} else {
|
|
3173
|
+
version_request.to_string()
|
|
3174
|
+
};
|
|
3175
|
+
|
|
3176
|
+
let version_entry = plugin
|
|
3177
|
+
.versions
|
|
3178
|
+
.iter()
|
|
3179
|
+
.find(|entry| entry.version.trim() == resolved_version)
|
|
3180
|
+
.ok_or_else(|| {
|
|
3181
|
+
anyhow!(
|
|
3182
|
+
"Version {} for plugin {} not found in index",
|
|
3183
|
+
resolved_version,
|
|
3184
|
+
plugin_name
|
|
3185
|
+
)
|
|
3186
|
+
})?;
|
|
3187
|
+
|
|
3188
|
+
let archive_url = if let Some(url) = version_entry.archive_url.as_ref() {
|
|
3189
|
+
let trimmed = url.trim();
|
|
3190
|
+
if trimmed.is_empty() {
|
|
3191
|
+
compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
|
|
3192
|
+
} else {
|
|
3193
|
+
trimmed.to_string()
|
|
3194
|
+
}
|
|
3195
|
+
} else {
|
|
3196
|
+
compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
|
|
3197
|
+
};
|
|
3198
|
+
|
|
3199
|
+
let expected_sha256 = version_entry
|
|
3200
|
+
.sha256
|
|
3201
|
+
.as_ref()
|
|
3202
|
+
.map(|value| normalize_sha256_hex(value))
|
|
3203
|
+
.transpose()?;
|
|
3204
|
+
|
|
3205
|
+
Ok((resolved_version, archive_url, expected_sha256))
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
fn compute_sha256_hex(path: &Path) -> Result<String> {
|
|
3209
|
+
let mut file =
|
|
3210
|
+
fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
|
3211
|
+
let mut hasher = sha2::Sha256::new();
|
|
3212
|
+
let mut buffer = [0u8; 64 * 1024];
|
|
3213
|
+
loop {
|
|
3214
|
+
let read = file
|
|
3215
|
+
.read(&mut buffer)
|
|
3216
|
+
.with_context(|| format!("Failed reading {}", path.display()))?;
|
|
3217
|
+
if read == 0 {
|
|
3218
|
+
break;
|
|
3219
|
+
}
|
|
3220
|
+
hasher.update(&buffer[..read]);
|
|
3221
|
+
}
|
|
3222
|
+
let digest = hasher.finalize();
|
|
3223
|
+
Ok(format!("{:x}", digest))
|
|
3224
|
+
}
|
|
3225
|
+
|
|
3226
|
+
async fn download_plugin_archive_to_file(
|
|
3227
|
+
client: &reqwest::Client,
|
|
3228
|
+
url: &str,
|
|
3229
|
+
destination: &Path,
|
|
3230
|
+
) -> Result<()> {
|
|
3231
|
+
if let Some(parent) = destination.parent() {
|
|
3232
|
+
tokio_fs::create_dir_all(parent)
|
|
3233
|
+
.await
|
|
3234
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
let response = with_cli_headers(client.get(url.to_string()))
|
|
3238
|
+
.send()
|
|
3239
|
+
.await
|
|
3240
|
+
.with_context(|| format!("Failed to download plugin archive {}", url))?;
|
|
3241
|
+
if !response.status().is_success() {
|
|
3242
|
+
let body = read_error_body(response).await;
|
|
3243
|
+
return Err(anyhow!("Plugin download failed: {}", body));
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
let mut file = tokio_fs::File::create(destination)
|
|
3247
|
+
.await
|
|
3248
|
+
.with_context(|| format!("Failed to create {}", destination.display()))?;
|
|
3249
|
+
let mut stream = response;
|
|
3250
|
+
while let Some(chunk) = stream
|
|
3251
|
+
.chunk()
|
|
3252
|
+
.await
|
|
3253
|
+
.with_context(|| format!("Failed while downloading {}", url))?
|
|
3254
|
+
{
|
|
3255
|
+
file.write_all(&chunk)
|
|
3256
|
+
.await
|
|
3257
|
+
.with_context(|| format!("Failed writing {}", destination.display()))?;
|
|
3258
|
+
}
|
|
3259
|
+
file.flush()
|
|
3260
|
+
.await
|
|
3261
|
+
.with_context(|| format!("Failed to finalize {}", destination.display()))?;
|
|
3262
|
+
Ok(())
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
fn extract_zip_file(zip_path: &Path, destination: &Path) -> Result<()> {
|
|
3266
|
+
let file = fs::File::open(zip_path)
|
|
3267
|
+
.with_context(|| format!("Failed to open {}", zip_path.display()))?;
|
|
3268
|
+
let mut archive = zip::ZipArchive::new(file).with_context(|| "Failed to read zip archive")?;
|
|
3269
|
+
|
|
3270
|
+
for index in 0..archive.len() {
|
|
3271
|
+
let mut file = archive
|
|
3272
|
+
.by_index(index)
|
|
3273
|
+
.with_context(|| format!("Failed to read zip entry {}", index))?;
|
|
3274
|
+
let Some(name) = file.enclosed_name().map(|value| value.to_path_buf()) else {
|
|
3275
|
+
continue;
|
|
3276
|
+
};
|
|
3277
|
+
let output_path = destination.join(name);
|
|
3278
|
+
|
|
3279
|
+
if file.is_dir() {
|
|
3280
|
+
fs::create_dir_all(&output_path)
|
|
3281
|
+
.with_context(|| format!("Failed to create {}", output_path.display()))?;
|
|
3282
|
+
continue;
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
if let Some(parent) = output_path.parent() {
|
|
3286
|
+
fs::create_dir_all(parent)
|
|
3287
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
3288
|
+
}
|
|
1157
3289
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
3290
|
+
let mut output_file = fs::File::create(&output_path)
|
|
3291
|
+
.with_context(|| format!("Failed to create {}", output_path.display()))?;
|
|
3292
|
+
io::copy(&mut file, &mut output_file)
|
|
3293
|
+
.with_context(|| format!("Failed to extract {}", output_path.display()))?;
|
|
1162
3294
|
}
|
|
1163
|
-
let payload: ListProjectsResponse = response.json().await?;
|
|
1164
|
-
println!("{}", serde_json::to_string_pretty(&payload.projects)?);
|
|
1165
|
-
save_session(&session)?;
|
|
1166
3295
|
Ok(())
|
|
1167
3296
|
}
|
|
1168
3297
|
|
|
1169
|
-
|
|
1170
|
-
let mut
|
|
1171
|
-
|
|
3298
|
+
fn collect_uplugin_roots(root: &Path) -> Result<Vec<PathBuf>> {
|
|
3299
|
+
let mut stack = vec![root.to_path_buf()];
|
|
3300
|
+
let mut roots: Vec<PathBuf> = Vec::new();
|
|
1172
3301
|
|
|
1173
|
-
let
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
3302
|
+
while let Some(path) = stack.pop() {
|
|
3303
|
+
if !path.is_dir() {
|
|
3304
|
+
continue;
|
|
3305
|
+
}
|
|
3306
|
+
for entry in
|
|
3307
|
+
fs::read_dir(&path).with_context(|| format!("Failed to read {}", path.display()))?
|
|
3308
|
+
{
|
|
3309
|
+
let entry = entry?;
|
|
3310
|
+
let child = entry.path();
|
|
3311
|
+
if child.is_dir() {
|
|
3312
|
+
stack.push(child);
|
|
3313
|
+
continue;
|
|
3314
|
+
}
|
|
3315
|
+
let is_uplugin = child
|
|
3316
|
+
.extension()
|
|
3317
|
+
.and_then(|value| value.to_str())
|
|
3318
|
+
.map(|value| value.eq_ignore_ascii_case("uplugin"))
|
|
3319
|
+
.unwrap_or(false);
|
|
3320
|
+
if is_uplugin {
|
|
3321
|
+
if let Some(parent) = child.parent() {
|
|
3322
|
+
roots.push(parent.to_path_buf());
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
1188
3326
|
}
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
3327
|
+
|
|
3328
|
+
roots.sort();
|
|
3329
|
+
roots.dedup_by(|left, right| {
|
|
3330
|
+
normalize_path_for_compare(left) == normalize_path_for_compare(right)
|
|
3331
|
+
});
|
|
3332
|
+
Ok(roots)
|
|
1193
3333
|
}
|
|
1194
3334
|
|
|
1195
|
-
|
|
1196
|
-
let
|
|
1197
|
-
|
|
3335
|
+
fn find_plugin_root_from_extracted(extracted_root: &Path) -> Result<PathBuf> {
|
|
3336
|
+
let roots = collect_uplugin_roots(extracted_root)?;
|
|
3337
|
+
if roots.is_empty() {
|
|
3338
|
+
return Err(anyhow!(
|
|
3339
|
+
"Plugin archive does not contain a .uplugin descriptor"
|
|
3340
|
+
));
|
|
3341
|
+
}
|
|
3342
|
+
if roots.len() > 1 {
|
|
3343
|
+
return Err(anyhow!(
|
|
3344
|
+
"Plugin archive contains multiple .uplugin roots; expected one"
|
|
3345
|
+
));
|
|
3346
|
+
}
|
|
3347
|
+
canonicalize_existing_path(&roots[0], "plugin root")
|
|
3348
|
+
}
|
|
1198
3349
|
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
3350
|
+
fn remove_existing_path(path: &Path) -> Result<()> {
|
|
3351
|
+
if !path.exists() {
|
|
3352
|
+
return Ok(());
|
|
3353
|
+
}
|
|
3354
|
+
if path.is_dir() {
|
|
3355
|
+
fs::remove_dir_all(path)
|
|
3356
|
+
.with_context(|| format!("Failed to remove directory {}", path.display()))?;
|
|
3357
|
+
} else {
|
|
3358
|
+
fs::remove_file(path)
|
|
3359
|
+
.with_context(|| format!("Failed to remove file {}", path.display()))?;
|
|
1204
3360
|
}
|
|
1205
|
-
let payload: ProjectResponse = response.json().await?;
|
|
1206
|
-
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
1207
|
-
save_session(&session)?;
|
|
1208
3361
|
Ok(())
|
|
1209
3362
|
}
|
|
1210
3363
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
3364
|
+
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
|
|
3365
|
+
fs::create_dir_all(destination)
|
|
3366
|
+
.with_context(|| format!("Failed to create {}", destination.display()))?;
|
|
3367
|
+
for entry in
|
|
3368
|
+
fs::read_dir(source).with_context(|| format!("Failed to read {}", source.display()))?
|
|
3369
|
+
{
|
|
3370
|
+
let entry = entry?;
|
|
3371
|
+
let child = entry.path();
|
|
3372
|
+
let target = destination.join(entry.file_name());
|
|
3373
|
+
if child.is_dir() {
|
|
3374
|
+
copy_dir_recursive(&child, &target)?;
|
|
3375
|
+
} else {
|
|
3376
|
+
if let Some(parent) = target.parent() {
|
|
3377
|
+
fs::create_dir_all(parent)
|
|
3378
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
3379
|
+
}
|
|
3380
|
+
fs::copy(&child, &target).with_context(|| {
|
|
3381
|
+
format!("Failed to copy {} to {}", child.display(), target.display())
|
|
3382
|
+
})?;
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
Ok(())
|
|
3386
|
+
}
|
|
1214
3387
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
3388
|
+
pub(crate) async fn link_plugin_install_command(
|
|
3389
|
+
client: &reqwest::Client,
|
|
3390
|
+
args: LinkPluginInstallArgs,
|
|
3391
|
+
) -> Result<()> {
|
|
3392
|
+
let config = load_unreal_links()?;
|
|
3393
|
+
if config.links.is_empty() {
|
|
3394
|
+
return Err(anyhow!(
|
|
3395
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
3396
|
+
));
|
|
1218
3397
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
3398
|
+
let target = resolve_link_target(
|
|
3399
|
+
&config,
|
|
3400
|
+
args.project_id.as_deref(),
|
|
3401
|
+
args.uproject.as_deref(),
|
|
3402
|
+
)?;
|
|
3403
|
+
|
|
3404
|
+
let plugin_name = args.name.trim();
|
|
3405
|
+
if plugin_name.is_empty() {
|
|
3406
|
+
return Err(anyhow!("Plugin name is required"));
|
|
3407
|
+
}
|
|
3408
|
+
if plugin_name.contains('/') || plugin_name.contains('\\') {
|
|
3409
|
+
return Err(anyhow!("Plugin name cannot include path separators"));
|
|
1226
3410
|
}
|
|
1227
3411
|
|
|
1228
|
-
|
|
3412
|
+
let mut expected_sha256 = args
|
|
3413
|
+
.sha256
|
|
3414
|
+
.as_ref()
|
|
3415
|
+
.map(|value| normalize_sha256_hex(value))
|
|
3416
|
+
.transpose()?;
|
|
3417
|
+
let mut resolved_version = args.version.trim().to_string();
|
|
3418
|
+
if resolved_version.is_empty() {
|
|
3419
|
+
return Err(anyhow!("Plugin version is required"));
|
|
3420
|
+
}
|
|
3421
|
+
|
|
3422
|
+
let archive_url = if let Some(url) = args.url {
|
|
3423
|
+
let trimmed = url.trim();
|
|
3424
|
+
if trimmed.is_empty() {
|
|
3425
|
+
return Err(anyhow!("--url cannot be empty"));
|
|
3426
|
+
}
|
|
3427
|
+
trimmed.to_string()
|
|
3428
|
+
} else if args.use_index {
|
|
3429
|
+
let index = fetch_plugin_index(client, &args.base_url, &args.index_path).await?;
|
|
3430
|
+
let (resolved, url, index_sha256) =
|
|
3431
|
+
resolve_plugin_from_index(&index, plugin_name, &resolved_version, &args.base_url)?;
|
|
3432
|
+
if expected_sha256.is_none() {
|
|
3433
|
+
expected_sha256 = index_sha256;
|
|
3434
|
+
}
|
|
3435
|
+
resolved_version = resolved;
|
|
3436
|
+
url
|
|
3437
|
+
} else {
|
|
3438
|
+
compose_plugin_archive_url(&args.base_url, plugin_name, &resolved_version)?
|
|
3439
|
+
};
|
|
3440
|
+
|
|
3441
|
+
let plugin_root_dir = if args.engine {
|
|
3442
|
+
let engine_root = target.engine_root.as_ref().ok_or_else(|| {
|
|
3443
|
+
anyhow!("Selected link has no engine_root; relink with --engine-root or --editor")
|
|
3444
|
+
})?;
|
|
3445
|
+
let root = PathBuf::from(engine_root);
|
|
3446
|
+
root.join("Engine").join("Plugins").join("Marketplace")
|
|
3447
|
+
} else {
|
|
3448
|
+
PathBuf::from(&target.project_root).join("Plugins")
|
|
3449
|
+
};
|
|
3450
|
+
fs::create_dir_all(&plugin_root_dir)
|
|
3451
|
+
.with_context(|| format!("Failed to create {}", plugin_root_dir.display()))?;
|
|
3452
|
+
let destination_dir = plugin_root_dir.join(plugin_name);
|
|
3453
|
+
|
|
3454
|
+
if destination_dir.exists() && !args.force {
|
|
1229
3455
|
return Err(anyhow!(
|
|
1230
|
-
"
|
|
3456
|
+
"Plugin destination already exists: {} (use --force to overwrite)",
|
|
3457
|
+
destination_dir.display()
|
|
1231
3458
|
));
|
|
1232
3459
|
}
|
|
3460
|
+
if destination_dir.exists() && args.force {
|
|
3461
|
+
remove_existing_path(&destination_dir)?;
|
|
3462
|
+
}
|
|
1233
3463
|
|
|
1234
|
-
let
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
)
|
|
1242
|
-
.await?;
|
|
1243
|
-
if !response.status().is_success() {
|
|
1244
|
-
let body = read_error_body(response).await;
|
|
1245
|
-
return Err(anyhow!("project update failed: {}", body));
|
|
3464
|
+
let temp_root = std::env::temp_dir().join(format!(
|
|
3465
|
+
"reallink-plugin-install-{}-{}",
|
|
3466
|
+
plugin_name,
|
|
3467
|
+
now_epoch_ms()
|
|
3468
|
+
));
|
|
3469
|
+
if temp_root.exists() {
|
|
3470
|
+
remove_existing_path(&temp_root)?;
|
|
1246
3471
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
3472
|
+
fs::create_dir_all(&temp_root)
|
|
3473
|
+
.with_context(|| format!("Failed to create {}", temp_root.display()))?;
|
|
3474
|
+
let archive_path = temp_root.join("plugin.zip");
|
|
3475
|
+
let temp_extract_root = temp_root.join("extract");
|
|
3476
|
+
|
|
3477
|
+
download_plugin_archive_to_file(client, &archive_url, &archive_path).await?;
|
|
3478
|
+
let archive_sha256 = compute_sha256_hex(&archive_path)?;
|
|
3479
|
+
if let Some(expected) = expected_sha256.as_ref() {
|
|
3480
|
+
if &archive_sha256 != expected {
|
|
3481
|
+
let _ = remove_existing_path(&temp_root);
|
|
3482
|
+
return Err(anyhow!(
|
|
3483
|
+
"Plugin checksum mismatch (expected {}, got {})",
|
|
3484
|
+
expected,
|
|
3485
|
+
archive_sha256
|
|
3486
|
+
));
|
|
3487
|
+
}
|
|
3488
|
+
}
|
|
3489
|
+
fs::create_dir_all(&temp_extract_root)
|
|
3490
|
+
.with_context(|| format!("Failed to create {}", temp_extract_root.display()))?;
|
|
3491
|
+
extract_zip_file(&archive_path, &temp_extract_root)?;
|
|
3492
|
+
let plugin_source_root = find_plugin_root_from_extracted(&temp_extract_root)?;
|
|
3493
|
+
copy_dir_recursive(&plugin_source_root, &destination_dir)?;
|
|
3494
|
+
let _ = remove_existing_path(&temp_root);
|
|
3495
|
+
|
|
3496
|
+
println!(
|
|
3497
|
+
"{}",
|
|
3498
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3499
|
+
"ok": true,
|
|
3500
|
+
"projectId": target.project_id,
|
|
3501
|
+
"plugin": plugin_name,
|
|
3502
|
+
"version": resolved_version,
|
|
3503
|
+
"scope": if args.engine { "engine" } else { "project" },
|
|
3504
|
+
"archiveUrl": archive_url,
|
|
3505
|
+
"archiveSha256": archive_sha256,
|
|
3506
|
+
"verifiedSha256": expected_sha256,
|
|
3507
|
+
"installedTo": destination_dir.display().to_string()
|
|
3508
|
+
}))?
|
|
3509
|
+
);
|
|
1250
3510
|
Ok(())
|
|
1251
3511
|
}
|
|
1252
3512
|
|
|
1253
|
-
async fn
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
let
|
|
1258
|
-
|
|
1259
|
-
if !response.status().is_success() {
|
|
1260
|
-
let body = read_error_body(response).await;
|
|
1261
|
-
return Err(anyhow!("project delete failed: {}", body));
|
|
1262
|
-
}
|
|
1263
|
-
let payload: serde_json::Value = response.json().await?;
|
|
1264
|
-
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1265
|
-
save_session(&session)?;
|
|
3513
|
+
pub(crate) async fn link_plugin_list_command(
|
|
3514
|
+
client: &reqwest::Client,
|
|
3515
|
+
args: LinkPluginListArgs,
|
|
3516
|
+
) -> Result<()> {
|
|
3517
|
+
let parsed = fetch_plugin_index_value(client, &args.base_url, &args.index_path).await?;
|
|
3518
|
+
println!("{}", serde_json::to_string_pretty(&parsed)?);
|
|
1266
3519
|
Ok(())
|
|
1267
3520
|
}
|
|
1268
3521
|
|
|
@@ -1303,11 +3556,23 @@ async fn upload_asset_via_intent(
|
|
|
1303
3556
|
let complete_payload = if strategy == "multipart" {
|
|
1304
3557
|
let part_size = intent
|
|
1305
3558
|
.part_size_bytes
|
|
1306
|
-
.and_then(|value|
|
|
3559
|
+
.and_then(|value| {
|
|
3560
|
+
if value > 0 {
|
|
3561
|
+
Some(value as usize)
|
|
3562
|
+
} else {
|
|
3563
|
+
None
|
|
3564
|
+
}
|
|
3565
|
+
})
|
|
1307
3566
|
.ok_or_else(|| anyhow!("multipart upload intent is missing partSizeBytes"))?;
|
|
1308
3567
|
let part_count = intent
|
|
1309
3568
|
.part_count
|
|
1310
|
-
.and_then(|value|
|
|
3569
|
+
.and_then(|value| {
|
|
3570
|
+
if value > 0 {
|
|
3571
|
+
Some(value as usize)
|
|
3572
|
+
} else {
|
|
3573
|
+
None
|
|
3574
|
+
}
|
|
3575
|
+
})
|
|
1311
3576
|
.ok_or_else(|| anyhow!("multipart upload intent is missing partCount"))?;
|
|
1312
3577
|
let session_id = intent
|
|
1313
3578
|
.session_id
|
|
@@ -1459,6 +3724,144 @@ async fn file_get_command(client: &reqwest::Client, args: FileGetArgs) -> Result
|
|
|
1459
3724
|
Ok(())
|
|
1460
3725
|
}
|
|
1461
3726
|
|
|
3727
|
+
async fn file_stat_command(client: &reqwest::Client, args: FileStatArgs) -> Result<()> {
|
|
3728
|
+
let mut session = load_session()?;
|
|
3729
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
3730
|
+
|
|
3731
|
+
let path = format!("/assets/{}/metadata", args.asset_id);
|
|
3732
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
3733
|
+
if !response.status().is_success() {
|
|
3734
|
+
let body = read_error_body(response).await;
|
|
3735
|
+
return Err(anyhow!("file stat failed: {}", body));
|
|
3736
|
+
}
|
|
3737
|
+
let payload: AssetMetadataResponse = response.json().await?;
|
|
3738
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
3739
|
+
save_session(&session)?;
|
|
3740
|
+
Ok(())
|
|
3741
|
+
}
|
|
3742
|
+
|
|
3743
|
+
async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs) -> Result<()> {
|
|
3744
|
+
let mut session = load_session()?;
|
|
3745
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
3746
|
+
|
|
3747
|
+
let metadata_path = format!("/assets/{}/metadata", args.asset_id);
|
|
3748
|
+
let metadata_response =
|
|
3749
|
+
authed_request(client, &mut session, Method::GET, &metadata_path, None).await?;
|
|
3750
|
+
if !metadata_response.status().is_success() {
|
|
3751
|
+
let body = read_error_body(metadata_response).await;
|
|
3752
|
+
return Err(anyhow!("file metadata fetch failed: {}", body));
|
|
3753
|
+
}
|
|
3754
|
+
let metadata_payload: AssetMetadataResponse = metadata_response.json().await?;
|
|
3755
|
+
let fallback_name = base_name_from_virtual_path(&metadata_payload.asset.file_name);
|
|
3756
|
+
|
|
3757
|
+
let mut output_path = args
|
|
3758
|
+
.output_path
|
|
3759
|
+
.unwrap_or_else(|| PathBuf::from(fallback_name.as_str()));
|
|
3760
|
+
if output_path.exists() && output_path.is_dir() {
|
|
3761
|
+
output_path = output_path.join(fallback_name.as_str());
|
|
3762
|
+
}
|
|
3763
|
+
|
|
3764
|
+
if let Some(parent) = output_path.parent() {
|
|
3765
|
+
if !parent.as_os_str().is_empty() {
|
|
3766
|
+
tokio_fs::create_dir_all(parent).await.with_context(|| {
|
|
3767
|
+
format!("Failed to create output directory {}", parent.display())
|
|
3768
|
+
})?;
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
let mut resume_from: Option<u64> = None;
|
|
3773
|
+
if args.resume && output_path.exists() && output_path.is_file() {
|
|
3774
|
+
let existing_size = tokio_fs::metadata(&output_path)
|
|
3775
|
+
.await
|
|
3776
|
+
.with_context(|| {
|
|
3777
|
+
format!(
|
|
3778
|
+
"Failed to read output file metadata {}",
|
|
3779
|
+
output_path.display()
|
|
3780
|
+
)
|
|
3781
|
+
})?
|
|
3782
|
+
.len();
|
|
3783
|
+
if existing_size > 0 {
|
|
3784
|
+
let remote_size = metadata_payload.content.content_length.max(0) as u64;
|
|
3785
|
+
if existing_size >= remote_size && remote_size > 0 {
|
|
3786
|
+
println!(
|
|
3787
|
+
"{}",
|
|
3788
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3789
|
+
"assetId": metadata_payload.asset.id,
|
|
3790
|
+
"fileName": metadata_payload.asset.file_name,
|
|
3791
|
+
"output": output_path.display().to_string(),
|
|
3792
|
+
"bytesWritten": 0,
|
|
3793
|
+
"resumedFrom": existing_size,
|
|
3794
|
+
"alreadyComplete": true
|
|
3795
|
+
}))?
|
|
3796
|
+
);
|
|
3797
|
+
save_session(&session)?;
|
|
3798
|
+
return Ok(());
|
|
3799
|
+
}
|
|
3800
|
+
resume_from = Some(existing_size);
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
let download_path = format!("/assets/{}/download", args.asset_id);
|
|
3805
|
+
let mut headers = Vec::new();
|
|
3806
|
+
if let Some(offset) = resume_from {
|
|
3807
|
+
headers.push(("range".to_string(), format!("bytes={}-", offset)));
|
|
3808
|
+
}
|
|
3809
|
+
let mut response = authed_request_with_headers(
|
|
3810
|
+
client,
|
|
3811
|
+
&mut session,
|
|
3812
|
+
Method::GET,
|
|
3813
|
+
&download_path,
|
|
3814
|
+
None,
|
|
3815
|
+
&headers,
|
|
3816
|
+
)
|
|
3817
|
+
.await?;
|
|
3818
|
+
if !(response.status().is_success() || response.status() == StatusCode::PARTIAL_CONTENT) {
|
|
3819
|
+
let body = read_error_body(response).await;
|
|
3820
|
+
return Err(anyhow!("file download failed: {}", body));
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
let append_mode = resume_from.is_some() && response.status() == StatusCode::PARTIAL_CONTENT;
|
|
3824
|
+
let mut output_file = if append_mode {
|
|
3825
|
+
tokio_fs::OpenOptions::new()
|
|
3826
|
+
.append(true)
|
|
3827
|
+
.open(&output_path)
|
|
3828
|
+
.await
|
|
3829
|
+
.with_context(|| {
|
|
3830
|
+
format!(
|
|
3831
|
+
"Failed to open output file for append {}",
|
|
3832
|
+
output_path.display()
|
|
3833
|
+
)
|
|
3834
|
+
})?
|
|
3835
|
+
} else {
|
|
3836
|
+
tokio_fs::File::create(&output_path)
|
|
3837
|
+
.await
|
|
3838
|
+
.with_context(|| format!("Failed to create output file {}", output_path.display()))?
|
|
3839
|
+
};
|
|
3840
|
+
let mut bytes_written: u64 = 0;
|
|
3841
|
+
while let Some(chunk) = response.chunk().await? {
|
|
3842
|
+
output_file
|
|
3843
|
+
.write_all(&chunk)
|
|
3844
|
+
.await
|
|
3845
|
+
.with_context(|| format!("Failed to write to {}", output_path.display()))?;
|
|
3846
|
+
bytes_written = bytes_written.saturating_add(chunk.len() as u64);
|
|
3847
|
+
}
|
|
3848
|
+
output_file.flush().await?;
|
|
3849
|
+
|
|
3850
|
+
println!(
|
|
3851
|
+
"{}",
|
|
3852
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3853
|
+
"assetId": metadata_payload.asset.id,
|
|
3854
|
+
"fileName": metadata_payload.asset.file_name,
|
|
3855
|
+
"output": output_path.display().to_string(),
|
|
3856
|
+
"bytesWritten": bytes_written,
|
|
3857
|
+
"resumedFrom": resume_from,
|
|
3858
|
+
"partialContent": response.status() == StatusCode::PARTIAL_CONTENT
|
|
3859
|
+
}))?
|
|
3860
|
+
);
|
|
3861
|
+
save_session(&session)?;
|
|
3862
|
+
Ok(())
|
|
3863
|
+
}
|
|
3864
|
+
|
|
1462
3865
|
async fn file_upload_command(client: &reqwest::Client, args: FileUploadArgs) -> Result<()> {
|
|
1463
3866
|
let mut session = load_session()?;
|
|
1464
3867
|
apply_base_url_override(&mut session, args.base_url);
|
|
@@ -1589,12 +3992,16 @@ async fn tool_register_command(client: &reqwest::Client, args: ToolRegisterArgs)
|
|
|
1589
3992
|
apply_base_url_override(&mut session, args.base_url);
|
|
1590
3993
|
|
|
1591
3994
|
let manifest = load_jsonc_file(&args.manifest, "tool manifest")?;
|
|
1592
|
-
let body =
|
|
1593
|
-
manifest,
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
3995
|
+
let body =
|
|
3996
|
+
serde_json::Value::Object(parse_object_from_value(manifest, "tool manifest payload")?);
|
|
3997
|
+
let response = authed_request(
|
|
3998
|
+
client,
|
|
3999
|
+
&mut session,
|
|
4000
|
+
Method::POST,
|
|
4001
|
+
"/tools/definitions",
|
|
4002
|
+
Some(body),
|
|
4003
|
+
)
|
|
4004
|
+
.await?;
|
|
1598
4005
|
if !response.status().is_success() {
|
|
1599
4006
|
let body = read_error_body(response).await;
|
|
1600
4007
|
return Err(anyhow!("tool register failed: {}", body));
|
|
@@ -1637,13 +4044,19 @@ async fn tool_set_entitlement_command(
|
|
|
1637
4044
|
body.insert("orgId".to_string(), serde_json::Value::String(org_id));
|
|
1638
4045
|
}
|
|
1639
4046
|
if let Some(project_id) = project_id {
|
|
1640
|
-
body.insert(
|
|
4047
|
+
body.insert(
|
|
4048
|
+
"projectId".to_string(),
|
|
4049
|
+
serde_json::Value::String(project_id),
|
|
4050
|
+
);
|
|
1641
4051
|
}
|
|
1642
4052
|
if let Some(user_id) = user_id {
|
|
1643
4053
|
body.insert("userId".to_string(), serde_json::Value::String(user_id));
|
|
1644
4054
|
}
|
|
1645
4055
|
if let Some(expires_at) = expires_at {
|
|
1646
|
-
body.insert(
|
|
4056
|
+
body.insert(
|
|
4057
|
+
"expiresAt".to_string(),
|
|
4058
|
+
serde_json::Value::String(expires_at),
|
|
4059
|
+
);
|
|
1647
4060
|
}
|
|
1648
4061
|
if let Some(path) = metadata_file {
|
|
1649
4062
|
let metadata = load_jsonc_file(&path, "tool entitlement metadata")?;
|
|
@@ -1731,19 +4144,23 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
|
|
|
1731
4144
|
serde_json::Value::Object(serde_json::Map::new())
|
|
1732
4145
|
};
|
|
1733
4146
|
|
|
1734
|
-
let input_object =
|
|
1735
|
-
input_value,
|
|
1736
|
-
"tool run input",
|
|
1737
|
-
)?);
|
|
4147
|
+
let input_object =
|
|
4148
|
+
serde_json::Value::Object(parse_object_from_value(input_value, "tool run input")?);
|
|
1738
4149
|
|
|
1739
4150
|
let mut body = serde_json::Map::new();
|
|
1740
|
-
body.insert(
|
|
4151
|
+
body.insert(
|
|
4152
|
+
"toolId".to_string(),
|
|
4153
|
+
serde_json::Value::String(args.tool_id),
|
|
4154
|
+
);
|
|
1741
4155
|
body.insert("input".to_string(), input_object);
|
|
1742
4156
|
if let Some(org_id) = args.org_id {
|
|
1743
4157
|
body.insert("orgId".to_string(), serde_json::Value::String(org_id));
|
|
1744
4158
|
}
|
|
1745
4159
|
if let Some(project_id) = args.project_id {
|
|
1746
|
-
body.insert(
|
|
4160
|
+
body.insert(
|
|
4161
|
+
"projectId".to_string(),
|
|
4162
|
+
serde_json::Value::String(project_id),
|
|
4163
|
+
);
|
|
1747
4164
|
}
|
|
1748
4165
|
let mut metadata_map = if let Some(path) = args.metadata_file {
|
|
1749
4166
|
let metadata = load_jsonc_file(&path, "tool run metadata")?;
|
|
@@ -1761,7 +4178,10 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
|
|
|
1761
4178
|
}
|
|
1762
4179
|
}
|
|
1763
4180
|
if !metadata_map.is_empty() {
|
|
1764
|
-
body.insert(
|
|
4181
|
+
body.insert(
|
|
4182
|
+
"metadata".to_string(),
|
|
4183
|
+
serde_json::Value::Object(metadata_map),
|
|
4184
|
+
);
|
|
1765
4185
|
}
|
|
1766
4186
|
|
|
1767
4187
|
let response = authed_request(
|
|
@@ -1851,23 +4271,58 @@ fn run_and_check_status(mut command: Command, context: &str) -> Result<()> {
|
|
|
1851
4271
|
Ok(())
|
|
1852
4272
|
}
|
|
1853
4273
|
|
|
1854
|
-
async fn self_update_command(
|
|
4274
|
+
async fn self_update_command(
|
|
4275
|
+
client: &reqwest::Client,
|
|
4276
|
+
args: SelfUpdateArgs,
|
|
4277
|
+
output: OutputFormat,
|
|
4278
|
+
) -> Result<()> {
|
|
1855
4279
|
let current = env!("CARGO_PKG_VERSION");
|
|
1856
4280
|
let latest = fetch_latest_cli_version(client).await;
|
|
1857
4281
|
|
|
1858
4282
|
let Some(latest_version) = latest else {
|
|
1859
|
-
|
|
4283
|
+
let payload = serde_json::json!({
|
|
4284
|
+
"ok": false,
|
|
4285
|
+
"checked": false,
|
|
4286
|
+
"currentVersion": current,
|
|
4287
|
+
"message": "Could not check latest version right now."
|
|
4288
|
+
});
|
|
4289
|
+
emit_text_or_json(output, "Could not check latest version right now.", payload)?;
|
|
1860
4290
|
return Ok(());
|
|
1861
4291
|
};
|
|
1862
4292
|
|
|
1863
4293
|
if !is_newer_version(current, &latest_version) {
|
|
1864
|
-
|
|
4294
|
+
let payload = serde_json::json!({
|
|
4295
|
+
"ok": true,
|
|
4296
|
+
"checked": true,
|
|
4297
|
+
"upToDate": true,
|
|
4298
|
+
"currentVersion": current,
|
|
4299
|
+
"latestVersion": latest_version
|
|
4300
|
+
});
|
|
4301
|
+
emit_text_or_json(
|
|
4302
|
+
output,
|
|
4303
|
+
&format!("reallink is up to date ({})", current),
|
|
4304
|
+
payload,
|
|
4305
|
+
)?;
|
|
1865
4306
|
return Ok(());
|
|
1866
4307
|
}
|
|
1867
4308
|
|
|
1868
|
-
println!("Update available: {} -> {}", current, latest_version);
|
|
1869
4309
|
if args.check {
|
|
1870
|
-
|
|
4310
|
+
let payload = serde_json::json!({
|
|
4311
|
+
"ok": true,
|
|
4312
|
+
"checked": true,
|
|
4313
|
+
"upToDate": false,
|
|
4314
|
+
"currentVersion": current,
|
|
4315
|
+
"latestVersion": latest_version,
|
|
4316
|
+
"action": "run `reallink self-update` to install"
|
|
4317
|
+
});
|
|
4318
|
+
emit_text_or_json(
|
|
4319
|
+
output,
|
|
4320
|
+
&format!(
|
|
4321
|
+
"Update available: {} -> {}. Run `reallink self-update`.",
|
|
4322
|
+
current, latest_version
|
|
4323
|
+
),
|
|
4324
|
+
payload,
|
|
4325
|
+
)?;
|
|
1871
4326
|
return Ok(());
|
|
1872
4327
|
}
|
|
1873
4328
|
|
|
@@ -1886,7 +4341,19 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
|
|
|
1886
4341
|
},
|
|
1887
4342
|
"npm self-update",
|
|
1888
4343
|
)?;
|
|
1889
|
-
|
|
4344
|
+
let payload = serde_json::json!({
|
|
4345
|
+
"ok": true,
|
|
4346
|
+
"checked": true,
|
|
4347
|
+
"updated": true,
|
|
4348
|
+
"method": "npm",
|
|
4349
|
+
"currentVersion": current,
|
|
4350
|
+
"latestVersion": latest_version
|
|
4351
|
+
});
|
|
4352
|
+
emit_text_or_json(
|
|
4353
|
+
output,
|
|
4354
|
+
"Updated via npm. Restart your shell if `reallink --version` still shows old version.",
|
|
4355
|
+
payload,
|
|
4356
|
+
)?;
|
|
1890
4357
|
return Ok(());
|
|
1891
4358
|
}
|
|
1892
4359
|
|
|
@@ -1916,33 +4383,176 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
|
|
|
1916
4383
|
)?;
|
|
1917
4384
|
}
|
|
1918
4385
|
|
|
1919
|
-
|
|
4386
|
+
let payload = serde_json::json!({
|
|
4387
|
+
"ok": true,
|
|
4388
|
+
"checked": true,
|
|
4389
|
+
"updated": true,
|
|
4390
|
+
"method": if cfg!(windows) { "powershell-installer" } else { "shell-installer" },
|
|
4391
|
+
"currentVersion": current,
|
|
4392
|
+
"latestVersion": latest_version
|
|
4393
|
+
});
|
|
4394
|
+
emit_text_or_json(
|
|
4395
|
+
output,
|
|
4396
|
+
"Update installed. Verify with `reallink --version`.",
|
|
4397
|
+
payload,
|
|
4398
|
+
)?;
|
|
1920
4399
|
Ok(())
|
|
1921
4400
|
}
|
|
1922
4401
|
|
|
1923
|
-
#[
|
|
1924
|
-
|
|
1925
|
-
|
|
4402
|
+
#[cfg(test)]
|
|
4403
|
+
mod tests {
|
|
4404
|
+
use super::{
|
|
4405
|
+
compose_plugin_archive_url, default_login_scopes, default_login_scopes_with_tools,
|
|
4406
|
+
file_name_component, normalize_public_bucket_base, normalize_sha256_hex, resolve_plugin_from_index,
|
|
4407
|
+
};
|
|
4408
|
+
use super::unreal::{PluginIndexFile, PluginIndexPlugin, PluginIndexVersion};
|
|
4409
|
+
|
|
4410
|
+
#[test]
|
|
4411
|
+
fn normalizes_public_bucket_base() {
|
|
4412
|
+
let normalized = normalize_public_bucket_base("https://real-agent.link/plugins/unreal/")
|
|
4413
|
+
.expect("base URL should normalize");
|
|
4414
|
+
assert_eq!(normalized, "https://real-agent.link/plugins/unreal");
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4417
|
+
#[test]
|
|
4418
|
+
fn composes_archive_url() {
|
|
4419
|
+
let url = compose_plugin_archive_url(
|
|
4420
|
+
"https://real-agent.link/plugins/unreal/",
|
|
4421
|
+
"RealLinkUnreal",
|
|
4422
|
+
"latest",
|
|
4423
|
+
)
|
|
4424
|
+
.expect("archive URL should compose");
|
|
4425
|
+
assert_eq!(
|
|
4426
|
+
url,
|
|
4427
|
+
"https://real-agent.link/plugins/unreal/RealLinkUnreal/latest/RealLinkUnreal.zip"
|
|
4428
|
+
);
|
|
4429
|
+
}
|
|
4430
|
+
|
|
4431
|
+
#[test]
|
|
4432
|
+
fn rejects_plugin_name_with_path_separator() {
|
|
4433
|
+
let result = compose_plugin_archive_url(
|
|
4434
|
+
"https://real-agent.link/plugins/unreal",
|
|
4435
|
+
"RealLink/Unreal",
|
|
4436
|
+
"latest",
|
|
4437
|
+
);
|
|
4438
|
+
assert!(result.is_err());
|
|
4439
|
+
}
|
|
4440
|
+
|
|
4441
|
+
#[test]
|
|
4442
|
+
fn extracts_file_name_component() {
|
|
4443
|
+
let output = file_name_component("D:/Games/MyGame/MyGame.uproject");
|
|
4444
|
+
assert_eq!(output, "MyGame.uproject");
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
#[test]
|
|
4448
|
+
fn normalizes_sha256_hex() {
|
|
4449
|
+
let value = normalize_sha256_hex(
|
|
4450
|
+
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
|
|
4451
|
+
)
|
|
4452
|
+
.expect("sha256 should normalize");
|
|
4453
|
+
assert_eq!(
|
|
4454
|
+
value,
|
|
4455
|
+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
4456
|
+
);
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
#[test]
|
|
4460
|
+
fn resolves_plugin_from_index_with_latest() {
|
|
4461
|
+
let index = PluginIndexFile {
|
|
4462
|
+
schema_version: Some(1),
|
|
4463
|
+
plugins: vec![PluginIndexPlugin {
|
|
4464
|
+
name: "RealLinkUnreal".to_string(),
|
|
4465
|
+
latest: Some("0.1.2".to_string()),
|
|
4466
|
+
versions: vec![PluginIndexVersion {
|
|
4467
|
+
version: "0.1.2".to_string(),
|
|
4468
|
+
archive_url: Some(
|
|
4469
|
+
"https://cdn.example.com/RealLinkUnreal-0.1.2.zip".to_string(),
|
|
4470
|
+
),
|
|
4471
|
+
sha256: Some(
|
|
4472
|
+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
4473
|
+
.to_string(),
|
|
4474
|
+
),
|
|
4475
|
+
}],
|
|
4476
|
+
}],
|
|
4477
|
+
};
|
|
4478
|
+
|
|
4479
|
+
let (version, url, sha) = resolve_plugin_from_index(
|
|
4480
|
+
&index,
|
|
4481
|
+
"RealLinkUnreal",
|
|
4482
|
+
"latest",
|
|
4483
|
+
"https://real-agent.link/plugins/unreal",
|
|
4484
|
+
)
|
|
4485
|
+
.expect("index resolve should succeed");
|
|
4486
|
+
assert_eq!(version, "0.1.2");
|
|
4487
|
+
assert_eq!(url, "https://cdn.example.com/RealLinkUnreal-0.1.2.zip");
|
|
4488
|
+
assert_eq!(
|
|
4489
|
+
sha,
|
|
4490
|
+
Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string())
|
|
4491
|
+
);
|
|
4492
|
+
}
|
|
4493
|
+
|
|
4494
|
+
#[test]
|
|
4495
|
+
fn default_login_scopes_exclude_tool_scopes() {
|
|
4496
|
+
let scopes = default_login_scopes();
|
|
4497
|
+
assert!(scopes.contains(&"core:read".to_string()));
|
|
4498
|
+
assert!(scopes.contains(&"core:write".to_string()));
|
|
4499
|
+
assert!(scopes.contains(&"scheduler:read".to_string()));
|
|
4500
|
+
assert!(scopes.contains(&"scheduler:write".to_string()));
|
|
4501
|
+
assert!(!scopes.iter().any(|value| value.starts_with("tools:")));
|
|
4502
|
+
}
|
|
4503
|
+
|
|
4504
|
+
#[test]
|
|
4505
|
+
fn default_login_scopes_with_tools_include_tool_scopes() {
|
|
4506
|
+
let scopes = default_login_scopes_with_tools();
|
|
4507
|
+
assert!(scopes.contains(&"tools:read".to_string()));
|
|
4508
|
+
assert!(scopes.contains(&"tools:write".to_string()));
|
|
4509
|
+
assert!(scopes.contains(&"tools:run".to_string()));
|
|
4510
|
+
}
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
async fn run_cli(cli: Cli) -> Result<()> {
|
|
1926
4514
|
let client = reqwest::Client::builder()
|
|
1927
4515
|
.user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
|
|
1928
4516
|
.build()?;
|
|
1929
4517
|
|
|
1930
4518
|
if !matches!(&cli.command, Commands::SelfUpdate(_)) {
|
|
1931
4519
|
let allow_update_fetch = matches!(&cli.command, Commands::Login(_));
|
|
1932
|
-
maybe_notify_update(&client, false, allow_update_fetch).await;
|
|
4520
|
+
maybe_notify_update(&client, false, allow_update_fetch, cli.output).await;
|
|
1933
4521
|
}
|
|
1934
4522
|
|
|
1935
4523
|
match cli.command {
|
|
1936
|
-
Commands::Login(args) => login_command(&client, args).await?,
|
|
4524
|
+
Commands::Login(args) => login_command(&client, args, cli.output).await?,
|
|
1937
4525
|
Commands::Whoami(args) => whoami_command(&client, args).await?,
|
|
1938
|
-
Commands::Logout => logout_command(&client).await?,
|
|
1939
|
-
Commands::SelfUpdate(args) => self_update_command(&client, args).await?,
|
|
4526
|
+
Commands::Logout => logout_command(&client, cli.output).await?,
|
|
4527
|
+
Commands::SelfUpdate(args) => self_update_command(&client, args, cli.output).await?,
|
|
4528
|
+
Commands::Org { command } => match command {
|
|
4529
|
+
OrgCommands::List(args) => org_list_command(&client, args).await?,
|
|
4530
|
+
OrgCommands::Create(args) => org_create_command(&client, args).await?,
|
|
4531
|
+
OrgCommands::Get(args) => org_get_command(&client, args).await?,
|
|
4532
|
+
OrgCommands::Update(args) => org_update_command(&client, args).await?,
|
|
4533
|
+
OrgCommands::Delete(args) => org_delete_command(&client, args).await?,
|
|
4534
|
+
OrgCommands::Invites(args) => org_invites_command(&client, args).await?,
|
|
4535
|
+
OrgCommands::Invite(args) => org_invite_command(&client, args).await?,
|
|
4536
|
+
OrgCommands::Members(args) => org_members_command(&client, args).await?,
|
|
4537
|
+
OrgCommands::AddMember(args) => org_add_member_command(&client, args).await?,
|
|
4538
|
+
OrgCommands::UpdateMember(args) => org_update_member_command(&client, args).await?,
|
|
4539
|
+
OrgCommands::RemoveMember(args) => org_remove_member_command(&client, args).await?,
|
|
4540
|
+
},
|
|
4541
|
+
Commands::Link { command } => unreal::dispatch(&client, command).await?,
|
|
1940
4542
|
Commands::Project { command } => match command {
|
|
1941
4543
|
ProjectCommands::List(args) => project_list_command(&client, args).await?,
|
|
1942
4544
|
ProjectCommands::Create(args) => project_create_command(&client, args).await?,
|
|
1943
4545
|
ProjectCommands::Get(args) => project_get_command(&client, args).await?,
|
|
1944
4546
|
ProjectCommands::Update(args) => project_update_command(&client, args).await?,
|
|
1945
4547
|
ProjectCommands::Delete(args) => project_delete_command(&client, args).await?,
|
|
4548
|
+
ProjectCommands::Members(args) => project_members_command(&client, args).await?,
|
|
4549
|
+
ProjectCommands::AddMember(args) => project_add_member_command(&client, args).await?,
|
|
4550
|
+
ProjectCommands::UpdateMember(args) => {
|
|
4551
|
+
project_update_member_command(&client, args).await?
|
|
4552
|
+
}
|
|
4553
|
+
ProjectCommands::RemoveMember(args) => {
|
|
4554
|
+
project_remove_member_command(&client, args).await?
|
|
4555
|
+
}
|
|
1946
4556
|
},
|
|
1947
4557
|
Commands::Token { command } => match command {
|
|
1948
4558
|
TokenCommands::List(args) => token_list_command(&client, args).await?,
|
|
@@ -1952,6 +4562,8 @@ async fn main() -> Result<()> {
|
|
|
1952
4562
|
Commands::File { command } => match command {
|
|
1953
4563
|
FileCommands::List(args) => file_list_command(&client, args).await?,
|
|
1954
4564
|
FileCommands::Get(args) => file_get_command(&client, args).await?,
|
|
4565
|
+
FileCommands::Stat(args) => file_stat_command(&client, args).await?,
|
|
4566
|
+
FileCommands::Download(args) => file_download_command(&client, args).await?,
|
|
1955
4567
|
FileCommands::Upload(args) => file_upload_command(&client, args).await?,
|
|
1956
4568
|
FileCommands::Mkdir(args) => file_mkdir_command(&client, args).await?,
|
|
1957
4569
|
FileCommands::Move(args) => file_move_command(&client, args).await?,
|
|
@@ -1970,3 +4582,27 @@ async fn main() -> Result<()> {
|
|
|
1970
4582
|
|
|
1971
4583
|
Ok(())
|
|
1972
4584
|
}
|
|
4585
|
+
|
|
4586
|
+
#[tokio::main]
|
|
4587
|
+
async fn main() {
|
|
4588
|
+
let cli = Cli::parse();
|
|
4589
|
+
let output = cli.output;
|
|
4590
|
+
if let Err(error) = run_cli(cli).await {
|
|
4591
|
+
match output {
|
|
4592
|
+
OutputFormat::Json => {
|
|
4593
|
+
let payload = serde_json::json!({
|
|
4594
|
+
"ok": false,
|
|
4595
|
+
"error": format!("{:#}", error)
|
|
4596
|
+
});
|
|
4597
|
+
if let Err(emit_error) = print_json(&payload) {
|
|
4598
|
+
eprintln!("Error: {}", error);
|
|
4599
|
+
eprintln!("Failed to emit JSON error payload: {}", emit_error);
|
|
4600
|
+
}
|
|
4601
|
+
}
|
|
4602
|
+
OutputFormat::Text => {
|
|
4603
|
+
eprintln!("Error: {:#}", error);
|
|
4604
|
+
}
|
|
4605
|
+
}
|
|
4606
|
+
std::process::exit(1);
|
|
4607
|
+
}
|
|
4608
|
+
}
|