reallink-cli 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/rust/src/main.rs CHANGED
@@ -1,9 +1,10 @@
1
1
  use anyhow::{anyhow, Context, Result};
2
- use clap::{ArgAction, Args, Parser, Subcommand};
2
+ use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
3
3
  use reqwest::{Method, StatusCode};
4
4
  use serde::{Deserialize, Serialize};
5
+ use sha2::Digest;
5
6
  use std::fs;
6
- use std::io::{self, Write};
7
+ use std::io::{self, Read, Write};
7
8
  use std::path::{Path, PathBuf};
8
9
  use std::process::Command;
9
10
  use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -11,10 +12,19 @@ use tokio::fs as tokio_fs;
11
12
  use tokio::io::AsyncWriteExt;
12
13
  use tokio::time::sleep;
13
14
 
15
+ mod unreal;
16
+ mod generated;
17
+ use unreal::{
18
+ LinkDoctorArgs, LinkOpenArgs, LinkPathsArgs, LinkPluginInstallArgs, LinkPluginListArgs,
19
+ LinkRemoveArgs, LinkRunArgs, LinkUnrealArgs, LinkUseArgs, PluginIndexFile, UnrealLinkRecord,
20
+ UnrealLinksConfig,
21
+ };
22
+
14
23
  const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
15
24
  const CONFIG_DIR_ENV: &str = "REALLINK_CONFIG_DIR";
16
25
  const SESSION_DIR_NAME: &str = "reallink";
17
26
  const SESSION_FILE_NAME: &str = "session.json";
27
+ const UNREAL_LINKS_FILE_NAME: &str = "unreal-links.json";
18
28
  const UPDATE_CACHE_FILE_NAME: &str = "update-check.json";
19
29
  const VERSION_CHECK_INTERVAL_MS: u128 = 24 * 60 * 60 * 1000;
20
30
  const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
@@ -27,16 +37,38 @@ const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
27
37
  about = "Reallink CLI"
28
38
  )]
29
39
  struct Cli {
40
+ #[arg(
41
+ long = "format",
42
+ global = true,
43
+ value_enum,
44
+ default_value_t = OutputFormat::Json,
45
+ help = "CLI output format (json is agent-friendly)"
46
+ )]
47
+ output: OutputFormat,
30
48
  #[command(subcommand)]
31
49
  command: Commands,
32
50
  }
33
51
 
52
+ #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
53
+ enum OutputFormat {
54
+ Json,
55
+ Text,
56
+ }
57
+
34
58
  #[derive(Subcommand)]
35
59
  enum Commands {
36
60
  Login(LoginArgs),
37
61
  Whoami(BaseArgs),
38
62
  Logout,
39
63
  SelfUpdate(SelfUpdateArgs),
64
+ Org {
65
+ #[command(subcommand)]
66
+ command: OrgCommands,
67
+ },
68
+ Link {
69
+ #[command(subcommand)]
70
+ command: unreal::LinkCommands,
71
+ },
40
72
  Project {
41
73
  #[command(subcommand)]
42
74
  command: ProjectCommands,
@@ -93,6 +125,25 @@ enum ProjectCommands {
93
125
  Get(ProjectGetArgs),
94
126
  Update(ProjectUpdateArgs),
95
127
  Delete(ProjectDeleteArgs),
128
+ Members(ProjectMembersArgs),
129
+ AddMember(ProjectAddMemberArgs),
130
+ UpdateMember(ProjectUpdateMemberArgs),
131
+ RemoveMember(ProjectRemoveMemberArgs),
132
+ }
133
+
134
+ #[derive(Subcommand)]
135
+ enum OrgCommands {
136
+ List(BaseArgs),
137
+ Create(OrgCreateArgs),
138
+ Get(OrgGetArgs),
139
+ Update(OrgUpdateArgs),
140
+ Delete(OrgDeleteArgs),
141
+ Invites(OrgInvitesArgs),
142
+ Invite(OrgInviteArgs),
143
+ Members(OrgMembersArgs),
144
+ AddMember(OrgAddMemberArgs),
145
+ UpdateMember(OrgUpdateMemberArgs),
146
+ RemoveMember(OrgRemoveMemberArgs),
96
147
  }
97
148
 
98
149
  #[derive(Subcommand)]
@@ -172,8 +223,8 @@ struct FileStatArgs {
172
223
  struct FileDownloadArgs {
173
224
  #[arg(long)]
174
225
  asset_id: String,
175
- #[arg(long)]
176
- output: Option<PathBuf>,
226
+ #[arg(long = "output")]
227
+ output_path: Option<PathBuf>,
177
228
  #[arg(
178
229
  long,
179
230
  help = "Resume download from existing output file size using HTTP Range"
@@ -371,6 +422,146 @@ struct ProjectDeleteArgs {
371
422
  base_url: Option<String>,
372
423
  }
373
424
 
425
+ #[derive(Args)]
426
+ struct OrgCreateArgs {
427
+ #[arg(long)]
428
+ name: String,
429
+ #[arg(long)]
430
+ base_url: Option<String>,
431
+ }
432
+
433
+ #[derive(Args)]
434
+ struct OrgGetArgs {
435
+ #[arg(long)]
436
+ org_id: String,
437
+ #[arg(long)]
438
+ base_url: Option<String>,
439
+ }
440
+
441
+ #[derive(Args)]
442
+ struct OrgUpdateArgs {
443
+ #[arg(long)]
444
+ org_id: String,
445
+ #[arg(long)]
446
+ name: String,
447
+ #[arg(long)]
448
+ base_url: Option<String>,
449
+ }
450
+
451
+ #[derive(Args)]
452
+ struct OrgDeleteArgs {
453
+ #[arg(long)]
454
+ org_id: String,
455
+ #[arg(long)]
456
+ base_url: Option<String>,
457
+ }
458
+
459
+ #[derive(Args)]
460
+ struct OrgMembersArgs {
461
+ #[arg(long)]
462
+ org_id: String,
463
+ #[arg(long)]
464
+ base_url: Option<String>,
465
+ }
466
+
467
+ #[derive(Args)]
468
+ struct OrgAddMemberArgs {
469
+ #[arg(long)]
470
+ org_id: String,
471
+ #[arg(long)]
472
+ email: String,
473
+ #[arg(long, default_value = "member")]
474
+ role: String,
475
+ #[arg(long)]
476
+ base_url: Option<String>,
477
+ }
478
+
479
+ #[derive(Args)]
480
+ struct OrgUpdateMemberArgs {
481
+ #[arg(long)]
482
+ org_id: String,
483
+ #[arg(long)]
484
+ user_id: String,
485
+ #[arg(long)]
486
+ role: String,
487
+ #[arg(long)]
488
+ base_url: Option<String>,
489
+ }
490
+
491
+ #[derive(Args)]
492
+ struct OrgRemoveMemberArgs {
493
+ #[arg(long)]
494
+ org_id: String,
495
+ #[arg(long)]
496
+ user_id: String,
497
+ #[arg(long)]
498
+ base_url: Option<String>,
499
+ }
500
+
501
+ #[derive(Args)]
502
+ struct OrgInvitesArgs {
503
+ #[arg(long)]
504
+ org_id: String,
505
+ #[arg(long)]
506
+ base_url: Option<String>,
507
+ }
508
+
509
+ #[derive(Args)]
510
+ struct OrgInviteArgs {
511
+ #[arg(long)]
512
+ org_id: String,
513
+ #[arg(long)]
514
+ email: String,
515
+ #[arg(long, default_value = "member")]
516
+ role: String,
517
+ #[arg(long, default_value_t = 7)]
518
+ expires_in_days: u32,
519
+ #[arg(long)]
520
+ base_url: Option<String>,
521
+ }
522
+
523
+ #[derive(Args)]
524
+ struct ProjectMembersArgs {
525
+ #[arg(long)]
526
+ project_id: String,
527
+ #[arg(long)]
528
+ base_url: Option<String>,
529
+ }
530
+
531
+ #[derive(Args)]
532
+ struct ProjectAddMemberArgs {
533
+ #[arg(long)]
534
+ project_id: String,
535
+ #[arg(long)]
536
+ email: String,
537
+ #[arg(long, default_value = "viewer")]
538
+ role: String,
539
+ #[arg(long)]
540
+ base_url: Option<String>,
541
+ }
542
+
543
+ #[derive(Args)]
544
+ struct ProjectUpdateMemberArgs {
545
+ #[arg(long)]
546
+ project_id: String,
547
+ #[arg(long)]
548
+ user_id: String,
549
+ #[arg(long)]
550
+ role: String,
551
+ #[arg(long)]
552
+ base_url: Option<String>,
553
+ }
554
+
555
+ #[derive(Args)]
556
+ struct ProjectRemoveMemberArgs {
557
+ #[arg(long)]
558
+ project_id: String,
559
+ #[arg(long)]
560
+ user_id: String,
561
+ #[arg(long)]
562
+ base_url: Option<String>,
563
+ }
564
+
374
565
  #[derive(Debug, Serialize, Deserialize, Clone)]
375
566
  struct SessionConfig {
376
567
  base_url: String,
@@ -513,6 +704,95 @@ struct ProjectResponse {
513
704
  access_level: Option<String>,
514
705
  }
515
706
 
707
+ #[derive(Debug, Serialize, Deserialize)]
708
+ #[serde(rename_all = "camelCase")]
709
+ struct OrgRecord {
710
+ id: String,
711
+ slug: String,
712
+ name: String,
713
+ owner_user_id: Option<String>,
714
+ created_at: Option<String>,
715
+ }
716
+
717
+ #[derive(Debug, Serialize, Deserialize)]
718
+ struct ListOrgsResponse {
719
+ orgs: Vec<OrgRecord>,
720
+ }
721
+
722
+ #[derive(Debug, Serialize, Deserialize)]
723
+ #[serde(rename_all = "camelCase")]
724
+ struct OrgResponse {
725
+ org: OrgRecord,
726
+ role: Option<String>,
727
+ }
728
+
729
+ #[derive(Debug, Serialize, Deserialize)]
730
+ #[serde(rename_all = "camelCase")]
731
+ struct OrgMemberRecord {
732
+ org_id: String,
733
+ user_id: String,
734
+ role: String,
735
+ created_at: Option<String>,
736
+ }
737
+
738
+ #[derive(Debug, Serialize, Deserialize)]
739
+ struct ListOrgMembersResponse {
740
+ members: Vec<OrgMemberRecord>,
741
+ }
742
+
743
+ #[derive(Debug, Serialize, Deserialize)]
744
+ struct OrgMemberResponse {
745
+ member: OrgMemberRecord,
746
+ }
747
+
748
+ #[derive(Debug, Serialize, Deserialize)]
749
+ #[serde(rename_all = "camelCase")]
750
+ struct OrgInviteRecordApi {
751
+ id: String,
752
+ org_id: String,
753
+ email: String,
754
+ role: String,
755
+ invited_by_user_id: Option<String>,
756
+ status: String,
757
+ expires_at: String,
758
+ accepted_by_user_id: Option<String>,
759
+ accepted_at: Option<String>,
760
+ created_at: Option<String>,
761
+ updated_at: Option<String>,
762
+ url: Option<String>,
763
+ }
764
+
765
+ #[derive(Debug, Serialize, Deserialize)]
766
+ struct ListOrgInvitesResponse {
767
+ invites: Vec<OrgInviteRecordApi>,
768
+ }
769
+
770
+ #[derive(Debug, Serialize, Deserialize)]
771
+ struct OrgInviteResponse {
772
+ invite: OrgInviteRecordApi,
773
+ }
774
+
775
+ #[derive(Debug, Serialize, Deserialize)]
776
+ #[serde(rename_all = "camelCase")]
777
+ struct ProjectMemberRecordApi {
778
+ project_id: String,
779
+ user_id: String,
780
+ role: String,
781
+ invited_by_user_id: Option<String>,
782
+ created_at: Option<String>,
783
+ updated_at: Option<String>,
784
+ }
785
+
786
+ #[derive(Debug, Serialize, Deserialize)]
787
+ struct ListProjectMembersResponse {
788
+ members: Vec<ProjectMemberRecordApi>,
789
+ }
790
+
791
+ #[derive(Debug, Serialize, Deserialize)]
792
+ struct ProjectMemberResponse {
793
+ member: ProjectMemberRecordApi,
794
+ }
795
+
516
796
  #[derive(Debug, Serialize, Deserialize)]
517
797
  #[serde(rename_all = "camelCase")]
518
798
  struct AssetRecord {
@@ -654,6 +934,19 @@ fn parse_object_from_value(
654
934
  }
655
935
  }
656
936
 
937
+ fn print_json(value: &serde_json::Value) -> Result<()> {
938
+ println!("{}", serde_json::to_string_pretty(value)?);
939
+ Ok(())
940
+ }
941
+
942
+ fn emit_text_or_json(output: OutputFormat, text: &str, value: serde_json::Value) -> Result<()> {
943
+ match output {
944
+ OutputFormat::Text => println!("{}", text),
945
+ OutputFormat::Json => print_json(&value)?,
946
+ }
947
+ Ok(())
948
+ }
949
+
657
950
  fn now_epoch_ms() -> u128 {
658
951
  SystemTime::now()
659
952
  .duration_since(UNIX_EPOCH)
@@ -682,6 +975,11 @@ fn update_cache_path() -> Result<PathBuf> {
682
975
  Ok(base.join(SESSION_DIR_NAME).join(UPDATE_CACHE_FILE_NAME))
683
976
  }
684
977
 
978
+ fn unreal_links_path() -> Result<PathBuf> {
979
+ let base = resolve_config_root()?;
980
+ Ok(base.join(SESSION_DIR_NAME).join(UNREAL_LINKS_FILE_NAME))
981
+ }
982
+
685
983
  fn session_path_display() -> String {
686
984
  config_path()
687
985
  .map(|path| path.display().to_string())
@@ -716,77 +1014,332 @@ fn save_session(session: &SessionConfig) -> Result<()> {
716
1014
  Ok(())
717
1015
  }
718
1016
 
719
- fn load_session() -> Result<SessionConfig> {
720
- let path = config_path()?;
721
- let raw = fs::read(&path).with_context(|| {
722
- format!(
723
- "No active session at {}. Run `reallink login` first.",
724
- path.display()
725
- )
726
- })?;
727
- let session: SessionConfig = serde_json::from_slice(&raw)
728
- .with_context(|| format!("Invalid session format in {}", path.display()))?;
729
- Ok(session)
730
- }
731
-
732
- fn clear_session() -> Result<bool> {
733
- let path = config_path()?;
734
- if path.exists() {
735
- fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
736
- return Ok(true);
1017
+ fn load_unreal_links() -> Result<UnrealLinksConfig> {
1018
+ let path = unreal_links_path()?;
1019
+ if !path.exists() {
1020
+ return Ok(UnrealLinksConfig::default());
737
1021
  }
738
- Ok(false)
739
- }
740
-
741
- fn load_update_cache() -> Option<UpdateCheckCache> {
742
- let path = update_cache_path().ok()?;
743
- let raw = fs::read(path).ok()?;
744
- 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)
745
1030
  }
746
1031
 
747
- fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
748
- let path = update_cache_path()?;
1032
+ fn save_unreal_links(config: &UnrealLinksConfig) -> Result<()> {
1033
+ let path = unreal_links_path()?;
749
1034
  if let Some(parent) = path.parent() {
750
1035
  fs::create_dir_all(parent)
751
1036
  .with_context(|| format!("Failed to create {}", parent.display()))?;
752
1037
  }
753
- let payload = serde_json::to_vec_pretty(cache)?;
1038
+ let payload = serde_json::to_vec_pretty(config)?;
754
1039
  write_atomic(&path, &payload)?;
755
1040
  Ok(())
756
1041
  }
757
1042
 
758
- async fn read_error_body(response: reqwest::Response) -> String {
759
- match response.text().await {
760
- Ok(text) if !text.trim().is_empty() => text,
761
- _ => "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
762
1049
  }
763
1050
  }
764
1051
 
765
- fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
766
- request
767
- .header("x-reallink-client", "cli")
768
- .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
+ }
769
1059
  }
770
1060
 
771
- fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
772
- let core = version.trim().split('-').next()?;
773
- let mut parts = core.split('.');
774
- let major = parts.next()?.parse::<u64>().ok()?;
775
- let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
776
- let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
777
- Some((major, minor, patch))
1061
+ fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf> {
1062
+ let absolute = if path.is_absolute() {
1063
+ path.to_path_buf()
1064
+ } else {
1065
+ std::env::current_dir()
1066
+ .with_context(|| "Failed to resolve current working directory")?
1067
+ .join(path)
1068
+ };
1069
+ fs::canonicalize(&absolute)
1070
+ .with_context(|| format!("Failed to resolve {} path {}", label, absolute.display()))
778
1071
  }
779
1072
 
780
- fn is_newer_version(current: &str, latest: &str) -> bool {
781
- match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
782
- (Some(current_parts), Some(latest_parts)) => latest_parts > current_parts,
783
- _ => false,
1073
+ fn resolve_uproject_path(path: &Path) -> Result<PathBuf> {
1074
+ let canonical = canonicalize_existing_path(path, "uproject")?;
1075
+ if canonical.is_file() {
1076
+ let is_uproject = canonical
1077
+ .extension()
1078
+ .and_then(|value| value.to_str())
1079
+ .map(|value| value.eq_ignore_ascii_case("uproject"))
1080
+ .unwrap_or(false);
1081
+ if !is_uproject {
1082
+ return Err(anyhow!(
1083
+ "Expected a .uproject file, got {}",
1084
+ canonical.display()
1085
+ ));
1086
+ }
1087
+ return Ok(canonical);
1088
+ }
1089
+ if canonical.is_dir() {
1090
+ let mut entries: Vec<PathBuf> = fs::read_dir(&canonical)
1091
+ .with_context(|| format!("Failed to read directory {}", canonical.display()))?
1092
+ .filter_map(|entry| entry.ok().map(|item| item.path()))
1093
+ .filter(|entry| {
1094
+ entry
1095
+ .extension()
1096
+ .and_then(|value| value.to_str())
1097
+ .map(|value| value.eq_ignore_ascii_case("uproject"))
1098
+ .unwrap_or(false)
1099
+ })
1100
+ .collect();
1101
+ entries.sort();
1102
+ if entries.len() == 1 {
1103
+ return Ok(entries.remove(0));
1104
+ }
1105
+ if entries.is_empty() {
1106
+ return Err(anyhow!(
1107
+ "No .uproject file found in {}",
1108
+ canonical.display()
1109
+ ));
1110
+ }
1111
+ return Err(anyhow!(
1112
+ "Multiple .uproject files found in {}; pass --uproject with explicit file path",
1113
+ canonical.display()
1114
+ ));
784
1115
  }
1116
+ Err(anyhow!(
1117
+ "Path {} is neither a file nor directory",
1118
+ canonical.display()
1119
+ ))
785
1120
  }
786
1121
 
787
- async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
788
- let timeout_ms = std::env::var("REALLINK_UPDATE_CHECK_TIMEOUT_MS")
789
- .ok()
1122
+ fn candidate_editor_paths_from_engine_root(engine_root: &Path) -> Vec<PathBuf> {
1123
+ vec![
1124
+ engine_root
1125
+ .join("Engine")
1126
+ .join("Binaries")
1127
+ .join("Win64")
1128
+ .join("UnrealEditor.exe"),
1129
+ engine_root
1130
+ .join("Engine")
1131
+ .join("Binaries")
1132
+ .join("Win64")
1133
+ .join("UE4Editor.exe"),
1134
+ engine_root
1135
+ .join("Engine")
1136
+ .join("Binaries")
1137
+ .join("Linux")
1138
+ .join("UnrealEditor"),
1139
+ engine_root
1140
+ .join("Engine")
1141
+ .join("Binaries")
1142
+ .join("Linux")
1143
+ .join("UE4Editor"),
1144
+ engine_root
1145
+ .join("Engine")
1146
+ .join("Binaries")
1147
+ .join("Mac")
1148
+ .join("UnrealEditor.app")
1149
+ .join("Contents")
1150
+ .join("MacOS")
1151
+ .join("UnrealEditor"),
1152
+ engine_root
1153
+ .join("Engine")
1154
+ .join("Binaries")
1155
+ .join("Mac")
1156
+ .join("UE4Editor.app")
1157
+ .join("Contents")
1158
+ .join("MacOS")
1159
+ .join("UE4Editor"),
1160
+ ]
1161
+ }
1162
+
1163
+ fn resolve_editor_from_engine_root(engine_root: &Path) -> Result<PathBuf> {
1164
+ let root = canonicalize_existing_path(engine_root, "engine root")?;
1165
+ if !root.is_dir() {
1166
+ return Err(anyhow!(
1167
+ "Engine root must be a directory: {}",
1168
+ root.display()
1169
+ ));
1170
+ }
1171
+
1172
+ for candidate in candidate_editor_paths_from_engine_root(&root) {
1173
+ if candidate.exists() {
1174
+ return Ok(candidate);
1175
+ }
1176
+ }
1177
+
1178
+ Err(anyhow!(
1179
+ "Could not find Unreal editor binary under {}. Pass --editor explicitly.",
1180
+ root.display()
1181
+ ))
1182
+ }
1183
+
1184
+ fn detect_engine_root_from_editor(editor_path: &Path) -> Option<PathBuf> {
1185
+ let parts: Vec<String> = editor_path
1186
+ .components()
1187
+ .map(|component| component.as_os_str().to_string_lossy().to_string())
1188
+ .collect();
1189
+ let mut engine_index = None;
1190
+ for (index, value) in parts.iter().enumerate() {
1191
+ if value.eq_ignore_ascii_case("Engine") {
1192
+ engine_index = Some(index);
1193
+ break;
1194
+ }
1195
+ }
1196
+ let index = engine_index?;
1197
+ let mut root = PathBuf::new();
1198
+ for segment in &parts[..index] {
1199
+ root.push(segment);
1200
+ }
1201
+ if root.as_os_str().is_empty() {
1202
+ None
1203
+ } else {
1204
+ Some(root)
1205
+ }
1206
+ }
1207
+
1208
+ fn resolve_editor_and_engine(
1209
+ explicit_editor: Option<PathBuf>,
1210
+ explicit_engine_root: Option<PathBuf>,
1211
+ ) -> Result<(PathBuf, Option<PathBuf>)> {
1212
+ if let Some(editor) = explicit_editor {
1213
+ let editor_path = canonicalize_existing_path(&editor, "editor")?;
1214
+ if !editor_path.is_file() {
1215
+ return Err(anyhow!(
1216
+ "Editor path must be a file: {}",
1217
+ editor_path.display()
1218
+ ));
1219
+ }
1220
+ let engine_root = if let Some(root) = explicit_engine_root {
1221
+ Some(canonicalize_existing_path(&root, "engine root")?)
1222
+ } else {
1223
+ detect_engine_root_from_editor(&editor_path)
1224
+ };
1225
+ return Ok((editor_path, engine_root));
1226
+ }
1227
+
1228
+ if let Some(engine_root) = explicit_engine_root {
1229
+ let root = canonicalize_existing_path(&engine_root, "engine root")?;
1230
+ let editor_path = resolve_editor_from_engine_root(&root)?;
1231
+ return Ok((editor_path, Some(root)));
1232
+ }
1233
+
1234
+ if let Ok(editor_env) = std::env::var("REALLINK_UNREAL_EDITOR") {
1235
+ let trimmed = editor_env.trim();
1236
+ if !trimmed.is_empty() {
1237
+ let editor_path = canonicalize_existing_path(Path::new(trimmed), "editor")?;
1238
+ let engine_root = detect_engine_root_from_editor(&editor_path);
1239
+ return Ok((editor_path, engine_root));
1240
+ }
1241
+ }
1242
+ if let Ok(editor_env) = std::env::var("UE_EDITOR_PATH") {
1243
+ let trimmed = editor_env.trim();
1244
+ if !trimmed.is_empty() {
1245
+ let editor_path = canonicalize_existing_path(Path::new(trimmed), "editor")?;
1246
+ let engine_root = detect_engine_root_from_editor(&editor_path);
1247
+ return Ok((editor_path, engine_root));
1248
+ }
1249
+ }
1250
+ if let Ok(engine_env) = std::env::var("REALLINK_UNREAL_ENGINE_ROOT") {
1251
+ let trimmed = engine_env.trim();
1252
+ if !trimmed.is_empty() {
1253
+ let root = canonicalize_existing_path(Path::new(trimmed), "engine root")?;
1254
+ let editor_path = resolve_editor_from_engine_root(&root)?;
1255
+ return Ok((editor_path, Some(root)));
1256
+ }
1257
+ }
1258
+ if let Ok(engine_env) = std::env::var("UE_ENGINE_ROOT") {
1259
+ let trimmed = engine_env.trim();
1260
+ if !trimmed.is_empty() {
1261
+ let root = canonicalize_existing_path(Path::new(trimmed), "engine root")?;
1262
+ let editor_path = resolve_editor_from_engine_root(&root)?;
1263
+ return Ok((editor_path, Some(root)));
1264
+ }
1265
+ }
1266
+
1267
+ Err(anyhow!(
1268
+ "Unable to resolve Unreal editor. Pass --editor or --engine-root, or set REALLINK_UNREAL_EDITOR/REALLINK_UNREAL_ENGINE_ROOT."
1269
+ ))
1270
+ }
1271
+
1272
+ fn load_session() -> Result<SessionConfig> {
1273
+ let path = config_path()?;
1274
+ let raw = fs::read(&path).with_context(|| {
1275
+ format!(
1276
+ "No active session at {}. Run `reallink login` first.",
1277
+ path.display()
1278
+ )
1279
+ })?;
1280
+ let session: SessionConfig = serde_json::from_slice(&raw)
1281
+ .with_context(|| format!("Invalid session format in {}", path.display()))?;
1282
+ Ok(session)
1283
+ }
1284
+
1285
+ fn clear_session() -> Result<bool> {
1286
+ let path = config_path()?;
1287
+ if path.exists() {
1288
+ fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
1289
+ return Ok(true);
1290
+ }
1291
+ Ok(false)
1292
+ }
1293
+
1294
+ fn load_update_cache() -> Option<UpdateCheckCache> {
1295
+ let path = update_cache_path().ok()?;
1296
+ let raw = fs::read(path).ok()?;
1297
+ serde_json::from_slice(&raw).ok()
1298
+ }
1299
+
1300
+ fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
1301
+ let path = update_cache_path()?;
1302
+ if let Some(parent) = path.parent() {
1303
+ fs::create_dir_all(parent)
1304
+ .with_context(|| format!("Failed to create {}", parent.display()))?;
1305
+ }
1306
+ let payload = serde_json::to_vec_pretty(cache)?;
1307
+ write_atomic(&path, &payload)?;
1308
+ Ok(())
1309
+ }
1310
+
1311
+ async fn read_error_body(response: reqwest::Response) -> String {
1312
+ match response.text().await {
1313
+ Ok(text) if !text.trim().is_empty() => text,
1314
+ _ => "No response body".to_string(),
1315
+ }
1316
+ }
1317
+
1318
+ fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
1319
+ request
1320
+ .header("x-reallink-client", "cli")
1321
+ .header("x-reallink-cli-version", env!("CARGO_PKG_VERSION"))
1322
+ }
1323
+
1324
+ fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
1325
+ let core = version.trim().split('-').next()?;
1326
+ let mut parts = core.split('.');
1327
+ let major = parts.next()?.parse::<u64>().ok()?;
1328
+ let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
1329
+ let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
1330
+ Some((major, minor, patch))
1331
+ }
1332
+
1333
+ fn is_newer_version(current: &str, latest: &str) -> bool {
1334
+ match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
1335
+ (Some(current_parts), Some(latest_parts)) => latest_parts > current_parts,
1336
+ _ => false,
1337
+ }
1338
+ }
1339
+
1340
+ async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
1341
+ let timeout_ms = std::env::var("REALLINK_UPDATE_CHECK_TIMEOUT_MS")
1342
+ .ok()
790
1343
  .and_then(|value| value.parse::<u64>().ok())
791
1344
  .filter(|value| *value > 0)
792
1345
  .unwrap_or(DEFAULT_VERSION_FETCH_TIMEOUT_MS);
@@ -810,6 +1363,7 @@ async fn maybe_notify_update(
810
1363
  client: &reqwest::Client,
811
1364
  force_refresh: bool,
812
1365
  allow_network_fetch: bool,
1366
+ output: OutputFormat,
813
1367
  ) {
814
1368
  if std::env::var("REALLINK_DISABLE_AUTO_UPDATE_CHECK")
815
1369
  .map(|value| value == "1")
@@ -844,10 +1398,12 @@ async fn maybe_notify_update(
844
1398
  let current = env!("CARGO_PKG_VERSION");
845
1399
  if let Some(latest) = latest_version {
846
1400
  if is_newer_version(current, &latest) {
847
- eprintln!(
848
- "Update available: {} -> {}. Run `reallink self-update`.",
849
- current, latest
850
- );
1401
+ if output == OutputFormat::Text {
1402
+ eprintln!(
1403
+ "Update available: {} -> {}. Run `reallink self-update`.",
1404
+ current, latest
1405
+ );
1406
+ }
851
1407
  }
852
1408
  }
853
1409
  }
@@ -987,65 +1543,130 @@ async fn existing_session_identity_for_base_url(
987
1543
  .map(|email| email.to_string())
988
1544
  }
989
1545
 
990
- 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<()> {
991
1565
  let base_url = normalize_base_url(&args.base_url);
992
1566
  if !args.force {
993
1567
  if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
994
- println!("Already logged in as {} on {}.", email, base_url);
995
- 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
+ )?;
996
1582
  return Ok(());
997
1583
  }
998
1584
  }
999
1585
 
1000
- let scope = if args.scope.is_empty() {
1001
- vec![
1002
- "core:read".to_string(),
1003
- "core:write".to_string(),
1004
- "assets:read".to_string(),
1005
- "assets:write".to_string(),
1006
- "trace:read".to_string(),
1007
- "trace:write".to_string(),
1008
- "tools:read".to_string(),
1009
- "tools:write".to_string(),
1010
- "tools:run".to_string(),
1011
- "org:admin".to_string(),
1012
- "project:admin".to_string(),
1013
- ]
1586
+ let (initial_scope, fallback_scope) = if args.scope.is_empty() {
1587
+ (
1588
+ default_login_scopes_with_tools(),
1589
+ Some(default_login_scopes()),
1590
+ )
1014
1591
  } else {
1015
- args.scope
1592
+ (args.scope, None)
1016
1593
  };
1017
1594
 
1018
- let device_code_response =
1595
+ let mut selected_scope = initial_scope;
1596
+ let mut device_code_response =
1019
1597
  with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
1020
1598
  .json(&DeviceCodeRequest {
1021
1599
  client_id: args.client_id.clone(),
1022
- scope,
1600
+ scope: selected_scope.clone(),
1023
1601
  })
1024
1602
  .send()
1025
1603
  .await?;
1026
1604
 
1027
1605
  if !device_code_response.status().is_success() {
1028
1606
  let body = read_error_body(device_code_response).await;
1029
- 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
+ }
1030
1632
  }
1031
1633
 
1032
1634
  let device_code: DeviceCodeResponse = device_code_response.json().await?;
1033
- println!("Open this URL in your browser and approve the login:");
1034
- println!("{}", device_code.verification_uri_complete);
1035
- println!("User code: {}", device_code.user_code);
1036
- match webbrowser::open(&device_code.verification_uri_complete) {
1037
- Ok(_) => println!("Browser opened for device approval."),
1038
- 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
+ }))?;
1039
1658
  }
1040
1659
 
1041
1660
  let expires_at = std::time::Instant::now() + Duration::from_secs(device_code.expires_in);
1042
1661
  let mut poll_interval = Duration::from_secs(device_code.interval.max(1));
1043
1662
  let mut pending_polls = 0u32;
1044
- 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
+ }
1045
1666
 
1046
1667
  loop {
1047
1668
  if std::time::Instant::now() >= expires_at {
1048
- if pending_polls > 0 {
1669
+ if output == OutputFormat::Text && pending_polls > 0 {
1049
1670
  println!();
1050
1671
  }
1051
1672
  return Err(anyhow!("Device code expired before approval"));
@@ -1065,7 +1686,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
1065
1686
 
1066
1687
  if token_response.status().is_success() {
1067
1688
  let tokens: DeviceTokenSuccess = token_response.json().await?;
1068
- if pending_polls > 0 {
1689
+ if output == OutputFormat::Text && pending_polls > 0 {
1069
1690
  println!();
1070
1691
  }
1071
1692
  let session = SessionConfig {
@@ -1076,8 +1697,20 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
1076
1697
  updated_at_epoch_ms: now_epoch_ms(),
1077
1698
  };
1078
1699
  save_session(&session)?;
1079
- println!("Login successful.");
1080
- 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
+ }
1081
1714
  return Ok(());
1082
1715
  }
1083
1716
 
@@ -1090,19 +1723,23 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
1090
1723
  match error_payload.error.as_str() {
1091
1724
  "authorization_pending" => {
1092
1725
  pending_polls = pending_polls.saturating_add(1);
1093
- print!(".");
1094
- let _ = io::stdout().flush();
1726
+ if output == OutputFormat::Text {
1727
+ print!(".");
1728
+ let _ = io::stdout().flush();
1729
+ }
1095
1730
  continue;
1096
1731
  }
1097
1732
  "slow_down" => {
1098
1733
  poll_interval += Duration::from_secs(1);
1099
1734
  pending_polls = pending_polls.saturating_add(1);
1100
- print!("+");
1101
- let _ = io::stdout().flush();
1735
+ if output == OutputFormat::Text {
1736
+ print!("+");
1737
+ let _ = io::stdout().flush();
1738
+ }
1102
1739
  continue;
1103
1740
  }
1104
1741
  _ => {
1105
- if pending_polls > 0 {
1742
+ if output == OutputFormat::Text && pending_polls > 0 {
1106
1743
  println!();
1107
1744
  }
1108
1745
  return Err(anyhow!(
@@ -1115,13 +1752,17 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
1115
1752
  }
1116
1753
  }
1117
1754
 
1118
- async fn logout_command(client: &reqwest::Client) -> Result<()> {
1755
+ async fn logout_command(client: &reqwest::Client, output: OutputFormat) -> Result<()> {
1119
1756
  let path_display = session_path_display();
1120
1757
  let mut session = match load_session() {
1121
1758
  Ok(session) => session,
1122
1759
  Err(_) => {
1123
- println!("No local session found at {}.", path_display);
1124
- 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)?;
1125
1766
  return Ok(());
1126
1767
  }
1127
1768
  };
@@ -1137,29 +1778,54 @@ async fn logout_command(client: &reqwest::Client) -> Result<()> {
1137
1778
  }
1138
1779
  Ok(response) => {
1139
1780
  let body = read_error_body(response).await;
1140
- eprintln!("Warning: remote logout request failed: {}", body);
1781
+ if output == OutputFormat::Text {
1782
+ eprintln!("Warning: remote logout request failed: {}", body);
1783
+ }
1141
1784
  }
1142
1785
  Err(error) => {
1143
- eprintln!("Warning: remote logout request failed: {}", error);
1786
+ if output == OutputFormat::Text {
1787
+ eprintln!("Warning: remote logout request failed: {}", error);
1788
+ }
1144
1789
  }
1145
1790
  }
1146
1791
 
1147
1792
  let removed = clear_session()?;
1148
1793
  if !removed {
1149
- 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)?;
1150
1800
  return Ok(());
1151
1801
  }
1152
1802
 
1153
- if remote_revoked {
1154
- println!(
1155
- "Logged out. Server session revoked and local session removed from {}.",
1156
- path_display
1157
- );
1158
- } else if remote_unavailable {
1159
- println!("Logged out locally. Removed session from {}.", path_display);
1160
- 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
+ }
1161
1827
  } else {
1162
- println!("Logged out locally. Removed session from {}.", path_display);
1828
+ print_json(&payload)?;
1163
1829
  }
1164
1830
 
1165
1831
  Ok(())
@@ -1195,177 +1861,1661 @@ async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<
1195
1861
  Ok(())
1196
1862
  }
1197
1863
 
1198
- async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
1199
- let mut session = load_session()?;
1200
- apply_base_url_override(&mut session, args.base_url);
1864
+ async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
1865
+ let mut session = load_session()?;
1866
+ apply_base_url_override(&mut session, args.base_url);
1867
+
1868
+ let scopes = if args.scope.is_empty() {
1869
+ return Err(anyhow!("At least one --scope must be provided"));
1870
+ } else {
1871
+ args.scope
1872
+ };
1873
+
1874
+ let body = serde_json::to_value(CreateApiTokenRequest {
1875
+ name: args.name,
1876
+ org_id: args.org_id,
1877
+ project_id: args.project_id,
1878
+ scopes,
1879
+ expires_in_days: args.expires_in_days,
1880
+ })?;
1881
+
1882
+ let response = authed_request(
1883
+ client,
1884
+ &mut session,
1885
+ Method::POST,
1886
+ "/auth/tokens",
1887
+ Some(body),
1888
+ )
1889
+ .await?;
1890
+ if !response.status().is_success() {
1891
+ let body_text = read_error_body(response).await;
1892
+ return Err(anyhow!("token create failed: {}", body_text));
1893
+ }
1894
+ let payload: CreateApiTokenResponse = response.json().await?;
1895
+ println!("{}", serde_json::to_string_pretty(&payload)?);
1896
+ save_session(&session)?;
1897
+ Ok(())
1898
+ }
1899
+
1900
+ async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -> Result<()> {
1901
+ let mut session = load_session()?;
1902
+ apply_base_url_override(&mut session, args.base_url);
1903
+
1904
+ let path = format!("/auth/tokens/{}", args.token_id);
1905
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
1906
+ if !response.status().is_success() {
1907
+ let body = read_error_body(response).await;
1908
+ return Err(anyhow!("token revoke failed: {}", body));
1909
+ }
1910
+ let payload: serde_json::Value = response.json().await?;
1911
+ println!("{}", serde_json::to_string_pretty(&payload)?);
1912
+ save_session(&session)?;
1913
+ Ok(())
1914
+ }
1915
+
1916
+ async fn org_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
1917
+ let mut session = load_session()?;
1918
+ apply_base_url_override(&mut session, args.base_url);
1919
+
1920
+ let response = authed_request(client, &mut session, Method::GET, "/core/orgs", None).await?;
1921
+ if !response.status().is_success() {
1922
+ let body = read_error_body(response).await;
1923
+ return Err(anyhow!("org list failed: {}", body));
1924
+ }
1925
+ let payload: ListOrgsResponse = response.json().await?;
1926
+ println!("{}", serde_json::to_string_pretty(&payload.orgs)?);
1927
+ save_session(&session)?;
1928
+ Ok(())
1929
+ }
1930
+
1931
+ async fn org_create_command(client: &reqwest::Client, args: OrgCreateArgs) -> Result<()> {
1932
+ let mut session = load_session()?;
1933
+ apply_base_url_override(&mut session, args.base_url);
1934
+
1935
+ let response = authed_request(
1936
+ client,
1937
+ &mut session,
1938
+ Method::POST,
1939
+ "/core/orgs",
1940
+ Some(serde_json::json!({
1941
+ "name": args.name
1942
+ })),
1943
+ )
1944
+ .await?;
1945
+ if !response.status().is_success() {
1946
+ let body = read_error_body(response).await;
1947
+ return Err(anyhow!("org create failed: {}", body));
1948
+ }
1949
+ let payload: OrgResponse = response.json().await?;
1950
+ println!("{}", serde_json::to_string_pretty(&payload.org)?);
1951
+ save_session(&session)?;
1952
+ Ok(())
1953
+ }
1954
+
1955
+ async fn org_get_command(client: &reqwest::Client, args: OrgGetArgs) -> Result<()> {
1956
+ let mut session = load_session()?;
1957
+ apply_base_url_override(&mut session, args.base_url);
1958
+
1959
+ let path = format!("/core/orgs/{}", args.org_id);
1960
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1961
+ if !response.status().is_success() {
1962
+ let body = read_error_body(response).await;
1963
+ return Err(anyhow!("org get failed: {}", body));
1964
+ }
1965
+ let payload: OrgResponse = response.json().await?;
1966
+ println!("{}", serde_json::to_string_pretty(&payload)?);
1967
+ save_session(&session)?;
1968
+ Ok(())
1969
+ }
1970
+
1971
+ async fn org_update_command(client: &reqwest::Client, args: OrgUpdateArgs) -> Result<()> {
1972
+ let mut session = load_session()?;
1973
+ apply_base_url_override(&mut session, args.base_url);
1974
+
1975
+ let path = format!("/core/orgs/{}", args.org_id);
1976
+ let response = authed_request(
1977
+ client,
1978
+ &mut session,
1979
+ Method::PATCH,
1980
+ &path,
1981
+ Some(serde_json::json!({
1982
+ "name": args.name
1983
+ })),
1984
+ )
1985
+ .await?;
1986
+ if !response.status().is_success() {
1987
+ let body = read_error_body(response).await;
1988
+ return Err(anyhow!("org update failed: {}", body));
1989
+ }
1990
+ let payload: OrgResponse = response.json().await?;
1991
+ println!("{}", serde_json::to_string_pretty(&payload.org)?);
1992
+ save_session(&session)?;
1993
+ Ok(())
1994
+ }
1995
+
1996
+ async fn org_delete_command(client: &reqwest::Client, args: OrgDeleteArgs) -> Result<()> {
1997
+ let mut session = load_session()?;
1998
+ apply_base_url_override(&mut session, args.base_url);
1999
+
2000
+ let path = format!("/core/orgs/{}", args.org_id);
2001
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2002
+ if !response.status().is_success() {
2003
+ let body = read_error_body(response).await;
2004
+ return Err(anyhow!("org delete failed: {}", body));
2005
+ }
2006
+ let payload: serde_json::Value = response.json().await?;
2007
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2008
+ save_session(&session)?;
2009
+ Ok(())
2010
+ }
2011
+
2012
+ async fn org_invites_command(client: &reqwest::Client, args: OrgInvitesArgs) -> Result<()> {
2013
+ let mut session = load_session()?;
2014
+ apply_base_url_override(&mut session, args.base_url);
2015
+
2016
+ let path = format!("/core/orgs/{}/invites", args.org_id);
2017
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2018
+ if !response.status().is_success() {
2019
+ let body = read_error_body(response).await;
2020
+ return Err(anyhow!("org invites failed: {}", body));
2021
+ }
2022
+ let payload: ListOrgInvitesResponse = response.json().await?;
2023
+ println!("{}", serde_json::to_string_pretty(&payload.invites)?);
2024
+ save_session(&session)?;
2025
+ Ok(())
2026
+ }
2027
+
2028
+ async fn org_invite_command(client: &reqwest::Client, args: OrgInviteArgs) -> Result<()> {
2029
+ let mut session = load_session()?;
2030
+ apply_base_url_override(&mut session, args.base_url);
2031
+
2032
+ let role = args.role.trim().to_lowercase();
2033
+ if role != "member" && role != "admin" {
2034
+ return Err(anyhow!("org invite role must be either 'member' or 'admin'"));
2035
+ }
2036
+ if args.expires_in_days == 0 || args.expires_in_days > 30 {
2037
+ return Err(anyhow!("org invite expires_in_days must be between 1 and 30"));
2038
+ }
2039
+
2040
+ let path = format!("/core/orgs/{}/invites", args.org_id);
2041
+ let response = authed_request(
2042
+ client,
2043
+ &mut session,
2044
+ Method::POST,
2045
+ &path,
2046
+ Some(serde_json::json!({
2047
+ "email": args.email.trim(),
2048
+ "role": role,
2049
+ "expiresInDays": args.expires_in_days
2050
+ })),
2051
+ )
2052
+ .await?;
2053
+ if !response.status().is_success() {
2054
+ let body = read_error_body(response).await;
2055
+ return Err(anyhow!("org invite failed: {}", body));
2056
+ }
2057
+ let payload: OrgInviteResponse = response.json().await?;
2058
+ println!("{}", serde_json::to_string_pretty(&payload.invite)?);
2059
+ save_session(&session)?;
2060
+ Ok(())
2061
+ }
2062
+
2063
+ async fn org_members_command(client: &reqwest::Client, args: OrgMembersArgs) -> Result<()> {
2064
+ let mut session = load_session()?;
2065
+ apply_base_url_override(&mut session, args.base_url);
2066
+
2067
+ let path = format!("/core/orgs/{}/members", args.org_id);
2068
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2069
+ if !response.status().is_success() {
2070
+ let body = read_error_body(response).await;
2071
+ return Err(anyhow!("org members failed: {}", body));
2072
+ }
2073
+ let payload: ListOrgMembersResponse = response.json().await?;
2074
+ println!("{}", serde_json::to_string_pretty(&payload.members)?);
2075
+ save_session(&session)?;
2076
+ Ok(())
2077
+ }
2078
+
2079
+ async fn org_add_member_command(client: &reqwest::Client, args: OrgAddMemberArgs) -> Result<()> {
2080
+ let mut session = load_session()?;
2081
+ apply_base_url_override(&mut session, args.base_url);
2082
+
2083
+ let path = format!("/core/orgs/{}/members", args.org_id);
2084
+ let response = authed_request(
2085
+ client,
2086
+ &mut session,
2087
+ Method::POST,
2088
+ &path,
2089
+ Some(serde_json::json!({
2090
+ "email": args.email,
2091
+ "role": args.role
2092
+ })),
2093
+ )
2094
+ .await?;
2095
+ if !response.status().is_success() {
2096
+ let body = read_error_body(response).await;
2097
+ return Err(anyhow!("org add member failed: {}", body));
2098
+ }
2099
+ let payload: OrgMemberResponse = response.json().await?;
2100
+ println!("{}", serde_json::to_string_pretty(&payload.member)?);
2101
+ save_session(&session)?;
2102
+ Ok(())
2103
+ }
2104
+
2105
+ async fn org_update_member_command(client: &reqwest::Client, args: OrgUpdateMemberArgs) -> Result<()> {
2106
+ let mut session = load_session()?;
2107
+ apply_base_url_override(&mut session, args.base_url);
2108
+
2109
+ let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
2110
+ let response = authed_request(
2111
+ client,
2112
+ &mut session,
2113
+ Method::PATCH,
2114
+ &path,
2115
+ Some(serde_json::json!({
2116
+ "role": args.role
2117
+ })),
2118
+ )
2119
+ .await?;
2120
+ if !response.status().is_success() {
2121
+ let body = read_error_body(response).await;
2122
+ return Err(anyhow!("org update member failed: {}", body));
2123
+ }
2124
+ let payload: OrgMemberResponse = response.json().await?;
2125
+ println!("{}", serde_json::to_string_pretty(&payload.member)?);
2126
+ save_session(&session)?;
2127
+ Ok(())
2128
+ }
2129
+
2130
+ async fn org_remove_member_command(client: &reqwest::Client, args: OrgRemoveMemberArgs) -> Result<()> {
2131
+ let mut session = load_session()?;
2132
+ apply_base_url_override(&mut session, args.base_url);
2133
+
2134
+ let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
2135
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2136
+ if !response.status().is_success() {
2137
+ let body = read_error_body(response).await;
2138
+ return Err(anyhow!("org remove member failed: {}", body));
2139
+ }
2140
+ let payload: serde_json::Value = response.json().await?;
2141
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2142
+ save_session(&session)?;
2143
+ Ok(())
2144
+ }
2145
+
2146
+ async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -> Result<()> {
2147
+ let mut session = load_session()?;
2148
+ apply_base_url_override(&mut session, args.base_url);
2149
+
2150
+ let path = match args.org_id {
2151
+ Some(org_id) if !org_id.trim().is_empty() => {
2152
+ format!("/core/projects?orgId={}", org_id.trim())
2153
+ }
2154
+ _ => "/core/projects".to_string(),
2155
+ };
2156
+
2157
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2158
+ if !response.status().is_success() {
2159
+ let body = read_error_body(response).await;
2160
+ return Err(anyhow!("project list failed: {}", body));
2161
+ }
2162
+ let payload: ListProjectsResponse = response.json().await?;
2163
+ println!("{}", serde_json::to_string_pretty(&payload.projects)?);
2164
+ save_session(&session)?;
2165
+ Ok(())
2166
+ }
2167
+
2168
+ async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArgs) -> Result<()> {
2169
+ let mut session = load_session()?;
2170
+ apply_base_url_override(&mut session, args.base_url);
2171
+
2172
+ let response = authed_request(
2173
+ client,
2174
+ &mut session,
2175
+ Method::POST,
2176
+ "/core/projects",
2177
+ Some(serde_json::json!({
2178
+ "orgId": args.org_id,
2179
+ "name": args.name,
2180
+ "description": args.description
2181
+ })),
2182
+ )
2183
+ .await?;
2184
+ if !response.status().is_success() {
2185
+ let body = read_error_body(response).await;
2186
+ return Err(anyhow!("project create failed: {}", body));
2187
+ }
2188
+ let payload: ProjectResponse = response.json().await?;
2189
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
2190
+ save_session(&session)?;
2191
+ Ok(())
2192
+ }
2193
+
2194
+ async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) -> Result<()> {
2195
+ let mut session = load_session()?;
2196
+ apply_base_url_override(&mut session, args.base_url);
2197
+
2198
+ let path = format!("/core/projects/{}", args.project_id);
2199
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2200
+ if !response.status().is_success() {
2201
+ let body = read_error_body(response).await;
2202
+ return Err(anyhow!("project get failed: {}", body));
2203
+ }
2204
+ let payload: ProjectResponse = response.json().await?;
2205
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
2206
+ save_session(&session)?;
2207
+ Ok(())
2208
+ }
2209
+
2210
+ async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArgs) -> Result<()> {
2211
+ let mut session = load_session()?;
2212
+ apply_base_url_override(&mut session, args.base_url);
2213
+
2214
+ let mut body = serde_json::Map::new();
2215
+ if let Some(name) = args.name {
2216
+ body.insert("name".to_string(), serde_json::Value::String(name));
2217
+ }
2218
+ if args.clear_description {
2219
+ body.insert("description".to_string(), serde_json::Value::Null);
2220
+ } else if let Some(description) = args.description {
2221
+ body.insert(
2222
+ "description".to_string(),
2223
+ serde_json::Value::String(description),
2224
+ );
2225
+ }
2226
+
2227
+ if body.is_empty() {
2228
+ return Err(anyhow!(
2229
+ "project update requires at least one field (--name, --description, or --clear-description)"
2230
+ ));
2231
+ }
2232
+
2233
+ let path = format!("/core/projects/{}", args.project_id);
2234
+ let response = authed_request(
2235
+ client,
2236
+ &mut session,
2237
+ Method::PATCH,
2238
+ &path,
2239
+ Some(serde_json::Value::Object(body)),
2240
+ )
2241
+ .await?;
2242
+ if !response.status().is_success() {
2243
+ let body = read_error_body(response).await;
2244
+ return Err(anyhow!("project update failed: {}", body));
2245
+ }
2246
+ let payload: ProjectResponse = response.json().await?;
2247
+ println!("{}", serde_json::to_string_pretty(&payload.project)?);
2248
+ save_session(&session)?;
2249
+ Ok(())
2250
+ }
2251
+
2252
+ async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArgs) -> Result<()> {
2253
+ let mut session = load_session()?;
2254
+ apply_base_url_override(&mut session, args.base_url);
2255
+
2256
+ let path = format!("/core/projects/{}", args.project_id);
2257
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2258
+ if !response.status().is_success() {
2259
+ let body = read_error_body(response).await;
2260
+ return Err(anyhow!("project delete failed: {}", body));
2261
+ }
2262
+ let payload: serde_json::Value = response.json().await?;
2263
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2264
+ save_session(&session)?;
2265
+ Ok(())
2266
+ }
2267
+
2268
+ async fn project_members_command(client: &reqwest::Client, args: ProjectMembersArgs) -> Result<()> {
2269
+ let mut session = load_session()?;
2270
+ apply_base_url_override(&mut session, args.base_url);
2271
+
2272
+ let path = format!("/core/projects/{}/members", args.project_id);
2273
+ let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
2274
+ if !response.status().is_success() {
2275
+ let body = read_error_body(response).await;
2276
+ return Err(anyhow!("project members failed: {}", body));
2277
+ }
2278
+ let payload: ListProjectMembersResponse = response.json().await?;
2279
+ println!("{}", serde_json::to_string_pretty(&payload.members)?);
2280
+ save_session(&session)?;
2281
+ Ok(())
2282
+ }
2283
+
2284
+ async fn project_add_member_command(client: &reqwest::Client, args: ProjectAddMemberArgs) -> Result<()> {
2285
+ let mut session = load_session()?;
2286
+ apply_base_url_override(&mut session, args.base_url);
2287
+
2288
+ let path = format!("/core/projects/{}/members", args.project_id);
2289
+ let response = authed_request(
2290
+ client,
2291
+ &mut session,
2292
+ Method::POST,
2293
+ &path,
2294
+ Some(serde_json::json!({
2295
+ "email": args.email,
2296
+ "role": args.role
2297
+ })),
2298
+ )
2299
+ .await?;
2300
+ if !response.status().is_success() {
2301
+ let body = read_error_body(response).await;
2302
+ return Err(anyhow!("project add member failed: {}", body));
2303
+ }
2304
+ let payload: ProjectMemberResponse = response.json().await?;
2305
+ println!("{}", serde_json::to_string_pretty(&payload.member)?);
2306
+ save_session(&session)?;
2307
+ Ok(())
2308
+ }
2309
+
2310
+ async fn project_update_member_command(
2311
+ client: &reqwest::Client,
2312
+ args: ProjectUpdateMemberArgs,
2313
+ ) -> Result<()> {
2314
+ let mut session = load_session()?;
2315
+ apply_base_url_override(&mut session, args.base_url);
2316
+
2317
+ let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
2318
+ let response = authed_request(
2319
+ client,
2320
+ &mut session,
2321
+ Method::PATCH,
2322
+ &path,
2323
+ Some(serde_json::json!({
2324
+ "role": args.role
2325
+ })),
2326
+ )
2327
+ .await?;
2328
+ if !response.status().is_success() {
2329
+ let body = read_error_body(response).await;
2330
+ return Err(anyhow!("project update member failed: {}", body));
2331
+ }
2332
+ let payload: ProjectMemberResponse = response.json().await?;
2333
+ println!("{}", serde_json::to_string_pretty(&payload.member)?);
2334
+ save_session(&session)?;
2335
+ Ok(())
2336
+ }
2337
+
2338
+ async fn project_remove_member_command(
2339
+ client: &reqwest::Client,
2340
+ args: ProjectRemoveMemberArgs,
2341
+ ) -> Result<()> {
2342
+ let mut session = load_session()?;
2343
+ apply_base_url_override(&mut session, args.base_url);
2344
+
2345
+ let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
2346
+ let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
2347
+ if !response.status().is_success() {
2348
+ let body = read_error_body(response).await;
2349
+ return Err(anyhow!("project remove member failed: {}", body));
2350
+ }
2351
+ let payload: serde_json::Value = response.json().await?;
2352
+ println!("{}", serde_json::to_string_pretty(&payload)?);
2353
+ save_session(&session)?;
2354
+ Ok(())
2355
+ }
2356
+
2357
+ async fn verify_project_access(
2358
+ client: &reqwest::Client,
2359
+ session: &mut SessionConfig,
2360
+ project_id: &str,
2361
+ ) -> Result<()> {
2362
+ let path = format!("/core/projects/{}", project_id);
2363
+ let response = authed_request(client, session, Method::GET, &path, None).await?;
2364
+ if response.status().is_success() {
2365
+ return Ok(());
2366
+ }
2367
+ let body = read_error_body(response).await;
2368
+ Err(anyhow!(
2369
+ "project verification failed for {}: {}",
2370
+ project_id,
2371
+ body
2372
+ ))
2373
+ }
2374
+
2375
+ fn file_name_component(path: &str) -> String {
2376
+ Path::new(path)
2377
+ .file_name()
2378
+ .and_then(|value| value.to_str())
2379
+ .unwrap_or(path)
2380
+ .to_string()
2381
+ }
2382
+
2383
+ fn build_unreal_link_manifest_payload(
2384
+ link: &UnrealLinkRecord,
2385
+ include_local_paths: bool,
2386
+ ) -> serde_json::Value {
2387
+ let mut payload = serde_json::json!({
2388
+ "schemaVersion": 1,
2389
+ "provider": "unreal",
2390
+ "projectId": link.project_id,
2391
+ "uprojectFile": file_name_component(&link.uproject_path),
2392
+ "projectRootName": file_name_component(&link.project_root),
2393
+ "editorBinary": file_name_component(&link.editor_path),
2394
+ "engineRootName": link.engine_root.as_ref().map(|value| file_name_component(value)),
2395
+ "updatedAtEpochMs": link.updated_at_epoch_ms
2396
+ });
2397
+
2398
+ if include_local_paths {
2399
+ payload["localPaths"] = serde_json::json!({
2400
+ "uprojectPath": link.uproject_path,
2401
+ "projectRoot": link.project_root,
2402
+ "editorPath": link.editor_path,
2403
+ "engineRoot": link.engine_root
2404
+ });
2405
+ }
2406
+ payload
2407
+ }
2408
+
2409
+ async fn sync_unreal_link_manifest_asset(
2410
+ client: &reqwest::Client,
2411
+ session: &mut SessionConfig,
2412
+ link: &UnrealLinkRecord,
2413
+ include_local_paths: bool,
2414
+ ) -> Result<AssetRecord> {
2415
+ let payload = build_unreal_link_manifest_payload(link, include_local_paths);
2416
+ let bytes = serde_json::to_vec_pretty(&payload)?;
2417
+ upload_asset_via_intent(
2418
+ client,
2419
+ session,
2420
+ &link.project_id,
2421
+ ".reallink/link/unreal-link.latest.json",
2422
+ bytes,
2423
+ "application/json",
2424
+ "other",
2425
+ "private",
2426
+ )
2427
+ .await
2428
+ }
2429
+
2430
+ pub(crate) async fn link_unreal_command(client: &reqwest::Client, args: LinkUnrealArgs) -> Result<()> {
2431
+ let uproject_path = resolve_uproject_path(&args.uproject)?;
2432
+ let project_root = uproject_path
2433
+ .parent()
2434
+ .ok_or_else(|| anyhow!("Invalid uproject path {}", uproject_path.display()))?
2435
+ .to_path_buf();
2436
+ let (editor_path, engine_root_path) = resolve_editor_and_engine(args.editor, args.engine_root)?;
2437
+
2438
+ let now = now_epoch_ms();
2439
+ let link_record = UnrealLinkRecord {
2440
+ project_id: args.project_id.clone(),
2441
+ uproject_path: uproject_path.display().to_string(),
2442
+ project_root: project_root.display().to_string(),
2443
+ engine_root: engine_root_path
2444
+ .as_ref()
2445
+ .map(|value| value.display().to_string()),
2446
+ editor_path: editor_path.display().to_string(),
2447
+ created_at_epoch_ms: now,
2448
+ updated_at_epoch_ms: now,
2449
+ };
2450
+
2451
+ let mut synced_asset: Option<AssetRecord> = None;
2452
+ if !args.no_verify_remote || args.sync_project {
2453
+ let mut session = load_session()?;
2454
+ apply_base_url_override(&mut session, args.base_url.clone());
2455
+ if !args.no_verify_remote {
2456
+ verify_project_access(client, &mut session, &args.project_id).await?;
2457
+ }
2458
+ if args.sync_project {
2459
+ let synced = sync_unreal_link_manifest_asset(
2460
+ client,
2461
+ &mut session,
2462
+ &link_record,
2463
+ args.include_local_paths,
2464
+ )
2465
+ .await?;
2466
+ synced_asset = Some(synced);
2467
+ }
2468
+ save_session(&session)?;
2469
+ }
2470
+
2471
+ let uproject_cmp = normalize_path_for_compare(&uproject_path);
2472
+ let mut config = load_unreal_links()?;
2473
+ config.links.retain(|entry| {
2474
+ entry.project_id != args.project_id
2475
+ && normalize_path_string_for_compare(&entry.uproject_path) != uproject_cmp
2476
+ });
2477
+ config.links.push(link_record.clone());
2478
+
2479
+ if args.set_default || config.default_project_id.is_none() {
2480
+ config.default_project_id = Some(args.project_id.clone());
2481
+ }
2482
+
2483
+ save_unreal_links(&config)?;
2484
+ println!(
2485
+ "{}",
2486
+ serde_json::to_string_pretty(&serde_json::json!({
2487
+ "ok": true,
2488
+ "defaultProjectId": config.default_project_id,
2489
+ "link": config.links.iter().find(|entry| entry.project_id == args.project_id),
2490
+ "syncedAssetId": synced_asset.as_ref().map(|entry| entry.id.clone()),
2491
+ "syncedManifestPath": if args.sync_project { Some(".reallink/link/unreal-link.latest.json") } else { None }
2492
+ }))?
2493
+ );
2494
+ Ok(())
2495
+ }
2496
+
2497
+ pub(crate) async fn link_list_command() -> Result<()> {
2498
+ let config = load_unreal_links()?;
2499
+ println!("{}", serde_json::to_string_pretty(&config)?);
2500
+ Ok(())
2501
+ }
2502
+
2503
+ pub(crate) async fn link_use_command(args: LinkUseArgs) -> Result<()> {
2504
+ let mut config = load_unreal_links()?;
2505
+ if !config
2506
+ .links
2507
+ .iter()
2508
+ .any(|entry| entry.project_id == args.project_id)
2509
+ {
2510
+ return Err(anyhow!(
2511
+ "No link found for project {}. Run `reallink link unreal ...` first.",
2512
+ args.project_id
2513
+ ));
2514
+ }
2515
+ config.default_project_id = Some(args.project_id.clone());
2516
+ save_unreal_links(&config)?;
2517
+ println!(
2518
+ "{}",
2519
+ serde_json::to_string_pretty(&serde_json::json!({
2520
+ "ok": true,
2521
+ "defaultProjectId": args.project_id
2522
+ }))?
2523
+ );
2524
+ Ok(())
2525
+ }
2526
+
2527
+ fn normalize_match_path(path: &Path) -> String {
2528
+ if let Ok(canonical) = canonicalize_existing_path(path, "uproject") {
2529
+ return normalize_path_for_compare(&canonical);
2530
+ }
2531
+ let absolute = if path.is_absolute() {
2532
+ path.to_path_buf()
2533
+ } else {
2534
+ match std::env::current_dir() {
2535
+ Ok(dir) => dir.join(path),
2536
+ Err(_) => path.to_path_buf(),
2537
+ }
2538
+ };
2539
+ normalize_path_for_compare(&absolute)
2540
+ }
2541
+
2542
+ pub(crate) async fn link_remove_command(args: LinkRemoveArgs) -> Result<()> {
2543
+ if args.project_id.is_none() && args.uproject.is_none() {
2544
+ return Err(anyhow!(
2545
+ "Provide --project-id or --uproject to remove a link."
2546
+ ));
2547
+ }
2548
+
2549
+ let mut config = load_unreal_links()?;
2550
+ let before = config.links.len();
2551
+ let target_uproject_cmp = args
2552
+ .uproject
2553
+ .as_ref()
2554
+ .map(|value| normalize_match_path(value));
2555
+ config.links.retain(|entry| {
2556
+ if let Some(project_id) = args.project_id.as_ref() {
2557
+ if &entry.project_id == project_id {
2558
+ return false;
2559
+ }
2560
+ }
2561
+ if let Some(target_cmp) = target_uproject_cmp.as_ref() {
2562
+ if normalize_path_string_for_compare(&entry.uproject_path) == *target_cmp {
2563
+ return false;
2564
+ }
2565
+ }
2566
+ true
2567
+ });
2568
+ let removed = before.saturating_sub(config.links.len());
2569
+ if removed == 0 {
2570
+ return Err(anyhow!("No matching link found."));
2571
+ }
2572
+
2573
+ if let Some(default_id) = config.default_project_id.clone() {
2574
+ let has_default = config
2575
+ .links
2576
+ .iter()
2577
+ .any(|entry| entry.project_id == default_id);
2578
+ if !has_default {
2579
+ config.default_project_id = config.links.first().map(|entry| entry.project_id.clone());
2580
+ }
2581
+ }
2582
+
2583
+ save_unreal_links(&config)?;
2584
+ println!(
2585
+ "{}",
2586
+ serde_json::to_string_pretty(&serde_json::json!({
2587
+ "ok": true,
2588
+ "removed": removed,
2589
+ "defaultProjectId": config.default_project_id
2590
+ }))?
2591
+ );
2592
+ Ok(())
2593
+ }
2594
+
2595
+ fn resolve_link_target(
2596
+ config: &UnrealLinksConfig,
2597
+ project_id: Option<&str>,
2598
+ uproject: Option<&Path>,
2599
+ ) -> Result<UnrealLinkRecord> {
2600
+ if let Some(project_id) = project_id {
2601
+ let entry = config
2602
+ .links
2603
+ .iter()
2604
+ .find(|item| item.project_id == project_id)
2605
+ .ok_or_else(|| anyhow!("No link found for project {}", project_id))?;
2606
+ return Ok(entry.clone());
2607
+ }
2608
+ if let Some(uproject) = uproject {
2609
+ let target_cmp = normalize_match_path(uproject);
2610
+ let entry = config
2611
+ .links
2612
+ .iter()
2613
+ .find(|item| normalize_path_string_for_compare(&item.uproject_path) == target_cmp)
2614
+ .ok_or_else(|| anyhow!("No link found for uproject {}", uproject.display()))?;
2615
+ return Ok(entry.clone());
2616
+ }
2617
+ if let Some(default_project_id) = config.default_project_id.as_ref() {
2618
+ if let Some(entry) = config
2619
+ .links
2620
+ .iter()
2621
+ .find(|item| &item.project_id == default_project_id)
2622
+ {
2623
+ return Ok(entry.clone());
2624
+ }
2625
+ }
2626
+ if config.links.len() == 1 {
2627
+ return Ok(config.links[0].clone());
2628
+ }
2629
+ Err(anyhow!(
2630
+ "No target specified. Use --project-id or set a default link with `reallink link use --project-id ...`."
2631
+ ))
2632
+ }
2633
+
2634
+ fn unreal_manifest_local_path(link: &UnrealLinkRecord) -> PathBuf {
2635
+ PathBuf::from(&link.project_root)
2636
+ .join(".reallink")
2637
+ .join("link")
2638
+ .join("unreal-link.latest.json")
2639
+ }
2640
+
2641
+ fn push_doctor_check(
2642
+ checks: &mut Vec<serde_json::Value>,
2643
+ errors: &mut Vec<String>,
2644
+ warnings: &mut Vec<String>,
2645
+ key: &str,
2646
+ ok: bool,
2647
+ detail: impl Into<String>,
2648
+ warning_only: bool,
2649
+ ) {
2650
+ let detail_text = detail.into();
2651
+ let status = if ok {
2652
+ "ok"
2653
+ } else if warning_only {
2654
+ "warn"
2655
+ } else {
2656
+ "error"
2657
+ };
2658
+ if !ok {
2659
+ if warning_only {
2660
+ warnings.push(format!("{}: {}", key, detail_text));
2661
+ } else {
2662
+ errors.push(format!("{}: {}", key, detail_text));
2663
+ }
2664
+ }
2665
+ checks.push(serde_json::json!({
2666
+ "key": key,
2667
+ "status": status,
2668
+ "detail": detail_text
2669
+ }));
2670
+ }
2671
+
2672
+ pub(crate) async fn link_paths_command(args: LinkPathsArgs) -> Result<()> {
2673
+ let config = load_unreal_links()?;
2674
+ if config.links.is_empty() {
2675
+ return Err(anyhow!(
2676
+ "No local links configured. Run `reallink link unreal ...` first."
2677
+ ));
2678
+ }
2679
+ let target = resolve_link_target(
2680
+ &config,
2681
+ args.project_id.as_deref(),
2682
+ args.uproject.as_deref(),
2683
+ )?;
2684
+ let project_plugins_dir = PathBuf::from(&target.project_root).join("Plugins");
2685
+ let engine_plugins_dir = target.engine_root.as_ref().map(|root| {
2686
+ PathBuf::from(root)
2687
+ .join("Engine")
2688
+ .join("Plugins")
2689
+ .join("Marketplace")
2690
+ });
2691
+ let manifest_path = unreal_manifest_local_path(&target);
2692
+
2693
+ print_json(&serde_json::json!({
2694
+ "ok": true,
2695
+ "projectId": target.project_id,
2696
+ "paths": {
2697
+ "uprojectPath": target.uproject_path,
2698
+ "projectRoot": target.project_root,
2699
+ "editorPath": target.editor_path,
2700
+ "engineRoot": target.engine_root,
2701
+ "projectPluginsDir": project_plugins_dir.display().to_string(),
2702
+ "enginePluginsDir": engine_plugins_dir.as_ref().map(|value| value.display().to_string()),
2703
+ "manifestPath": manifest_path.display().to_string(),
2704
+ "localLinkConfigPath": unreal_links_path()?.display().to_string()
2705
+ },
2706
+ "remoteManifestAssetPath": ".reallink/link/unreal-link.latest.json"
2707
+ }))?;
2708
+ Ok(())
2709
+ }
2710
+
2711
+ pub(crate) async fn link_doctor_command(client: &reqwest::Client, args: LinkDoctorArgs) -> Result<()> {
2712
+ let config = load_unreal_links()?;
2713
+ if config.links.is_empty() {
2714
+ return Err(anyhow!(
2715
+ "No local links configured. Run `reallink link unreal ...` first."
2716
+ ));
2717
+ }
2718
+ let target = resolve_link_target(
2719
+ &config,
2720
+ args.project_id.as_deref(),
2721
+ args.uproject.as_deref(),
2722
+ )?;
2723
+
2724
+ let mut checks: Vec<serde_json::Value> = Vec::new();
2725
+ let mut warnings: Vec<String> = Vec::new();
2726
+ let mut errors: Vec<String> = Vec::new();
2727
+
2728
+ let project_root = PathBuf::from(&target.project_root);
2729
+ let uproject_path = PathBuf::from(&target.uproject_path);
2730
+ let editor_path = PathBuf::from(&target.editor_path);
2731
+ let engine_root = target.engine_root.as_ref().map(PathBuf::from);
2732
+ let project_plugins_dir = project_root.join("Plugins");
2733
+ let engine_plugins_dir = engine_root
2734
+ .as_ref()
2735
+ .map(|root| root.join("Engine").join("Plugins").join("Marketplace"));
2736
+ let manifest_path = unreal_manifest_local_path(&target);
2737
+
2738
+ push_doctor_check(
2739
+ &mut checks,
2740
+ &mut errors,
2741
+ &mut warnings,
2742
+ "projectRoot.exists",
2743
+ project_root.exists() && project_root.is_dir(),
2744
+ format!("projectRoot={}", project_root.display()),
2745
+ false,
2746
+ );
2747
+ let uproject_ext_ok = uproject_path
2748
+ .extension()
2749
+ .and_then(|value| value.to_str())
2750
+ .map(|value| value.eq_ignore_ascii_case("uproject"))
2751
+ .unwrap_or(false);
2752
+ push_doctor_check(
2753
+ &mut checks,
2754
+ &mut errors,
2755
+ &mut warnings,
2756
+ "uproject.valid",
2757
+ uproject_path.exists() && uproject_path.is_file() && uproject_ext_ok,
2758
+ format!("uprojectPath={}", uproject_path.display()),
2759
+ false,
2760
+ );
2761
+ push_doctor_check(
2762
+ &mut checks,
2763
+ &mut errors,
2764
+ &mut warnings,
2765
+ "editor.valid",
2766
+ editor_path.exists() && editor_path.is_file(),
2767
+ format!("editorPath={}", editor_path.display()),
2768
+ false,
2769
+ );
2770
+ if let Some(root) = engine_root.as_ref() {
2771
+ let root_ok = root.exists() && root.is_dir();
2772
+ push_doctor_check(
2773
+ &mut checks,
2774
+ &mut errors,
2775
+ &mut warnings,
2776
+ "engineRoot.valid",
2777
+ root_ok,
2778
+ format!("engineRoot={}", root.display()),
2779
+ false,
2780
+ );
2781
+ let binaries_path = root.join("Engine").join("Binaries");
2782
+ push_doctor_check(
2783
+ &mut checks,
2784
+ &mut errors,
2785
+ &mut warnings,
2786
+ "engineRoot.layout",
2787
+ binaries_path.exists() && binaries_path.is_dir(),
2788
+ format!("expected Engine/Binaries under {}", root.display()),
2789
+ true,
2790
+ );
2791
+ } else {
2792
+ push_doctor_check(
2793
+ &mut checks,
2794
+ &mut errors,
2795
+ &mut warnings,
2796
+ "engineRoot.present",
2797
+ false,
2798
+ "engineRoot not set on this link; plugin engine-scope operations are unavailable",
2799
+ true,
2800
+ );
2801
+ }
2802
+ push_doctor_check(
2803
+ &mut checks,
2804
+ &mut errors,
2805
+ &mut warnings,
2806
+ "plugins.projectDir",
2807
+ project_plugins_dir.exists() && project_plugins_dir.is_dir(),
2808
+ format!("projectPluginsDir={}", project_plugins_dir.display()),
2809
+ true,
2810
+ );
2811
+ if let Some(engine_plugins) = engine_plugins_dir.as_ref() {
2812
+ push_doctor_check(
2813
+ &mut checks,
2814
+ &mut errors,
2815
+ &mut warnings,
2816
+ "plugins.engineDir",
2817
+ engine_plugins.exists() && engine_plugins.is_dir(),
2818
+ format!("enginePluginsDir={}", engine_plugins.display()),
2819
+ true,
2820
+ );
2821
+ }
2822
+
2823
+ if manifest_path.exists() {
2824
+ let manifest_ok = fs::read_to_string(&manifest_path)
2825
+ .ok()
2826
+ .and_then(|value| json5::from_str::<serde_json::Value>(&value).ok())
2827
+ .is_some();
2828
+ push_doctor_check(
2829
+ &mut checks,
2830
+ &mut errors,
2831
+ &mut warnings,
2832
+ "manifest.local",
2833
+ manifest_ok,
2834
+ format!("manifestPath={}", manifest_path.display()),
2835
+ true,
2836
+ );
2837
+ } else {
2838
+ push_doctor_check(
2839
+ &mut checks,
2840
+ &mut errors,
2841
+ &mut warnings,
2842
+ "manifest.local",
2843
+ false,
2844
+ format!("manifestPath={} (missing)", manifest_path.display()),
2845
+ true,
2846
+ );
2847
+ }
2848
+
2849
+ if args.verify_remote {
2850
+ let (remote_ok, remote_detail) = match load_session() {
2851
+ Ok(mut session) => {
2852
+ apply_base_url_override(&mut session, args.base_url.clone());
2853
+ let outcome = if let Err(error) =
2854
+ verify_project_access(client, &mut session, &target.project_id).await
2855
+ {
2856
+ (false, error.to_string())
2857
+ } else {
2858
+ (
2859
+ true,
2860
+ format!(
2861
+ "projectId={} verified against {}",
2862
+ target.project_id, session.base_url
2863
+ ),
2864
+ )
2865
+ };
2866
+ let _ = save_session(&session);
2867
+ outcome
2868
+ }
2869
+ Err(error) => (false, format!("no active session: {}", error)),
2870
+ };
2871
+ push_doctor_check(
2872
+ &mut checks,
2873
+ &mut errors,
2874
+ &mut warnings,
2875
+ "remote.verify",
2876
+ remote_ok,
2877
+ remote_detail,
2878
+ false,
2879
+ );
2880
+ }
2881
+
2882
+ let ok = errors.is_empty();
2883
+ print_json(&serde_json::json!({
2884
+ "ok": ok,
2885
+ "projectId": target.project_id,
2886
+ "checks": checks,
2887
+ "warnings": warnings,
2888
+ "errors": errors,
2889
+ "paths": {
2890
+ "uprojectPath": uproject_path.display().to_string(),
2891
+ "projectRoot": project_root.display().to_string(),
2892
+ "editorPath": editor_path.display().to_string(),
2893
+ "engineRoot": engine_root.as_ref().map(|value| value.display().to_string()),
2894
+ "projectPluginsDir": project_plugins_dir.display().to_string(),
2895
+ "enginePluginsDir": engine_plugins_dir.as_ref().map(|value| value.display().to_string()),
2896
+ "manifestPath": manifest_path.display().to_string()
2897
+ }
2898
+ }))?;
2899
+ Ok(())
2900
+ }
2901
+
2902
+ pub(crate) async fn link_open_command(args: LinkOpenArgs) -> Result<()> {
2903
+ let config = load_unreal_links()?;
2904
+ if config.links.is_empty() {
2905
+ return Err(anyhow!(
2906
+ "No local links configured. Run `reallink link unreal ...` first."
2907
+ ));
2908
+ }
2909
+ let target = resolve_link_target(
2910
+ &config,
2911
+ args.project_id.as_deref(),
2912
+ args.uproject.as_deref(),
2913
+ )?;
2914
+ let editor_path = PathBuf::from(&target.editor_path);
2915
+ let uproject_path = PathBuf::from(&target.uproject_path);
2916
+
2917
+ if !editor_path.exists() {
2918
+ return Err(anyhow!(
2919
+ "Editor path does not exist anymore: {}",
2920
+ editor_path.display()
2921
+ ));
2922
+ }
2923
+ if !uproject_path.exists() {
2924
+ return Err(anyhow!(
2925
+ "uproject path does not exist anymore: {}",
2926
+ uproject_path.display()
2927
+ ));
2928
+ }
2929
+
2930
+ let mut command = Command::new(&editor_path);
2931
+ command.arg(&uproject_path);
2932
+ for argument in args.extra_arg {
2933
+ command.arg(argument);
2934
+ }
2935
+
2936
+ if args.wait {
2937
+ let status = command
2938
+ .status()
2939
+ .with_context(|| format!("Failed to run Unreal editor {}", editor_path.display()))?;
2940
+ if !status.success() {
2941
+ return Err(anyhow!("Unreal editor exited with {}", status));
2942
+ }
2943
+ println!(
2944
+ "{}",
2945
+ serde_json::to_string_pretty(&serde_json::json!({
2946
+ "ok": true,
2947
+ "projectId": target.project_id,
2948
+ "waited": true,
2949
+ "status": status.code()
2950
+ }))?
2951
+ );
2952
+ } else {
2953
+ let child = command
2954
+ .spawn()
2955
+ .with_context(|| format!("Failed to launch Unreal editor {}", editor_path.display()))?;
2956
+ println!(
2957
+ "{}",
2958
+ serde_json::to_string_pretty(&serde_json::json!({
2959
+ "ok": true,
2960
+ "projectId": target.project_id,
2961
+ "waited": false,
2962
+ "pid": child.id()
2963
+ }))?
2964
+ );
2965
+ }
2966
+
2967
+ Ok(())
2968
+ }
2969
+
2970
+ pub(crate) async fn link_run_command(args: LinkRunArgs) -> Result<()> {
2971
+ let config = load_unreal_links()?;
2972
+ if config.links.is_empty() {
2973
+ return Err(anyhow!(
2974
+ "No local links configured. Run `reallink link unreal ...` first."
2975
+ ));
2976
+ }
2977
+ let target = resolve_link_target(
2978
+ &config,
2979
+ args.project_id.as_deref(),
2980
+ args.uproject.as_deref(),
2981
+ )?;
2982
+ let editor_path = PathBuf::from(&target.editor_path);
2983
+ let uproject_path = PathBuf::from(&target.uproject_path);
2984
+
2985
+ if !editor_path.exists() {
2986
+ return Err(anyhow!(
2987
+ "Editor path does not exist anymore: {}",
2988
+ editor_path.display()
2989
+ ));
2990
+ }
2991
+ if !uproject_path.exists() {
2992
+ return Err(anyhow!(
2993
+ "uproject path does not exist anymore: {}",
2994
+ uproject_path.display()
2995
+ ));
2996
+ }
2997
+
2998
+ let mut command = Command::new(&editor_path);
2999
+ command.arg(&uproject_path);
3000
+ if let Some(commandlet) = args.commandlet.as_ref() {
3001
+ let normalized = commandlet.trim();
3002
+ if normalized.is_empty() {
3003
+ return Err(anyhow!("--commandlet cannot be empty"));
3004
+ }
3005
+ command.arg(format!("-run={}", normalized));
3006
+ }
3007
+ if args.log {
3008
+ command.arg("-log");
3009
+ }
3010
+ if args.headless {
3011
+ command.arg("-unattended");
3012
+ command.arg("-nop4");
3013
+ command.arg("-nosplash");
3014
+ command.arg("-nullrhi");
3015
+ }
3016
+ for argument in args.extra_arg {
3017
+ command.arg(argument);
3018
+ }
3019
+
3020
+ if args.no_wait {
3021
+ let child = command
3022
+ .spawn()
3023
+ .with_context(|| format!("Failed to launch Unreal editor {}", editor_path.display()))?;
3024
+ println!(
3025
+ "{}",
3026
+ serde_json::to_string_pretty(&serde_json::json!({
3027
+ "ok": true,
3028
+ "projectId": target.project_id,
3029
+ "mode": if args.commandlet.is_some() { "commandlet" } else { "editor" },
3030
+ "waited": false,
3031
+ "pid": child.id()
3032
+ }))?
3033
+ );
3034
+ return Ok(());
3035
+ }
3036
+
3037
+ let status = command
3038
+ .status()
3039
+ .with_context(|| format!("Failed to run Unreal editor {}", editor_path.display()))?;
3040
+ if !status.success() {
3041
+ return Err(anyhow!("Unreal editor exited with {}", status));
3042
+ }
3043
+
3044
+ println!(
3045
+ "{}",
3046
+ serde_json::to_string_pretty(&serde_json::json!({
3047
+ "ok": true,
3048
+ "projectId": target.project_id,
3049
+ "mode": if args.commandlet.is_some() { "commandlet" } else { "editor" },
3050
+ "waited": true,
3051
+ "status": status.code()
3052
+ }))?
3053
+ );
3054
+ Ok(())
3055
+ }
3056
+
3057
+ fn normalize_public_bucket_base(base_url: &str) -> Result<String> {
3058
+ let trimmed = base_url.trim().trim_end_matches('/');
3059
+ if trimmed.is_empty() {
3060
+ return Err(anyhow!("Plugin base URL is empty"));
3061
+ }
3062
+ let parsed = reqwest::Url::parse(trimmed)
3063
+ .with_context(|| format!("Invalid plugin base URL {}", trimmed))?;
3064
+ Ok(parsed.to_string().trim_end_matches('/').to_string())
3065
+ }
3066
+
3067
+ fn compose_plugin_archive_url(base_url: &str, name: &str, version: &str) -> Result<String> {
3068
+ let plugin = name.trim();
3069
+ let release = version.trim();
3070
+ if plugin.is_empty() {
3071
+ return Err(anyhow!("Plugin name is required"));
3072
+ }
3073
+ if release.is_empty() {
3074
+ return Err(anyhow!("Plugin version is required"));
3075
+ }
3076
+ if plugin.contains('/') || plugin.contains('\\') {
3077
+ return Err(anyhow!("Plugin name cannot include path separators"));
3078
+ }
3079
+ let base = normalize_public_bucket_base(base_url)?;
3080
+ Ok(format!("{}/{}/{}/{}.zip", base, plugin, release, plugin))
3081
+ }
3082
+
3083
+ fn normalize_sha256_hex(input: &str) -> Result<String> {
3084
+ let cleaned = input.trim().to_ascii_lowercase();
3085
+ if cleaned.len() != 64 {
3086
+ return Err(anyhow!(
3087
+ "SHA-256 must be a 64-character hex string, got length {}",
3088
+ cleaned.len()
3089
+ ));
3090
+ }
3091
+ if !cleaned
3092
+ .chars()
3093
+ .all(|value| matches!(value, '0'..='9' | 'a'..='f'))
3094
+ {
3095
+ return Err(anyhow!("SHA-256 contains non-hex characters"));
3096
+ }
3097
+ Ok(cleaned)
3098
+ }
3099
+
3100
+ async fn fetch_plugin_index_value(
3101
+ client: &reqwest::Client,
3102
+ base_url: &str,
3103
+ index_path: &str,
3104
+ ) -> Result<serde_json::Value> {
3105
+ let base = normalize_public_bucket_base(base_url)?;
3106
+ let normalized_index_path = index_path.trim().trim_start_matches('/');
3107
+ if normalized_index_path.is_empty() {
3108
+ return Err(anyhow!("index path cannot be empty"));
3109
+ }
3110
+ let url = format!("{}/{}", base, normalized_index_path);
3111
+ let response = with_cli_headers(client.get(url.clone()))
3112
+ .send()
3113
+ .await
3114
+ .with_context(|| format!("Failed to fetch plugin index {}", url))?;
3115
+ if !response.status().is_success() {
3116
+ let body = read_error_body(response).await;
3117
+ return Err(anyhow!("plugin index fetch failed: {}", body));
3118
+ }
3119
+ let body = response
3120
+ .text()
3121
+ .await
3122
+ .with_context(|| format!("Failed to read plugin index {}", url))?;
3123
+ serde_json::from_str(&body)
3124
+ .or_else(|_| json5::from_str(&body))
3125
+ .with_context(|| format!("Plugin index is not valid JSON/JSONC: {}", url))
3126
+ }
3127
+
3128
+ async fn fetch_plugin_index(
3129
+ client: &reqwest::Client,
3130
+ base_url: &str,
3131
+ index_path: &str,
3132
+ ) -> Result<PluginIndexFile> {
3133
+ let value = fetch_plugin_index_value(client, base_url, index_path).await?;
3134
+ let parsed: PluginIndexFile = serde_json::from_value(value)
3135
+ .with_context(|| "Plugin index does not match expected schema")?;
3136
+ Ok(parsed)
3137
+ }
3138
+
3139
+ fn resolve_plugin_from_index(
3140
+ index: &PluginIndexFile,
3141
+ name: &str,
3142
+ requested_version: &str,
3143
+ base_url: &str,
3144
+ ) -> Result<(String, String, Option<String>)> {
3145
+ let plugin_name = name.trim();
3146
+ let version_request = requested_version.trim();
3147
+ if plugin_name.is_empty() {
3148
+ return Err(anyhow!("Plugin name is required"));
3149
+ }
3150
+ if version_request.is_empty() {
3151
+ return Err(anyhow!("Plugin version is required"));
3152
+ }
3153
+
3154
+ let plugin = index
3155
+ .plugins
3156
+ .iter()
3157
+ .find(|entry| entry.name.eq_ignore_ascii_case(plugin_name))
3158
+ .ok_or_else(|| anyhow!("Plugin {} not found in index", plugin_name))?;
3159
+
3160
+ let resolved_version = if version_request.eq_ignore_ascii_case("latest") {
3161
+ plugin
3162
+ .latest
3163
+ .as_ref()
3164
+ .map(|value| value.trim().to_string())
3165
+ .or_else(|| {
3166
+ plugin
3167
+ .versions
3168
+ .first()
3169
+ .map(|entry| entry.version.trim().to_string())
3170
+ })
3171
+ .ok_or_else(|| anyhow!("Plugin {} has no versions in index", plugin_name))?
3172
+ } else {
3173
+ version_request.to_string()
3174
+ };
1201
3175
 
1202
- let scopes = if args.scope.is_empty() {
1203
- return Err(anyhow!("At least one --scope must be provided"));
3176
+ let version_entry = plugin
3177
+ .versions
3178
+ .iter()
3179
+ .find(|entry| entry.version.trim() == resolved_version)
3180
+ .ok_or_else(|| {
3181
+ anyhow!(
3182
+ "Version {} for plugin {} not found in index",
3183
+ resolved_version,
3184
+ plugin_name
3185
+ )
3186
+ })?;
3187
+
3188
+ let archive_url = if let Some(url) = version_entry.archive_url.as_ref() {
3189
+ let trimmed = url.trim();
3190
+ if trimmed.is_empty() {
3191
+ compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
3192
+ } else {
3193
+ trimmed.to_string()
3194
+ }
1204
3195
  } else {
1205
- args.scope
3196
+ compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
1206
3197
  };
1207
3198
 
1208
- let body = serde_json::to_value(CreateApiTokenRequest {
1209
- name: args.name,
1210
- org_id: args.org_id,
1211
- project_id: args.project_id,
1212
- scopes,
1213
- expires_in_days: args.expires_in_days,
1214
- })?;
3199
+ let expected_sha256 = version_entry
3200
+ .sha256
3201
+ .as_ref()
3202
+ .map(|value| normalize_sha256_hex(value))
3203
+ .transpose()?;
1215
3204
 
1216
- let response = authed_request(
1217
- client,
1218
- &mut session,
1219
- Method::POST,
1220
- "/auth/tokens",
1221
- Some(body),
1222
- )
1223
- .await?;
1224
- if !response.status().is_success() {
1225
- let body_text = read_error_body(response).await;
1226
- return Err(anyhow!("token create failed: {}", body_text));
3205
+ Ok((resolved_version, archive_url, expected_sha256))
3206
+ }
3207
+
3208
+ fn compute_sha256_hex(path: &Path) -> Result<String> {
3209
+ let mut file =
3210
+ fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
3211
+ let mut hasher = sha2::Sha256::new();
3212
+ let mut buffer = [0u8; 64 * 1024];
3213
+ loop {
3214
+ let read = file
3215
+ .read(&mut buffer)
3216
+ .with_context(|| format!("Failed reading {}", path.display()))?;
3217
+ if read == 0 {
3218
+ break;
3219
+ }
3220
+ hasher.update(&buffer[..read]);
1227
3221
  }
1228
- let payload: CreateApiTokenResponse = response.json().await?;
1229
- println!("{}", serde_json::to_string_pretty(&payload)?);
1230
- save_session(&session)?;
1231
- Ok(())
3222
+ let digest = hasher.finalize();
3223
+ Ok(format!("{:x}", digest))
1232
3224
  }
1233
3225
 
1234
- async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -> Result<()> {
1235
- let mut session = load_session()?;
1236
- apply_base_url_override(&mut session, args.base_url);
3226
+ async fn download_plugin_archive_to_file(
3227
+ client: &reqwest::Client,
3228
+ url: &str,
3229
+ destination: &Path,
3230
+ ) -> Result<()> {
3231
+ if let Some(parent) = destination.parent() {
3232
+ tokio_fs::create_dir_all(parent)
3233
+ .await
3234
+ .with_context(|| format!("Failed to create {}", parent.display()))?;
3235
+ }
1237
3236
 
1238
- let path = format!("/auth/tokens/{}", args.token_id);
1239
- let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
3237
+ let response = with_cli_headers(client.get(url.to_string()))
3238
+ .send()
3239
+ .await
3240
+ .with_context(|| format!("Failed to download plugin archive {}", url))?;
1240
3241
  if !response.status().is_success() {
1241
3242
  let body = read_error_body(response).await;
1242
- return Err(anyhow!("token revoke failed: {}", body));
3243
+ return Err(anyhow!("Plugin download failed: {}", body));
1243
3244
  }
1244
- let payload: serde_json::Value = response.json().await?;
1245
- println!("{}", serde_json::to_string_pretty(&payload)?);
1246
- save_session(&session)?;
3245
+
3246
+ let mut file = tokio_fs::File::create(destination)
3247
+ .await
3248
+ .with_context(|| format!("Failed to create {}", destination.display()))?;
3249
+ let mut stream = response;
3250
+ while let Some(chunk) = stream
3251
+ .chunk()
3252
+ .await
3253
+ .with_context(|| format!("Failed while downloading {}", url))?
3254
+ {
3255
+ file.write_all(&chunk)
3256
+ .await
3257
+ .with_context(|| format!("Failed writing {}", destination.display()))?;
3258
+ }
3259
+ file.flush()
3260
+ .await
3261
+ .with_context(|| format!("Failed to finalize {}", destination.display()))?;
1247
3262
  Ok(())
1248
3263
  }
1249
3264
 
1250
- async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -> Result<()> {
1251
- let mut session = load_session()?;
1252
- apply_base_url_override(&mut session, args.base_url);
3265
+ fn extract_zip_file(zip_path: &Path, destination: &Path) -> Result<()> {
3266
+ let file = fs::File::open(zip_path)
3267
+ .with_context(|| format!("Failed to open {}", zip_path.display()))?;
3268
+ let mut archive = zip::ZipArchive::new(file).with_context(|| "Failed to read zip archive")?;
3269
+
3270
+ for index in 0..archive.len() {
3271
+ let mut file = archive
3272
+ .by_index(index)
3273
+ .with_context(|| format!("Failed to read zip entry {}", index))?;
3274
+ let Some(name) = file.enclosed_name().map(|value| value.to_path_buf()) else {
3275
+ continue;
3276
+ };
3277
+ let output_path = destination.join(name);
3278
+
3279
+ if file.is_dir() {
3280
+ fs::create_dir_all(&output_path)
3281
+ .with_context(|| format!("Failed to create {}", output_path.display()))?;
3282
+ continue;
3283
+ }
1253
3284
 
1254
- let path = match args.org_id {
1255
- Some(org_id) if !org_id.trim().is_empty() => {
1256
- format!("/core/projects?orgId={}", org_id.trim())
3285
+ if let Some(parent) = output_path.parent() {
3286
+ fs::create_dir_all(parent)
3287
+ .with_context(|| format!("Failed to create {}", parent.display()))?;
1257
3288
  }
1258
- _ => "/core/projects".to_string(),
1259
- };
1260
3289
 
1261
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1262
- if !response.status().is_success() {
1263
- let body = read_error_body(response).await;
1264
- 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()))?;
1265
3294
  }
1266
- let payload: ListProjectsResponse = response.json().await?;
1267
- println!("{}", serde_json::to_string_pretty(&payload.projects)?);
1268
- save_session(&session)?;
1269
3295
  Ok(())
1270
3296
  }
1271
3297
 
1272
- async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArgs) -> Result<()> {
1273
- let mut session = load_session()?;
1274
- 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();
1275
3301
 
1276
- let response = authed_request(
1277
- client,
1278
- &mut session,
1279
- Method::POST,
1280
- "/core/projects",
1281
- Some(serde_json::json!({
1282
- "orgId": args.org_id,
1283
- "name": args.name,
1284
- "description": args.description
1285
- })),
1286
- )
1287
- .await?;
1288
- if !response.status().is_success() {
1289
- let body = read_error_body(response).await;
1290
- 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
+ }
1291
3326
  }
1292
- let payload: ProjectResponse = response.json().await?;
1293
- println!("{}", serde_json::to_string_pretty(&payload.project)?);
1294
- save_session(&session)?;
1295
- 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)
1296
3333
  }
1297
3334
 
1298
- async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) -> Result<()> {
1299
- let mut session = load_session()?;
1300
- 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
+ }
1301
3349
 
1302
- let path = format!("/core/projects/{}", args.project_id);
1303
- let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
1304
- if !response.status().is_success() {
1305
- let body = read_error_body(response).await;
1306
- 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()))?;
1307
3360
  }
1308
- let payload: ProjectResponse = response.json().await?;
1309
- println!("{}", serde_json::to_string_pretty(&payload.project)?);
1310
- save_session(&session)?;
1311
3361
  Ok(())
1312
3362
  }
1313
3363
 
1314
- async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArgs) -> Result<()> {
1315
- let mut session = load_session()?;
1316
- 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
+ }
1317
3387
 
1318
- let mut body = serde_json::Map::new();
1319
- if let Some(name) = args.name {
1320
- 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
+ ));
1321
3397
  }
1322
- if args.clear_description {
1323
- body.insert("description".to_string(), serde_json::Value::Null);
1324
- } else if let Some(description) = args.description {
1325
- body.insert(
1326
- "description".to_string(),
1327
- serde_json::Value::String(description),
1328
- );
3398
+ let target = resolve_link_target(
3399
+ &config,
3400
+ args.project_id.as_deref(),
3401
+ args.uproject.as_deref(),
3402
+ )?;
3403
+
3404
+ let plugin_name = args.name.trim();
3405
+ if plugin_name.is_empty() {
3406
+ return Err(anyhow!("Plugin name is required"));
3407
+ }
3408
+ if plugin_name.contains('/') || plugin_name.contains('\\') {
3409
+ return Err(anyhow!("Plugin name cannot include path separators"));
1329
3410
  }
1330
3411
 
1331
- 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 {
1332
3455
  return Err(anyhow!(
1333
- "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()
1334
3458
  ));
1335
3459
  }
3460
+ if destination_dir.exists() && args.force {
3461
+ remove_existing_path(&destination_dir)?;
3462
+ }
1336
3463
 
1337
- let path = format!("/core/projects/{}", args.project_id);
1338
- let response = authed_request(
1339
- client,
1340
- &mut session,
1341
- Method::PATCH,
1342
- &path,
1343
- Some(serde_json::Value::Object(body)),
1344
- )
1345
- .await?;
1346
- if !response.status().is_success() {
1347
- let body = read_error_body(response).await;
1348
- return Err(anyhow!("project update failed: {}", body));
3464
+ let temp_root = std::env::temp_dir().join(format!(
3465
+ "reallink-plugin-install-{}-{}",
3466
+ plugin_name,
3467
+ now_epoch_ms()
3468
+ ));
3469
+ if temp_root.exists() {
3470
+ remove_existing_path(&temp_root)?;
1349
3471
  }
1350
- let payload: ProjectResponse = response.json().await?;
1351
- println!("{}", serde_json::to_string_pretty(&payload.project)?);
1352
- 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
+ );
1353
3510
  Ok(())
1354
3511
  }
1355
3512
 
1356
- async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArgs) -> Result<()> {
1357
- let mut session = load_session()?;
1358
- apply_base_url_override(&mut session, args.base_url);
1359
-
1360
- let path = format!("/core/projects/{}", args.project_id);
1361
- let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
1362
- if !response.status().is_success() {
1363
- let body = read_error_body(response).await;
1364
- return Err(anyhow!("project delete failed: {}", body));
1365
- }
1366
- let payload: serde_json::Value = response.json().await?;
1367
- println!("{}", serde_json::to_string_pretty(&payload)?);
1368
- save_session(&session)?;
3513
+ pub(crate) async fn link_plugin_list_command(
3514
+ client: &reqwest::Client,
3515
+ args: LinkPluginListArgs,
3516
+ ) -> Result<()> {
3517
+ let parsed = fetch_plugin_index_value(client, &args.base_url, &args.index_path).await?;
3518
+ println!("{}", serde_json::to_string_pretty(&parsed)?);
1369
3519
  Ok(())
1370
3520
  }
1371
3521
 
@@ -1605,7 +3755,7 @@ async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs)
1605
3755
  let fallback_name = base_name_from_virtual_path(&metadata_payload.asset.file_name);
1606
3756
 
1607
3757
  let mut output_path = args
1608
- .output
3758
+ .output_path
1609
3759
  .unwrap_or_else(|| PathBuf::from(fallback_name.as_str()));
1610
3760
  if output_path.exists() && output_path.is_dir() {
1611
3761
  output_path = output_path.join(fallback_name.as_str());
@@ -2121,23 +4271,58 @@ fn run_and_check_status(mut command: Command, context: &str) -> Result<()> {
2121
4271
  Ok(())
2122
4272
  }
2123
4273
 
2124
- async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) -> Result<()> {
4274
+ async fn self_update_command(
4275
+ client: &reqwest::Client,
4276
+ args: SelfUpdateArgs,
4277
+ output: OutputFormat,
4278
+ ) -> Result<()> {
2125
4279
  let current = env!("CARGO_PKG_VERSION");
2126
4280
  let latest = fetch_latest_cli_version(client).await;
2127
4281
 
2128
4282
  let Some(latest_version) = latest else {
2129
- 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)?;
2130
4290
  return Ok(());
2131
4291
  };
2132
4292
 
2133
4293
  if !is_newer_version(current, &latest_version) {
2134
- 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
+ )?;
2135
4306
  return Ok(());
2136
4307
  }
2137
4308
 
2138
- println!("Update available: {} -> {}", current, latest_version);
2139
4309
  if args.check {
2140
- 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
+ )?;
2141
4326
  return Ok(());
2142
4327
  }
2143
4328
 
@@ -2156,9 +4341,19 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
2156
4341
  },
2157
4342
  "npm self-update",
2158
4343
  )?;
2159
- println!(
2160
- "Updated via npm. Restart your shell if `reallink --version` still shows old version."
2161
- );
4344
+ let payload = serde_json::json!({
4345
+ "ok": true,
4346
+ "checked": true,
4347
+ "updated": true,
4348
+ "method": "npm",
4349
+ "currentVersion": current,
4350
+ "latestVersion": latest_version
4351
+ });
4352
+ emit_text_or_json(
4353
+ output,
4354
+ "Updated via npm. Restart your shell if `reallink --version` still shows old version.",
4355
+ payload,
4356
+ )?;
2162
4357
  return Ok(());
2163
4358
  }
2164
4359
 
@@ -2188,33 +4383,176 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
2188
4383
  )?;
2189
4384
  }
2190
4385
 
2191
- 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
+ )?;
2192
4399
  Ok(())
2193
4400
  }
2194
4401
 
2195
- #[tokio::main]
2196
- async fn main() -> Result<()> {
2197
- 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<()> {
2198
4514
  let client = reqwest::Client::builder()
2199
4515
  .user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
2200
4516
  .build()?;
2201
4517
 
2202
4518
  if !matches!(&cli.command, Commands::SelfUpdate(_)) {
2203
4519
  let allow_update_fetch = matches!(&cli.command, Commands::Login(_));
2204
- maybe_notify_update(&client, false, allow_update_fetch).await;
4520
+ maybe_notify_update(&client, false, allow_update_fetch, cli.output).await;
2205
4521
  }
2206
4522
 
2207
4523
  match cli.command {
2208
- Commands::Login(args) => login_command(&client, args).await?,
4524
+ Commands::Login(args) => login_command(&client, args, cli.output).await?,
2209
4525
  Commands::Whoami(args) => whoami_command(&client, args).await?,
2210
- Commands::Logout => logout_command(&client).await?,
2211
- Commands::SelfUpdate(args) => self_update_command(&client, args).await?,
4526
+ Commands::Logout => logout_command(&client, cli.output).await?,
4527
+ Commands::SelfUpdate(args) => self_update_command(&client, args, cli.output).await?,
4528
+ Commands::Org { command } => match command {
4529
+ OrgCommands::List(args) => org_list_command(&client, args).await?,
4530
+ OrgCommands::Create(args) => org_create_command(&client, args).await?,
4531
+ OrgCommands::Get(args) => org_get_command(&client, args).await?,
4532
+ OrgCommands::Update(args) => org_update_command(&client, args).await?,
4533
+ OrgCommands::Delete(args) => org_delete_command(&client, args).await?,
4534
+ OrgCommands::Invites(args) => org_invites_command(&client, args).await?,
4535
+ OrgCommands::Invite(args) => org_invite_command(&client, args).await?,
4536
+ OrgCommands::Members(args) => org_members_command(&client, args).await?,
4537
+ OrgCommands::AddMember(args) => org_add_member_command(&client, args).await?,
4538
+ OrgCommands::UpdateMember(args) => org_update_member_command(&client, args).await?,
4539
+ OrgCommands::RemoveMember(args) => org_remove_member_command(&client, args).await?,
4540
+ },
4541
+ Commands::Link { command } => unreal::dispatch(&client, command).await?,
2212
4542
  Commands::Project { command } => match command {
2213
4543
  ProjectCommands::List(args) => project_list_command(&client, args).await?,
2214
4544
  ProjectCommands::Create(args) => project_create_command(&client, args).await?,
2215
4545
  ProjectCommands::Get(args) => project_get_command(&client, args).await?,
2216
4546
  ProjectCommands::Update(args) => project_update_command(&client, args).await?,
2217
4547
  ProjectCommands::Delete(args) => project_delete_command(&client, args).await?,
4548
+ ProjectCommands::Members(args) => project_members_command(&client, args).await?,
4549
+ ProjectCommands::AddMember(args) => project_add_member_command(&client, args).await?,
4550
+ ProjectCommands::UpdateMember(args) => {
4551
+ project_update_member_command(&client, args).await?
4552
+ }
4553
+ ProjectCommands::RemoveMember(args) => {
4554
+ project_remove_member_command(&client, args).await?
4555
+ }
2218
4556
  },
2219
4557
  Commands::Token { command } => match command {
2220
4558
  TokenCommands::List(args) => token_list_command(&client, args).await?,
@@ -2244,3 +4582,27 @@ async fn main() -> Result<()> {
2244
4582
 
2245
4583
  Ok(())
2246
4584
  }
4585
+
4586
+ #[tokio::main]
4587
+ async fn main() {
4588
+ let cli = Cli::parse();
4589
+ let output = cli.output;
4590
+ if let Err(error) = run_cli(cli).await {
4591
+ match output {
4592
+ OutputFormat::Json => {
4593
+ let payload = serde_json::json!({
4594
+ "ok": false,
4595
+ "error": format!("{:#}", error)
4596
+ });
4597
+ if let Err(emit_error) = print_json(&payload) {
4598
+ eprintln!("Error: {}", error);
4599
+ eprintln!("Failed to emit JSON error payload: {}", emit_error);
4600
+ }
4601
+ }
4602
+ OutputFormat::Text => {
4603
+ eprintln!("Error: {:#}", error);
4604
+ }
4605
+ }
4606
+ std::process::exit(1);
4607
+ }
4608
+ }