reallink-cli 0.1.10 → 0.1.12

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