reallink-cli 0.1.11 → 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 +123 -0
- package/package.json +4 -3
- package/prebuilt/win32-x64/reallink-cli.exe +0 -0
- package/rust/Cargo.lock +121 -1
- package/rust/Cargo.toml +3 -1
- package/rust/src/generated/contract.rs +52 -0
- package/rust/src/generated/mod.rs +1 -0
- package/rust/src/main.rs +2617 -255
- package/rust/src/unreal.rs +266 -0
package/rust/src/main.rs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
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,10 +12,19 @@ use tokio::fs as tokio_fs;
|
|
|
11
12
|
use tokio::io::AsyncWriteExt;
|
|
12
13
|
use tokio::time::sleep;
|
|
13
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
|
+
|
|
14
23
|
const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
|
|
15
24
|
const CONFIG_DIR_ENV: &str = "REALLINK_CONFIG_DIR";
|
|
16
25
|
const SESSION_DIR_NAME: &str = "reallink";
|
|
17
26
|
const SESSION_FILE_NAME: &str = "session.json";
|
|
27
|
+
const UNREAL_LINKS_FILE_NAME: &str = "unreal-links.json";
|
|
18
28
|
const UPDATE_CACHE_FILE_NAME: &str = "update-check.json";
|
|
19
29
|
const VERSION_CHECK_INTERVAL_MS: u128 = 24 * 60 * 60 * 1000;
|
|
20
30
|
const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
|
|
@@ -27,16 +37,38 @@ const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
|
|
|
27
37
|
about = "Reallink CLI"
|
|
28
38
|
)]
|
|
29
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,
|
|
30
48
|
#[command(subcommand)]
|
|
31
49
|
command: Commands,
|
|
32
50
|
}
|
|
33
51
|
|
|
52
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
53
|
+
enum OutputFormat {
|
|
54
|
+
Json,
|
|
55
|
+
Text,
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
#[derive(Subcommand)]
|
|
35
59
|
enum Commands {
|
|
36
60
|
Login(LoginArgs),
|
|
37
61
|
Whoami(BaseArgs),
|
|
38
62
|
Logout,
|
|
39
63
|
SelfUpdate(SelfUpdateArgs),
|
|
64
|
+
Org {
|
|
65
|
+
#[command(subcommand)]
|
|
66
|
+
command: OrgCommands,
|
|
67
|
+
},
|
|
68
|
+
Link {
|
|
69
|
+
#[command(subcommand)]
|
|
70
|
+
command: unreal::LinkCommands,
|
|
71
|
+
},
|
|
40
72
|
Project {
|
|
41
73
|
#[command(subcommand)]
|
|
42
74
|
command: ProjectCommands,
|
|
@@ -93,6 +125,25 @@ enum ProjectCommands {
|
|
|
93
125
|
Get(ProjectGetArgs),
|
|
94
126
|
Update(ProjectUpdateArgs),
|
|
95
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),
|
|
96
147
|
}
|
|
97
148
|
|
|
98
149
|
#[derive(Subcommand)]
|
|
@@ -172,8 +223,8 @@ struct FileStatArgs {
|
|
|
172
223
|
struct FileDownloadArgs {
|
|
173
224
|
#[arg(long)]
|
|
174
225
|
asset_id: String,
|
|
175
|
-
#[arg(long)]
|
|
176
|
-
|
|
226
|
+
#[arg(long = "output")]
|
|
227
|
+
output_path: Option<PathBuf>,
|
|
177
228
|
#[arg(
|
|
178
229
|
long,
|
|
179
230
|
help = "Resume download from existing output file size using HTTP Range"
|
|
@@ -371,6 +422,146 @@ struct ProjectDeleteArgs {
|
|
|
371
422
|
base_url: Option<String>,
|
|
372
423
|
}
|
|
373
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
|
+
|
|
374
565
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
375
566
|
struct SessionConfig {
|
|
376
567
|
base_url: String,
|
|
@@ -513,6 +704,95 @@ struct ProjectResponse {
|
|
|
513
704
|
access_level: Option<String>,
|
|
514
705
|
}
|
|
515
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
|
+
|
|
516
796
|
#[derive(Debug, Serialize, Deserialize)]
|
|
517
797
|
#[serde(rename_all = "camelCase")]
|
|
518
798
|
struct AssetRecord {
|
|
@@ -654,6 +934,19 @@ fn parse_object_from_value(
|
|
|
654
934
|
}
|
|
655
935
|
}
|
|
656
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
|
+
|
|
657
950
|
fn now_epoch_ms() -> u128 {
|
|
658
951
|
SystemTime::now()
|
|
659
952
|
.duration_since(UNIX_EPOCH)
|
|
@@ -682,6 +975,11 @@ fn update_cache_path() -> Result<PathBuf> {
|
|
|
682
975
|
Ok(base.join(SESSION_DIR_NAME).join(UPDATE_CACHE_FILE_NAME))
|
|
683
976
|
}
|
|
684
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
|
+
|
|
685
983
|
fn session_path_display() -> String {
|
|
686
984
|
config_path()
|
|
687
985
|
.map(|path| path.display().to_string())
|
|
@@ -716,77 +1014,332 @@ fn save_session(session: &SessionConfig) -> Result<()> {
|
|
|
716
1014
|
Ok(())
|
|
717
1015
|
}
|
|
718
1016
|
|
|
719
|
-
fn
|
|
720
|
-
let path =
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
"No active session at {}. Run `reallink login` first.",
|
|
724
|
-
path.display()
|
|
725
|
-
)
|
|
726
|
-
})?;
|
|
727
|
-
let session: SessionConfig = serde_json::from_slice(&raw)
|
|
728
|
-
.with_context(|| format!("Invalid session format in {}", path.display()))?;
|
|
729
|
-
Ok(session)
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
fn clear_session() -> Result<bool> {
|
|
733
|
-
let path = config_path()?;
|
|
734
|
-
if path.exists() {
|
|
735
|
-
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
|
736
|
-
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());
|
|
737
1021
|
}
|
|
738
|
-
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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)
|
|
745
1030
|
}
|
|
746
1031
|
|
|
747
|
-
fn
|
|
748
|
-
let path =
|
|
1032
|
+
fn save_unreal_links(config: &UnrealLinksConfig) -> Result<()> {
|
|
1033
|
+
let path = unreal_links_path()?;
|
|
749
1034
|
if let Some(parent) = path.parent() {
|
|
750
1035
|
fs::create_dir_all(parent)
|
|
751
1036
|
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
752
1037
|
}
|
|
753
|
-
let payload = serde_json::to_vec_pretty(
|
|
1038
|
+
let payload = serde_json::to_vec_pretty(config)?;
|
|
754
1039
|
write_atomic(&path, &payload)?;
|
|
755
1040
|
Ok(())
|
|
756
1041
|
}
|
|
757
1042
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
762
1049
|
}
|
|
763
1050
|
}
|
|
764
1051
|
|
|
765
|
-
fn
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
.
|
|
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
|
+
}
|
|
769
1059
|
}
|
|
770
1060
|
|
|
771
|
-
fn
|
|
772
|
-
let
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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()))
|
|
778
1071
|
}
|
|
779
1072
|
|
|
780
|
-
fn
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
+
));
|
|
784
1115
|
}
|
|
1116
|
+
Err(anyhow!(
|
|
1117
|
+
"Path {} is neither a file nor directory",
|
|
1118
|
+
canonical.display()
|
|
1119
|
+
))
|
|
785
1120
|
}
|
|
786
1121
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
+
}
|
|
1332
|
+
|
|
1333
|
+
fn is_newer_version(current: &str, latest: &str) -> bool {
|
|
1334
|
+
match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
|
|
1335
|
+
(Some(current_parts), Some(latest_parts)) => latest_parts > current_parts,
|
|
1336
|
+
_ => false,
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
|
|
1341
|
+
let timeout_ms = std::env::var("REALLINK_UPDATE_CHECK_TIMEOUT_MS")
|
|
1342
|
+
.ok()
|
|
790
1343
|
.and_then(|value| value.parse::<u64>().ok())
|
|
791
1344
|
.filter(|value| *value > 0)
|
|
792
1345
|
.unwrap_or(DEFAULT_VERSION_FETCH_TIMEOUT_MS);
|
|
@@ -810,6 +1363,7 @@ async fn maybe_notify_update(
|
|
|
810
1363
|
client: &reqwest::Client,
|
|
811
1364
|
force_refresh: bool,
|
|
812
1365
|
allow_network_fetch: bool,
|
|
1366
|
+
output: OutputFormat,
|
|
813
1367
|
) {
|
|
814
1368
|
if std::env::var("REALLINK_DISABLE_AUTO_UPDATE_CHECK")
|
|
815
1369
|
.map(|value| value == "1")
|
|
@@ -844,10 +1398,12 @@ async fn maybe_notify_update(
|
|
|
844
1398
|
let current = env!("CARGO_PKG_VERSION");
|
|
845
1399
|
if let Some(latest) = latest_version {
|
|
846
1400
|
if is_newer_version(current, &latest) {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1401
|
+
if output == OutputFormat::Text {
|
|
1402
|
+
eprintln!(
|
|
1403
|
+
"Update available: {} -> {}. Run `reallink self-update`.",
|
|
1404
|
+
current, latest
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
851
1407
|
}
|
|
852
1408
|
}
|
|
853
1409
|
}
|
|
@@ -987,65 +1543,130 @@ async fn existing_session_identity_for_base_url(
|
|
|
987
1543
|
.map(|email| email.to_string())
|
|
988
1544
|
}
|
|
989
1545
|
|
|
990
|
-
|
|
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<()> {
|
|
991
1565
|
let base_url = normalize_base_url(&args.base_url);
|
|
992
1566
|
if !args.force {
|
|
993
1567
|
if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
|
|
994
|
-
|
|
995
|
-
|
|
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
|
+
)?;
|
|
996
1582
|
return Ok(());
|
|
997
1583
|
}
|
|
998
1584
|
}
|
|
999
1585
|
|
|
1000
|
-
let
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
"assets:write".to_string(),
|
|
1006
|
-
"trace:read".to_string(),
|
|
1007
|
-
"trace:write".to_string(),
|
|
1008
|
-
"tools:read".to_string(),
|
|
1009
|
-
"tools:write".to_string(),
|
|
1010
|
-
"tools:run".to_string(),
|
|
1011
|
-
"org:admin".to_string(),
|
|
1012
|
-
"project:admin".to_string(),
|
|
1013
|
-
]
|
|
1586
|
+
let (initial_scope, fallback_scope) = if args.scope.is_empty() {
|
|
1587
|
+
(
|
|
1588
|
+
default_login_scopes_with_tools(),
|
|
1589
|
+
Some(default_login_scopes()),
|
|
1590
|
+
)
|
|
1014
1591
|
} else {
|
|
1015
|
-
args.scope
|
|
1592
|
+
(args.scope, None)
|
|
1016
1593
|
};
|
|
1017
1594
|
|
|
1018
|
-
let
|
|
1595
|
+
let mut selected_scope = initial_scope;
|
|
1596
|
+
let mut device_code_response =
|
|
1019
1597
|
with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
|
|
1020
1598
|
.json(&DeviceCodeRequest {
|
|
1021
1599
|
client_id: args.client_id.clone(),
|
|
1022
|
-
scope,
|
|
1600
|
+
scope: selected_scope.clone(),
|
|
1023
1601
|
})
|
|
1024
1602
|
.send()
|
|
1025
1603
|
.await?;
|
|
1026
1604
|
|
|
1027
1605
|
if !device_code_response.status().is_success() {
|
|
1028
1606
|
let body = read_error_body(device_code_response).await;
|
|
1029
|
-
|
|
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
|
+
}
|
|
1030
1632
|
}
|
|
1031
1633
|
|
|
1032
1634
|
let device_code: DeviceCodeResponse = device_code_response.json().await?;
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
+
}))?;
|
|
1039
1658
|
}
|
|
1040
1659
|
|
|
1041
1660
|
let expires_at = std::time::Instant::now() + Duration::from_secs(device_code.expires_in);
|
|
1042
1661
|
let mut poll_interval = Duration::from_secs(device_code.interval.max(1));
|
|
1043
1662
|
let mut pending_polls = 0u32;
|
|
1044
|
-
|
|
1663
|
+
if output == OutputFormat::Text {
|
|
1664
|
+
println!("Waiting for approval (press Ctrl+C to cancel)");
|
|
1665
|
+
}
|
|
1045
1666
|
|
|
1046
1667
|
loop {
|
|
1047
1668
|
if std::time::Instant::now() >= expires_at {
|
|
1048
|
-
if pending_polls > 0 {
|
|
1669
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
1049
1670
|
println!();
|
|
1050
1671
|
}
|
|
1051
1672
|
return Err(anyhow!("Device code expired before approval"));
|
|
@@ -1065,7 +1686,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1065
1686
|
|
|
1066
1687
|
if token_response.status().is_success() {
|
|
1067
1688
|
let tokens: DeviceTokenSuccess = token_response.json().await?;
|
|
1068
|
-
if pending_polls > 0 {
|
|
1689
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
1069
1690
|
println!();
|
|
1070
1691
|
}
|
|
1071
1692
|
let session = SessionConfig {
|
|
@@ -1076,8 +1697,20 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1076
1697
|
updated_at_epoch_ms: now_epoch_ms(),
|
|
1077
1698
|
};
|
|
1078
1699
|
save_session(&session)?;
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
+
}
|
|
1081
1714
|
return Ok(());
|
|
1082
1715
|
}
|
|
1083
1716
|
|
|
@@ -1090,19 +1723,23 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1090
1723
|
match error_payload.error.as_str() {
|
|
1091
1724
|
"authorization_pending" => {
|
|
1092
1725
|
pending_polls = pending_polls.saturating_add(1);
|
|
1093
|
-
|
|
1094
|
-
|
|
1726
|
+
if output == OutputFormat::Text {
|
|
1727
|
+
print!(".");
|
|
1728
|
+
let _ = io::stdout().flush();
|
|
1729
|
+
}
|
|
1095
1730
|
continue;
|
|
1096
1731
|
}
|
|
1097
1732
|
"slow_down" => {
|
|
1098
1733
|
poll_interval += Duration::from_secs(1);
|
|
1099
1734
|
pending_polls = pending_polls.saturating_add(1);
|
|
1100
|
-
|
|
1101
|
-
|
|
1735
|
+
if output == OutputFormat::Text {
|
|
1736
|
+
print!("+");
|
|
1737
|
+
let _ = io::stdout().flush();
|
|
1738
|
+
}
|
|
1102
1739
|
continue;
|
|
1103
1740
|
}
|
|
1104
1741
|
_ => {
|
|
1105
|
-
if pending_polls > 0 {
|
|
1742
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
1106
1743
|
println!();
|
|
1107
1744
|
}
|
|
1108
1745
|
return Err(anyhow!(
|
|
@@ -1115,13 +1752,17 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1115
1752
|
}
|
|
1116
1753
|
}
|
|
1117
1754
|
|
|
1118
|
-
async fn logout_command(client: &reqwest::Client) -> Result<()> {
|
|
1755
|
+
async fn logout_command(client: &reqwest::Client, output: OutputFormat) -> Result<()> {
|
|
1119
1756
|
let path_display = session_path_display();
|
|
1120
1757
|
let mut session = match load_session() {
|
|
1121
1758
|
Ok(session) => session,
|
|
1122
1759
|
Err(_) => {
|
|
1123
|
-
|
|
1124
|
-
|
|
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)?;
|
|
1125
1766
|
return Ok(());
|
|
1126
1767
|
}
|
|
1127
1768
|
};
|
|
@@ -1137,29 +1778,54 @@ async fn logout_command(client: &reqwest::Client) -> Result<()> {
|
|
|
1137
1778
|
}
|
|
1138
1779
|
Ok(response) => {
|
|
1139
1780
|
let body = read_error_body(response).await;
|
|
1140
|
-
|
|
1781
|
+
if output == OutputFormat::Text {
|
|
1782
|
+
eprintln!("Warning: remote logout request failed: {}", body);
|
|
1783
|
+
}
|
|
1141
1784
|
}
|
|
1142
1785
|
Err(error) => {
|
|
1143
|
-
|
|
1786
|
+
if output == OutputFormat::Text {
|
|
1787
|
+
eprintln!("Warning: remote logout request failed: {}", error);
|
|
1788
|
+
}
|
|
1144
1789
|
}
|
|
1145
1790
|
}
|
|
1146
1791
|
|
|
1147
1792
|
let removed = clear_session()?;
|
|
1148
1793
|
if !removed {
|
|
1149
|
-
|
|
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)?;
|
|
1150
1800
|
return Ok(());
|
|
1151
1801
|
}
|
|
1152
1802
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
+
}
|
|
1161
1827
|
} else {
|
|
1162
|
-
|
|
1828
|
+
print_json(&payload)?;
|
|
1163
1829
|
}
|
|
1164
1830
|
|
|
1165
1831
|
Ok(())
|
|
@@ -1195,177 +1861,1661 @@ async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<
|
|
|
1195
1861
|
Ok(())
|
|
1196
1862
|
}
|
|
1197
1863
|
|
|
1198
|
-
async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
|
|
1199
|
-
let mut session = load_session()?;
|
|
1200
|
-
apply_base_url_override(&mut session, args.base_url);
|
|
1864
|
+
async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
|
|
1865
|
+
let mut session = load_session()?;
|
|
1866
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1867
|
+
|
|
1868
|
+
let scopes = if args.scope.is_empty() {
|
|
1869
|
+
return Err(anyhow!("At least one --scope must be provided"));
|
|
1870
|
+
} else {
|
|
1871
|
+
args.scope
|
|
1872
|
+
};
|
|
1873
|
+
|
|
1874
|
+
let body = serde_json::to_value(CreateApiTokenRequest {
|
|
1875
|
+
name: args.name,
|
|
1876
|
+
org_id: args.org_id,
|
|
1877
|
+
project_id: args.project_id,
|
|
1878
|
+
scopes,
|
|
1879
|
+
expires_in_days: args.expires_in_days,
|
|
1880
|
+
})?;
|
|
1881
|
+
|
|
1882
|
+
let response = authed_request(
|
|
1883
|
+
client,
|
|
1884
|
+
&mut session,
|
|
1885
|
+
Method::POST,
|
|
1886
|
+
"/auth/tokens",
|
|
1887
|
+
Some(body),
|
|
1888
|
+
)
|
|
1889
|
+
.await?;
|
|
1890
|
+
if !response.status().is_success() {
|
|
1891
|
+
let body_text = read_error_body(response).await;
|
|
1892
|
+
return Err(anyhow!("token create failed: {}", body_text));
|
|
1893
|
+
}
|
|
1894
|
+
let payload: CreateApiTokenResponse = response.json().await?;
|
|
1895
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1896
|
+
save_session(&session)?;
|
|
1897
|
+
Ok(())
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -> Result<()> {
|
|
1901
|
+
let mut session = load_session()?;
|
|
1902
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1903
|
+
|
|
1904
|
+
let path = format!("/auth/tokens/{}", args.token_id);
|
|
1905
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
1906
|
+
if !response.status().is_success() {
|
|
1907
|
+
let body = read_error_body(response).await;
|
|
1908
|
+
return Err(anyhow!("token revoke failed: {}", body));
|
|
1909
|
+
}
|
|
1910
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1911
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1912
|
+
save_session(&session)?;
|
|
1913
|
+
Ok(())
|
|
1914
|
+
}
|
|
1915
|
+
|
|
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<()> {
|
|
1997
|
+
let mut session = load_session()?;
|
|
1998
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1999
|
+
|
|
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
|
+
};
|
|
1201
3175
|
|
|
1202
|
-
let
|
|
1203
|
-
|
|
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
|
+
}
|
|
1204
3195
|
} else {
|
|
1205
|
-
|
|
3196
|
+
compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
|
|
1206
3197
|
};
|
|
1207
3198
|
|
|
1208
|
-
let
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
expires_in_days: args.expires_in_days,
|
|
1214
|
-
})?;
|
|
3199
|
+
let expected_sha256 = version_entry
|
|
3200
|
+
.sha256
|
|
3201
|
+
.as_ref()
|
|
3202
|
+
.map(|value| normalize_sha256_hex(value))
|
|
3203
|
+
.transpose()?;
|
|
1215
3204
|
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
)
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
let
|
|
1226
|
-
|
|
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]);
|
|
1227
3221
|
}
|
|
1228
|
-
let
|
|
1229
|
-
|
|
1230
|
-
save_session(&session)?;
|
|
1231
|
-
Ok(())
|
|
3222
|
+
let digest = hasher.finalize();
|
|
3223
|
+
Ok(format!("{:x}", digest))
|
|
1232
3224
|
}
|
|
1233
3225
|
|
|
1234
|
-
async fn
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
+
}
|
|
1237
3236
|
|
|
1238
|
-
let
|
|
1239
|
-
|
|
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))?;
|
|
1240
3241
|
if !response.status().is_success() {
|
|
1241
3242
|
let body = read_error_body(response).await;
|
|
1242
|
-
return Err(anyhow!("
|
|
3243
|
+
return Err(anyhow!("Plugin download failed: {}", body));
|
|
1243
3244
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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()))?;
|
|
1247
3262
|
Ok(())
|
|
1248
3263
|
}
|
|
1249
3264
|
|
|
1250
|
-
|
|
1251
|
-
let
|
|
1252
|
-
|
|
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
|
+
}
|
|
1253
3284
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
3285
|
+
if let Some(parent) = output_path.parent() {
|
|
3286
|
+
fs::create_dir_all(parent)
|
|
3287
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
1257
3288
|
}
|
|
1258
|
-
_ => "/core/projects".to_string(),
|
|
1259
|
-
};
|
|
1260
3289
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
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()))?;
|
|
1265
3294
|
}
|
|
1266
|
-
let payload: ListProjectsResponse = response.json().await?;
|
|
1267
|
-
println!("{}", serde_json::to_string_pretty(&payload.projects)?);
|
|
1268
|
-
save_session(&session)?;
|
|
1269
3295
|
Ok(())
|
|
1270
3296
|
}
|
|
1271
3297
|
|
|
1272
|
-
|
|
1273
|
-
let mut
|
|
1274
|
-
|
|
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();
|
|
1275
3301
|
|
|
1276
|
-
let
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
+
}
|
|
1291
3326
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
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)
|
|
1296
3333
|
}
|
|
1297
3334
|
|
|
1298
|
-
|
|
1299
|
-
let
|
|
1300
|
-
|
|
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
|
+
}
|
|
1301
3349
|
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
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()))?;
|
|
1307
3360
|
}
|
|
1308
|
-
let payload: ProjectResponse = response.json().await?;
|
|
1309
|
-
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
1310
|
-
save_session(&session)?;
|
|
1311
3361
|
Ok(())
|
|
1312
3362
|
}
|
|
1313
3363
|
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
+
}
|
|
1317
3387
|
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
+
));
|
|
1321
3397
|
}
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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"));
|
|
1329
3410
|
}
|
|
1330
3411
|
|
|
1331
|
-
|
|
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 {
|
|
1332
3455
|
return Err(anyhow!(
|
|
1333
|
-
"
|
|
3456
|
+
"Plugin destination already exists: {} (use --force to overwrite)",
|
|
3457
|
+
destination_dir.display()
|
|
1334
3458
|
));
|
|
1335
3459
|
}
|
|
3460
|
+
if destination_dir.exists() && args.force {
|
|
3461
|
+
remove_existing_path(&destination_dir)?;
|
|
3462
|
+
}
|
|
1336
3463
|
|
|
1337
|
-
let
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
)
|
|
1345
|
-
.await?;
|
|
1346
|
-
if !response.status().is_success() {
|
|
1347
|
-
let body = read_error_body(response).await;
|
|
1348
|
-
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)?;
|
|
1349
3471
|
}
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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
|
+
);
|
|
1353
3510
|
Ok(())
|
|
1354
3511
|
}
|
|
1355
3512
|
|
|
1356
|
-
async fn
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
let
|
|
1361
|
-
|
|
1362
|
-
if !response.status().is_success() {
|
|
1363
|
-
let body = read_error_body(response).await;
|
|
1364
|
-
return Err(anyhow!("project delete failed: {}", body));
|
|
1365
|
-
}
|
|
1366
|
-
let payload: serde_json::Value = response.json().await?;
|
|
1367
|
-
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1368
|
-
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)?);
|
|
1369
3519
|
Ok(())
|
|
1370
3520
|
}
|
|
1371
3521
|
|
|
@@ -1605,7 +3755,7 @@ async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs)
|
|
|
1605
3755
|
let fallback_name = base_name_from_virtual_path(&metadata_payload.asset.file_name);
|
|
1606
3756
|
|
|
1607
3757
|
let mut output_path = args
|
|
1608
|
-
.
|
|
3758
|
+
.output_path
|
|
1609
3759
|
.unwrap_or_else(|| PathBuf::from(fallback_name.as_str()));
|
|
1610
3760
|
if output_path.exists() && output_path.is_dir() {
|
|
1611
3761
|
output_path = output_path.join(fallback_name.as_str());
|
|
@@ -2121,23 +4271,58 @@ fn run_and_check_status(mut command: Command, context: &str) -> Result<()> {
|
|
|
2121
4271
|
Ok(())
|
|
2122
4272
|
}
|
|
2123
4273
|
|
|
2124
|
-
async fn self_update_command(
|
|
4274
|
+
async fn self_update_command(
|
|
4275
|
+
client: &reqwest::Client,
|
|
4276
|
+
args: SelfUpdateArgs,
|
|
4277
|
+
output: OutputFormat,
|
|
4278
|
+
) -> Result<()> {
|
|
2125
4279
|
let current = env!("CARGO_PKG_VERSION");
|
|
2126
4280
|
let latest = fetch_latest_cli_version(client).await;
|
|
2127
4281
|
|
|
2128
4282
|
let Some(latest_version) = latest else {
|
|
2129
|
-
|
|
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)?;
|
|
2130
4290
|
return Ok(());
|
|
2131
4291
|
};
|
|
2132
4292
|
|
|
2133
4293
|
if !is_newer_version(current, &latest_version) {
|
|
2134
|
-
|
|
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
|
+
)?;
|
|
2135
4306
|
return Ok(());
|
|
2136
4307
|
}
|
|
2137
4308
|
|
|
2138
|
-
println!("Update available: {} -> {}", current, latest_version);
|
|
2139
4309
|
if args.check {
|
|
2140
|
-
|
|
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
|
+
)?;
|
|
2141
4326
|
return Ok(());
|
|
2142
4327
|
}
|
|
2143
4328
|
|
|
@@ -2156,9 +4341,19 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
|
|
|
2156
4341
|
},
|
|
2157
4342
|
"npm self-update",
|
|
2158
4343
|
)?;
|
|
2159
|
-
|
|
2160
|
-
"
|
|
2161
|
-
|
|
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
|
+
)?;
|
|
2162
4357
|
return Ok(());
|
|
2163
4358
|
}
|
|
2164
4359
|
|
|
@@ -2188,33 +4383,176 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
|
|
|
2188
4383
|
)?;
|
|
2189
4384
|
}
|
|
2190
4385
|
|
|
2191
|
-
|
|
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
|
+
)?;
|
|
2192
4399
|
Ok(())
|
|
2193
4400
|
}
|
|
2194
4401
|
|
|
2195
|
-
#[
|
|
2196
|
-
|
|
2197
|
-
|
|
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<()> {
|
|
2198
4514
|
let client = reqwest::Client::builder()
|
|
2199
4515
|
.user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
|
|
2200
4516
|
.build()?;
|
|
2201
4517
|
|
|
2202
4518
|
if !matches!(&cli.command, Commands::SelfUpdate(_)) {
|
|
2203
4519
|
let allow_update_fetch = matches!(&cli.command, Commands::Login(_));
|
|
2204
|
-
maybe_notify_update(&client, false, allow_update_fetch).await;
|
|
4520
|
+
maybe_notify_update(&client, false, allow_update_fetch, cli.output).await;
|
|
2205
4521
|
}
|
|
2206
4522
|
|
|
2207
4523
|
match cli.command {
|
|
2208
|
-
Commands::Login(args) => login_command(&client, args).await?,
|
|
4524
|
+
Commands::Login(args) => login_command(&client, args, cli.output).await?,
|
|
2209
4525
|
Commands::Whoami(args) => whoami_command(&client, args).await?,
|
|
2210
|
-
Commands::Logout => logout_command(&client).await?,
|
|
2211
|
-
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?,
|
|
2212
4542
|
Commands::Project { command } => match command {
|
|
2213
4543
|
ProjectCommands::List(args) => project_list_command(&client, args).await?,
|
|
2214
4544
|
ProjectCommands::Create(args) => project_create_command(&client, args).await?,
|
|
2215
4545
|
ProjectCommands::Get(args) => project_get_command(&client, args).await?,
|
|
2216
4546
|
ProjectCommands::Update(args) => project_update_command(&client, args).await?,
|
|
2217
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
|
+
}
|
|
2218
4556
|
},
|
|
2219
4557
|
Commands::Token { command } => match command {
|
|
2220
4558
|
TokenCommands::List(args) => token_list_command(&client, args).await?,
|
|
@@ -2244,3 +4582,27 @@ async fn main() -> Result<()> {
|
|
|
2244
4582
|
|
|
2245
4583
|
Ok(())
|
|
2246
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
|
+
}
|