reallink-cli 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +149 -0
- package/package.json +4 -3
- package/prebuilt/win32-x64/reallink-cli.exe +0 -0
- package/rust/Cargo.lock +121 -1
- package/rust/Cargo.toml +3 -1
- package/rust/src/generated/contract.rs +52 -0
- package/rust/src/generated/mod.rs +1 -0
- package/rust/src/logs.rs +277 -0
- package/rust/src/main.rs +3927 -357
- package/rust/src/unreal.rs +266 -0
package/rust/src/main.rs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
use anyhow::{anyhow, Context, Result};
|
|
2
|
-
use clap::{ArgAction, Args, Parser, Subcommand};
|
|
2
|
+
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
|
|
3
3
|
use reqwest::{Method, StatusCode};
|
|
4
4
|
use serde::{Deserialize, Serialize};
|
|
5
|
+
use sha2::Digest;
|
|
5
6
|
use std::fs;
|
|
6
|
-
use std::io::{self, Write};
|
|
7
|
+
use std::io::{self, Read, Write};
|
|
7
8
|
use std::path::{Path, PathBuf};
|
|
8
9
|
use std::process::Command;
|
|
9
10
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
@@ -11,10 +12,20 @@ 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
|
+
mod logs;
|
|
18
|
+
use unreal::{
|
|
19
|
+
LinkDoctorArgs, LinkOpenArgs, LinkPathsArgs, LinkPluginInstallArgs, LinkPluginListArgs,
|
|
20
|
+
LinkRemoveArgs, LinkRunArgs, LinkUnrealArgs, LinkUseArgs, PluginIndexFile, UnrealLinkRecord,
|
|
21
|
+
UnrealLinksConfig,
|
|
22
|
+
};
|
|
23
|
+
|
|
14
24
|
const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
|
|
15
25
|
const CONFIG_DIR_ENV: &str = "REALLINK_CONFIG_DIR";
|
|
16
26
|
const SESSION_DIR_NAME: &str = "reallink";
|
|
17
27
|
const SESSION_FILE_NAME: &str = "session.json";
|
|
28
|
+
const UNREAL_LINKS_FILE_NAME: &str = "unreal-links.json";
|
|
18
29
|
const UPDATE_CACHE_FILE_NAME: &str = "update-check.json";
|
|
19
30
|
const VERSION_CHECK_INTERVAL_MS: u128 = 24 * 60 * 60 * 1000;
|
|
20
31
|
const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
|
|
@@ -27,16 +38,38 @@ const DEFAULT_VERSION_FETCH_TIMEOUT_MS: u64 = 800;
|
|
|
27
38
|
about = "Reallink CLI"
|
|
28
39
|
)]
|
|
29
40
|
struct Cli {
|
|
41
|
+
#[arg(
|
|
42
|
+
long = "format",
|
|
43
|
+
global = true,
|
|
44
|
+
value_enum,
|
|
45
|
+
default_value_t = OutputFormat::Json,
|
|
46
|
+
help = "CLI output format (json is agent-friendly)"
|
|
47
|
+
)]
|
|
48
|
+
output: OutputFormat,
|
|
30
49
|
#[command(subcommand)]
|
|
31
50
|
command: Commands,
|
|
32
51
|
}
|
|
33
52
|
|
|
53
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
54
|
+
enum OutputFormat {
|
|
55
|
+
Json,
|
|
56
|
+
Text,
|
|
57
|
+
}
|
|
58
|
+
|
|
34
59
|
#[derive(Subcommand)]
|
|
35
60
|
enum Commands {
|
|
36
61
|
Login(LoginArgs),
|
|
37
62
|
Whoami(BaseArgs),
|
|
38
63
|
Logout,
|
|
39
64
|
SelfUpdate(SelfUpdateArgs),
|
|
65
|
+
Org {
|
|
66
|
+
#[command(subcommand)]
|
|
67
|
+
command: OrgCommands,
|
|
68
|
+
},
|
|
69
|
+
Link {
|
|
70
|
+
#[command(subcommand)]
|
|
71
|
+
command: unreal::LinkCommands,
|
|
72
|
+
},
|
|
40
73
|
Project {
|
|
41
74
|
#[command(subcommand)]
|
|
42
75
|
command: ProjectCommands,
|
|
@@ -53,6 +86,10 @@ enum Commands {
|
|
|
53
86
|
#[command(subcommand)]
|
|
54
87
|
command: ToolCommands,
|
|
55
88
|
},
|
|
89
|
+
Logs {
|
|
90
|
+
#[command(subcommand)]
|
|
91
|
+
command: LogsCommands,
|
|
92
|
+
},
|
|
56
93
|
}
|
|
57
94
|
|
|
58
95
|
#[derive(Args)]
|
|
@@ -93,29 +130,91 @@ enum ProjectCommands {
|
|
|
93
130
|
Get(ProjectGetArgs),
|
|
94
131
|
Update(ProjectUpdateArgs),
|
|
95
132
|
Delete(ProjectDeleteArgs),
|
|
133
|
+
Members(ProjectMembersArgs),
|
|
134
|
+
AddMember(ProjectAddMemberArgs),
|
|
135
|
+
UpdateMember(ProjectUpdateMemberArgs),
|
|
136
|
+
RemoveMember(ProjectRemoveMemberArgs),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#[derive(Subcommand)]
|
|
140
|
+
enum OrgCommands {
|
|
141
|
+
List(BaseArgs),
|
|
142
|
+
Create(OrgCreateArgs),
|
|
143
|
+
Get(OrgGetArgs),
|
|
144
|
+
Update(OrgUpdateArgs),
|
|
145
|
+
Delete(OrgDeleteArgs),
|
|
146
|
+
Invites(OrgInvitesArgs),
|
|
147
|
+
Invite(OrgInviteArgs),
|
|
148
|
+
Members(OrgMembersArgs),
|
|
149
|
+
AddMember(OrgAddMemberArgs),
|
|
150
|
+
UpdateMember(OrgUpdateMemberArgs),
|
|
151
|
+
RemoveMember(OrgRemoveMemberArgs),
|
|
96
152
|
}
|
|
97
153
|
|
|
98
154
|
#[derive(Subcommand)]
|
|
99
155
|
enum FileCommands {
|
|
100
156
|
List(FileListArgs),
|
|
157
|
+
Tree(FileTreeArgs),
|
|
101
158
|
Get(FileGetArgs),
|
|
102
159
|
Stat(FileStatArgs),
|
|
160
|
+
Thumbnail(FileThumbnailArgs),
|
|
103
161
|
Download(FileDownloadArgs),
|
|
104
162
|
Upload(FileUploadArgs),
|
|
105
163
|
Mkdir(FileMkdirArgs),
|
|
106
164
|
Move(FileMoveArgs),
|
|
165
|
+
Set(FileSetArgs),
|
|
166
|
+
MoveFolder(FileMoveFolderArgs),
|
|
167
|
+
Rmdir(FileRemoveFolderArgs),
|
|
107
168
|
Remove(FileRemoveArgs),
|
|
108
169
|
}
|
|
109
170
|
|
|
171
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
|
|
172
|
+
enum FileThumbnailSize {
|
|
173
|
+
Small,
|
|
174
|
+
Medium,
|
|
175
|
+
Large,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
impl FileThumbnailSize {
|
|
179
|
+
fn as_str(self) -> &'static str {
|
|
180
|
+
match self {
|
|
181
|
+
FileThumbnailSize::Small => "small",
|
|
182
|
+
FileThumbnailSize::Medium => "medium",
|
|
183
|
+
FileThumbnailSize::Large => "large",
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
110
188
|
#[derive(Subcommand)]
|
|
111
189
|
enum ToolCommands {
|
|
112
190
|
List(ToolListArgs),
|
|
113
191
|
Register(ToolRegisterArgs),
|
|
192
|
+
Publish(ToolPublishArgs),
|
|
114
193
|
Enable(ToolEnableArgs),
|
|
115
194
|
Disable(ToolDisableArgs),
|
|
116
195
|
Run(ToolRunArgs),
|
|
117
196
|
Runs(ToolRunsArgs),
|
|
118
197
|
GetRun(ToolGetRunArgs),
|
|
198
|
+
RunEvents(ToolRunEventsArgs),
|
|
199
|
+
Local {
|
|
200
|
+
#[command(subcommand)]
|
|
201
|
+
command: ToolLocalCommands,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#[derive(Subcommand)]
|
|
206
|
+
enum ToolLocalCommands {
|
|
207
|
+
Catalog(ToolLocalCatalogArgs),
|
|
208
|
+
Install(ToolLocalInstallArgs),
|
|
209
|
+
CompleteRun(ToolLocalCompleteRunArgs),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#[derive(Subcommand)]
|
|
213
|
+
enum LogsCommands {
|
|
214
|
+
Status,
|
|
215
|
+
Consent(LogsConsentArgs),
|
|
216
|
+
Tail(LogsTailArgs),
|
|
217
|
+
Upload(LogsUploadArgs),
|
|
119
218
|
}
|
|
120
219
|
|
|
121
220
|
#[derive(Args)]
|
|
@@ -149,6 +248,12 @@ struct FileListArgs {
|
|
|
149
248
|
#[arg(long)]
|
|
150
249
|
path: Option<String>,
|
|
151
250
|
#[arg(long)]
|
|
251
|
+
offset: Option<u32>,
|
|
252
|
+
#[arg(long)]
|
|
253
|
+
limit: Option<u32>,
|
|
254
|
+
#[arg(long)]
|
|
255
|
+
include_folder_markers: Option<bool>,
|
|
256
|
+
#[arg(long)]
|
|
152
257
|
base_url: Option<String>,
|
|
153
258
|
}
|
|
154
259
|
|
|
@@ -160,6 +265,14 @@ struct FileGetArgs {
|
|
|
160
265
|
base_url: Option<String>,
|
|
161
266
|
}
|
|
162
267
|
|
|
268
|
+
#[derive(Args)]
|
|
269
|
+
struct FileTreeArgs {
|
|
270
|
+
#[arg(long)]
|
|
271
|
+
project_id: String,
|
|
272
|
+
#[arg(long)]
|
|
273
|
+
base_url: Option<String>,
|
|
274
|
+
}
|
|
275
|
+
|
|
163
276
|
#[derive(Args)]
|
|
164
277
|
struct FileStatArgs {
|
|
165
278
|
#[arg(long)]
|
|
@@ -169,11 +282,23 @@ struct FileStatArgs {
|
|
|
169
282
|
}
|
|
170
283
|
|
|
171
284
|
#[derive(Args)]
|
|
172
|
-
struct
|
|
285
|
+
struct FileThumbnailArgs {
|
|
173
286
|
#[arg(long)]
|
|
174
287
|
asset_id: String,
|
|
288
|
+
#[arg(long, value_enum, default_value_t = FileThumbnailSize::Medium)]
|
|
289
|
+
size: FileThumbnailSize,
|
|
290
|
+
#[arg(long = "output")]
|
|
291
|
+
output_path: Option<PathBuf>,
|
|
175
292
|
#[arg(long)]
|
|
176
|
-
|
|
293
|
+
base_url: Option<String>,
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#[derive(Args)]
|
|
297
|
+
struct FileDownloadArgs {
|
|
298
|
+
#[arg(long)]
|
|
299
|
+
asset_id: String,
|
|
300
|
+
#[arg(long = "output")]
|
|
301
|
+
output_path: Option<PathBuf>,
|
|
177
302
|
#[arg(
|
|
178
303
|
long,
|
|
179
304
|
help = "Resume download from existing output file size using HTTP Range"
|
|
@@ -205,6 +330,8 @@ struct FileMkdirArgs {
|
|
|
205
330
|
project_id: String,
|
|
206
331
|
#[arg(long)]
|
|
207
332
|
path: String,
|
|
333
|
+
#[arg(long, default_value = "private")]
|
|
334
|
+
visibility: String,
|
|
208
335
|
#[arg(long)]
|
|
209
336
|
base_url: Option<String>,
|
|
210
337
|
}
|
|
@@ -219,6 +346,48 @@ struct FileMoveArgs {
|
|
|
219
346
|
base_url: Option<String>,
|
|
220
347
|
}
|
|
221
348
|
|
|
349
|
+
#[derive(Args)]
|
|
350
|
+
struct FileSetArgs {
|
|
351
|
+
#[arg(long)]
|
|
352
|
+
asset_id: String,
|
|
353
|
+
#[arg(long)]
|
|
354
|
+
file_name: Option<String>,
|
|
355
|
+
#[arg(long)]
|
|
356
|
+
asset_type: Option<String>,
|
|
357
|
+
#[arg(long)]
|
|
358
|
+
visibility: Option<String>,
|
|
359
|
+
#[arg(long)]
|
|
360
|
+
base_url: Option<String>,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
#[derive(Args)]
|
|
364
|
+
struct FileMoveFolderArgs {
|
|
365
|
+
#[arg(long)]
|
|
366
|
+
project_id: String,
|
|
367
|
+
#[arg(long)]
|
|
368
|
+
source_path: String,
|
|
369
|
+
#[arg(long, default_value = "")]
|
|
370
|
+
target_path: String,
|
|
371
|
+
#[arg(long)]
|
|
372
|
+
base_url: Option<String>,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
#[derive(Args)]
|
|
376
|
+
struct FileRemoveFolderArgs {
|
|
377
|
+
#[arg(long)]
|
|
378
|
+
project_id: String,
|
|
379
|
+
#[arg(long)]
|
|
380
|
+
path: String,
|
|
381
|
+
#[arg(long, default_value_t = false)]
|
|
382
|
+
recursive: bool,
|
|
383
|
+
#[arg(long, default_value_t = false)]
|
|
384
|
+
dry_run: bool,
|
|
385
|
+
#[arg(long, default_value_t = true)]
|
|
386
|
+
include_folder_markers: bool,
|
|
387
|
+
#[arg(long)]
|
|
388
|
+
base_url: Option<String>,
|
|
389
|
+
}
|
|
390
|
+
|
|
222
391
|
#[derive(Args)]
|
|
223
392
|
struct FileRemoveArgs {
|
|
224
393
|
#[arg(long)]
|
|
@@ -263,6 +432,20 @@ struct ToolEnableArgs {
|
|
|
263
432
|
base_url: Option<String>,
|
|
264
433
|
}
|
|
265
434
|
|
|
435
|
+
#[derive(Args)]
|
|
436
|
+
struct ToolPublishArgs {
|
|
437
|
+
#[arg(long)]
|
|
438
|
+
tool_id: String,
|
|
439
|
+
#[arg(long)]
|
|
440
|
+
channel: Option<String>,
|
|
441
|
+
#[arg(long)]
|
|
442
|
+
visibility: Option<String>,
|
|
443
|
+
#[arg(long)]
|
|
444
|
+
notes: Option<String>,
|
|
445
|
+
#[arg(long)]
|
|
446
|
+
base_url: Option<String>,
|
|
447
|
+
}
|
|
448
|
+
|
|
266
449
|
#[derive(Args)]
|
|
267
450
|
struct ToolDisableArgs {
|
|
268
451
|
#[arg(long)]
|
|
@@ -321,6 +504,114 @@ struct ToolGetRunArgs {
|
|
|
321
504
|
base_url: Option<String>,
|
|
322
505
|
}
|
|
323
506
|
|
|
507
|
+
#[derive(Args)]
|
|
508
|
+
struct ToolRunEventsArgs {
|
|
509
|
+
#[arg(long)]
|
|
510
|
+
run_id: String,
|
|
511
|
+
#[arg(long)]
|
|
512
|
+
limit: Option<u32>,
|
|
513
|
+
#[arg(long)]
|
|
514
|
+
status: Option<String>,
|
|
515
|
+
#[arg(long)]
|
|
516
|
+
stage_prefix: Option<String>,
|
|
517
|
+
#[arg(long, help = "Only include events created after this ISO-8601 timestamp")]
|
|
518
|
+
since: Option<String>,
|
|
519
|
+
#[arg(long, help = "Only include events created at/before this ISO-8601 timestamp")]
|
|
520
|
+
until: Option<String>,
|
|
521
|
+
#[arg(long)]
|
|
522
|
+
base_url: Option<String>,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
#[derive(Args)]
|
|
526
|
+
struct ToolLocalCatalogArgs {
|
|
527
|
+
#[arg(long)]
|
|
528
|
+
org_id: Option<String>,
|
|
529
|
+
#[arg(long)]
|
|
530
|
+
project_id: Option<String>,
|
|
531
|
+
#[arg(long)]
|
|
532
|
+
platform: Option<String>,
|
|
533
|
+
#[arg(long)]
|
|
534
|
+
arch: Option<String>,
|
|
535
|
+
#[arg(long)]
|
|
536
|
+
base_url: Option<String>,
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
#[derive(Args)]
|
|
540
|
+
struct ToolLocalInstallArgs {
|
|
541
|
+
#[arg(long)]
|
|
542
|
+
tool_id: String,
|
|
543
|
+
#[arg(long)]
|
|
544
|
+
org_id: Option<String>,
|
|
545
|
+
#[arg(long)]
|
|
546
|
+
project_id: Option<String>,
|
|
547
|
+
#[arg(long)]
|
|
548
|
+
platform: Option<String>,
|
|
549
|
+
#[arg(long)]
|
|
550
|
+
arch: Option<String>,
|
|
551
|
+
#[arg(long)]
|
|
552
|
+
version: Option<String>,
|
|
553
|
+
#[arg(long = "output")]
|
|
554
|
+
output_path: Option<PathBuf>,
|
|
555
|
+
#[arg(long, help = "Resume download from existing output file size using HTTP Range")]
|
|
556
|
+
resume: bool,
|
|
557
|
+
#[arg(long, help = "Only print install intent, do not download")]
|
|
558
|
+
no_download: bool,
|
|
559
|
+
#[arg(long)]
|
|
560
|
+
base_url: Option<String>,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
#[derive(Args)]
|
|
564
|
+
struct ToolLocalCompleteRunArgs {
|
|
565
|
+
#[arg(long)]
|
|
566
|
+
run_id: String,
|
|
567
|
+
#[arg(long, default_value = "succeeded")]
|
|
568
|
+
status: String,
|
|
569
|
+
#[arg(long, help = "Path to JSON/JSONC file for run output")]
|
|
570
|
+
output_file: Option<PathBuf>,
|
|
571
|
+
#[arg(long, help = "Inline JSON object for run output")]
|
|
572
|
+
output_json: Option<String>,
|
|
573
|
+
#[arg(long)]
|
|
574
|
+
error_message: Option<String>,
|
|
575
|
+
#[arg(long, help = "Path to JSON/JSONC metadata file")]
|
|
576
|
+
metadata_file: Option<PathBuf>,
|
|
577
|
+
#[arg(long)]
|
|
578
|
+
base_url: Option<String>,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
#[derive(Args)]
|
|
582
|
+
struct LogsConsentArgs {
|
|
583
|
+
#[arg(long, default_value_t = false, conflicts_with = "disable")]
|
|
584
|
+
enable: bool,
|
|
585
|
+
#[arg(long, default_value_t = false, conflicts_with = "enable")]
|
|
586
|
+
disable: bool,
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
#[derive(Args)]
|
|
590
|
+
struct LogsTailArgs {
|
|
591
|
+
#[arg(long, default_value_t = 80)]
|
|
592
|
+
lines: usize,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
#[derive(Args)]
|
|
596
|
+
struct LogsUploadArgs {
|
|
597
|
+
#[arg(long)]
|
|
598
|
+
project_id: String,
|
|
599
|
+
#[arg(long, action = ArgAction::Set, default_value_t = true)]
|
|
600
|
+
include_runtime: bool,
|
|
601
|
+
#[arg(long, action = ArgAction::Set, default_value_t = true)]
|
|
602
|
+
include_crash: bool,
|
|
603
|
+
#[arg(long)]
|
|
604
|
+
clear_on_success: bool,
|
|
605
|
+
#[arg(long, default_value = "other")]
|
|
606
|
+
asset_type: String,
|
|
607
|
+
#[arg(long, default_value = "private")]
|
|
608
|
+
visibility: String,
|
|
609
|
+
#[arg(long)]
|
|
610
|
+
dry_run: bool,
|
|
611
|
+
#[arg(long)]
|
|
612
|
+
base_url: Option<String>,
|
|
613
|
+
}
|
|
614
|
+
|
|
324
615
|
#[derive(Args)]
|
|
325
616
|
struct ProjectListArgs {
|
|
326
617
|
#[arg(long)]
|
|
@@ -371,77 +662,217 @@ struct ProjectDeleteArgs {
|
|
|
371
662
|
base_url: Option<String>,
|
|
372
663
|
}
|
|
373
664
|
|
|
374
|
-
#[derive(
|
|
375
|
-
struct
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
updated_at_epoch_ms: u128,
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
384
|
-
struct UpdateCheckCache {
|
|
385
|
-
last_checked_epoch_ms: u128,
|
|
386
|
-
latest_version: Option<String>,
|
|
665
|
+
#[derive(Args)]
|
|
666
|
+
struct OrgCreateArgs {
|
|
667
|
+
#[arg(long)]
|
|
668
|
+
name: String,
|
|
669
|
+
#[arg(long)]
|
|
670
|
+
base_url: Option<String>,
|
|
387
671
|
}
|
|
388
672
|
|
|
389
|
-
#[derive(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
673
|
+
#[derive(Args)]
|
|
674
|
+
struct OrgGetArgs {
|
|
675
|
+
#[arg(long)]
|
|
676
|
+
org_id: String,
|
|
677
|
+
#[arg(long)]
|
|
678
|
+
base_url: Option<String>,
|
|
394
679
|
}
|
|
395
680
|
|
|
396
|
-
#[derive(
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
interval: u64,
|
|
681
|
+
#[derive(Args)]
|
|
682
|
+
struct OrgUpdateArgs {
|
|
683
|
+
#[arg(long)]
|
|
684
|
+
org_id: String,
|
|
685
|
+
#[arg(long)]
|
|
686
|
+
name: String,
|
|
687
|
+
#[arg(long)]
|
|
688
|
+
base_url: Option<String>,
|
|
405
689
|
}
|
|
406
690
|
|
|
407
|
-
#[derive(
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
691
|
+
#[derive(Args)]
|
|
692
|
+
struct OrgDeleteArgs {
|
|
693
|
+
#[arg(long)]
|
|
694
|
+
org_id: String,
|
|
695
|
+
#[arg(long)]
|
|
696
|
+
base_url: Option<String>,
|
|
413
697
|
}
|
|
414
698
|
|
|
415
|
-
#[derive(
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
699
|
+
#[derive(Args)]
|
|
700
|
+
struct OrgMembersArgs {
|
|
701
|
+
#[arg(long)]
|
|
702
|
+
org_id: String,
|
|
703
|
+
#[arg(long)]
|
|
704
|
+
base_url: Option<String>,
|
|
421
705
|
}
|
|
422
706
|
|
|
423
|
-
#[derive(
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
707
|
+
#[derive(Args)]
|
|
708
|
+
struct OrgAddMemberArgs {
|
|
709
|
+
#[arg(long)]
|
|
710
|
+
org_id: String,
|
|
711
|
+
#[arg(long)]
|
|
712
|
+
email: String,
|
|
713
|
+
#[arg(long, default_value = "member")]
|
|
714
|
+
role: String,
|
|
715
|
+
#[arg(long)]
|
|
716
|
+
base_url: Option<String>,
|
|
428
717
|
}
|
|
429
718
|
|
|
430
|
-
#[derive(
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
719
|
+
#[derive(Args)]
|
|
720
|
+
struct OrgUpdateMemberArgs {
|
|
721
|
+
#[arg(long)]
|
|
722
|
+
org_id: String,
|
|
723
|
+
#[arg(long)]
|
|
724
|
+
user_id: String,
|
|
725
|
+
#[arg(long)]
|
|
726
|
+
role: String,
|
|
727
|
+
#[arg(long)]
|
|
728
|
+
base_url: Option<String>,
|
|
434
729
|
}
|
|
435
730
|
|
|
436
|
-
#[derive(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
731
|
+
#[derive(Args)]
|
|
732
|
+
struct OrgRemoveMemberArgs {
|
|
733
|
+
#[arg(long)]
|
|
734
|
+
org_id: String,
|
|
735
|
+
#[arg(long)]
|
|
736
|
+
user_id: String,
|
|
737
|
+
#[arg(long)]
|
|
738
|
+
base_url: Option<String>,
|
|
442
739
|
}
|
|
443
740
|
|
|
444
|
-
#[derive(
|
|
741
|
+
#[derive(Args)]
|
|
742
|
+
struct OrgInvitesArgs {
|
|
743
|
+
#[arg(long)]
|
|
744
|
+
org_id: String,
|
|
745
|
+
#[arg(long)]
|
|
746
|
+
base_url: Option<String>,
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
#[derive(Args)]
|
|
750
|
+
struct OrgInviteArgs {
|
|
751
|
+
#[arg(long)]
|
|
752
|
+
org_id: String,
|
|
753
|
+
#[arg(long)]
|
|
754
|
+
email: String,
|
|
755
|
+
#[arg(long, default_value = "member")]
|
|
756
|
+
role: String,
|
|
757
|
+
#[arg(long, default_value_t = 7)]
|
|
758
|
+
expires_in_days: u32,
|
|
759
|
+
#[arg(long)]
|
|
760
|
+
base_url: Option<String>,
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
#[derive(Args)]
|
|
764
|
+
struct ProjectMembersArgs {
|
|
765
|
+
#[arg(long)]
|
|
766
|
+
project_id: String,
|
|
767
|
+
#[arg(long)]
|
|
768
|
+
base_url: Option<String>,
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
#[derive(Args)]
|
|
772
|
+
struct ProjectAddMemberArgs {
|
|
773
|
+
#[arg(long)]
|
|
774
|
+
project_id: String,
|
|
775
|
+
#[arg(long)]
|
|
776
|
+
email: String,
|
|
777
|
+
#[arg(long, default_value = "viewer")]
|
|
778
|
+
role: String,
|
|
779
|
+
#[arg(long)]
|
|
780
|
+
base_url: Option<String>,
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
#[derive(Args)]
|
|
784
|
+
struct ProjectUpdateMemberArgs {
|
|
785
|
+
#[arg(long)]
|
|
786
|
+
project_id: String,
|
|
787
|
+
#[arg(long)]
|
|
788
|
+
user_id: String,
|
|
789
|
+
#[arg(long)]
|
|
790
|
+
role: String,
|
|
791
|
+
#[arg(long)]
|
|
792
|
+
base_url: Option<String>,
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
#[derive(Args)]
|
|
796
|
+
struct ProjectRemoveMemberArgs {
|
|
797
|
+
#[arg(long)]
|
|
798
|
+
project_id: String,
|
|
799
|
+
#[arg(long)]
|
|
800
|
+
user_id: String,
|
|
801
|
+
#[arg(long)]
|
|
802
|
+
base_url: Option<String>,
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
806
|
+
struct SessionConfig {
|
|
807
|
+
base_url: String,
|
|
808
|
+
access_token: String,
|
|
809
|
+
refresh_token: String,
|
|
810
|
+
session_id: String,
|
|
811
|
+
updated_at_epoch_ms: u128,
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
815
|
+
struct UpdateCheckCache {
|
|
816
|
+
last_checked_epoch_ms: u128,
|
|
817
|
+
latest_version: Option<String>,
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
821
|
+
#[serde(rename_all = "camelCase")]
|
|
822
|
+
struct DeviceCodeRequest {
|
|
823
|
+
client_id: String,
|
|
824
|
+
scope: Vec<String>,
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
828
|
+
#[serde(rename_all = "camelCase")]
|
|
829
|
+
struct DeviceCodeResponse {
|
|
830
|
+
device_code: String,
|
|
831
|
+
user_code: String,
|
|
832
|
+
verification_uri: String,
|
|
833
|
+
verification_uri_complete: String,
|
|
834
|
+
expires_in: u64,
|
|
835
|
+
interval: u64,
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
839
|
+
#[serde(rename_all = "camelCase")]
|
|
840
|
+
struct DeviceTokenRequest {
|
|
841
|
+
grant_type: String,
|
|
842
|
+
device_code: String,
|
|
843
|
+
client_id: String,
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
847
|
+
#[serde(rename_all = "camelCase")]
|
|
848
|
+
struct TokenPairResponse {
|
|
849
|
+
access_token: String,
|
|
850
|
+
refresh_token: String,
|
|
851
|
+
session_id: String,
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
855
|
+
#[serde(rename_all = "camelCase")]
|
|
856
|
+
struct RefreshRequest {
|
|
857
|
+
refresh_token: String,
|
|
858
|
+
session_id: String,
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
862
|
+
#[serde(rename_all = "camelCase")]
|
|
863
|
+
struct RefreshResponse {
|
|
864
|
+
tokens: TokenPairResponse,
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
868
|
+
#[serde(rename_all = "camelCase")]
|
|
869
|
+
struct DeviceTokenSuccess {
|
|
870
|
+
access_token: String,
|
|
871
|
+
refresh_token: String,
|
|
872
|
+
session_id: String,
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
445
876
|
struct DeviceTokenError {
|
|
446
877
|
error: String,
|
|
447
878
|
error_description: Option<String>,
|
|
@@ -513,6 +944,95 @@ struct ProjectResponse {
|
|
|
513
944
|
access_level: Option<String>,
|
|
514
945
|
}
|
|
515
946
|
|
|
947
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
948
|
+
#[serde(rename_all = "camelCase")]
|
|
949
|
+
struct OrgRecord {
|
|
950
|
+
id: String,
|
|
951
|
+
slug: String,
|
|
952
|
+
name: String,
|
|
953
|
+
owner_user_id: Option<String>,
|
|
954
|
+
created_at: Option<String>,
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
958
|
+
struct ListOrgsResponse {
|
|
959
|
+
orgs: Vec<OrgRecord>,
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
963
|
+
#[serde(rename_all = "camelCase")]
|
|
964
|
+
struct OrgResponse {
|
|
965
|
+
org: OrgRecord,
|
|
966
|
+
role: Option<String>,
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
970
|
+
#[serde(rename_all = "camelCase")]
|
|
971
|
+
struct OrgMemberRecord {
|
|
972
|
+
org_id: String,
|
|
973
|
+
user_id: String,
|
|
974
|
+
role: String,
|
|
975
|
+
created_at: Option<String>,
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
979
|
+
struct ListOrgMembersResponse {
|
|
980
|
+
members: Vec<OrgMemberRecord>,
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
984
|
+
struct OrgMemberResponse {
|
|
985
|
+
member: OrgMemberRecord,
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
989
|
+
#[serde(rename_all = "camelCase")]
|
|
990
|
+
struct OrgInviteRecordApi {
|
|
991
|
+
id: String,
|
|
992
|
+
org_id: String,
|
|
993
|
+
email: String,
|
|
994
|
+
role: String,
|
|
995
|
+
invited_by_user_id: Option<String>,
|
|
996
|
+
status: String,
|
|
997
|
+
expires_at: String,
|
|
998
|
+
accepted_by_user_id: Option<String>,
|
|
999
|
+
accepted_at: Option<String>,
|
|
1000
|
+
created_at: Option<String>,
|
|
1001
|
+
updated_at: Option<String>,
|
|
1002
|
+
url: Option<String>,
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
1006
|
+
struct ListOrgInvitesResponse {
|
|
1007
|
+
invites: Vec<OrgInviteRecordApi>,
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
1011
|
+
struct OrgInviteResponse {
|
|
1012
|
+
invite: OrgInviteRecordApi,
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
1016
|
+
#[serde(rename_all = "camelCase")]
|
|
1017
|
+
struct ProjectMemberRecordApi {
|
|
1018
|
+
project_id: String,
|
|
1019
|
+
user_id: String,
|
|
1020
|
+
role: String,
|
|
1021
|
+
invited_by_user_id: Option<String>,
|
|
1022
|
+
created_at: Option<String>,
|
|
1023
|
+
updated_at: Option<String>,
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
1027
|
+
struct ListProjectMembersResponse {
|
|
1028
|
+
members: Vec<ProjectMemberRecordApi>,
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
1032
|
+
struct ProjectMemberResponse {
|
|
1033
|
+
member: ProjectMemberRecordApi,
|
|
1034
|
+
}
|
|
1035
|
+
|
|
516
1036
|
#[derive(Debug, Serialize, Deserialize)]
|
|
517
1037
|
#[serde(rename_all = "camelCase")]
|
|
518
1038
|
struct AssetRecord {
|
|
@@ -533,6 +1053,12 @@ struct AssetRecord {
|
|
|
533
1053
|
#[derive(Debug, Serialize, Deserialize)]
|
|
534
1054
|
struct ListAssetsResponse {
|
|
535
1055
|
assets: Vec<AssetRecord>,
|
|
1056
|
+
#[serde(default)]
|
|
1057
|
+
total: Option<usize>,
|
|
1058
|
+
#[serde(default)]
|
|
1059
|
+
offset: Option<usize>,
|
|
1060
|
+
#[serde(default)]
|
|
1061
|
+
limit: Option<usize>,
|
|
536
1062
|
}
|
|
537
1063
|
|
|
538
1064
|
#[derive(Debug, Serialize, Deserialize)]
|
|
@@ -654,6 +1180,19 @@ fn parse_object_from_value(
|
|
|
654
1180
|
}
|
|
655
1181
|
}
|
|
656
1182
|
|
|
1183
|
+
fn print_json(value: &serde_json::Value) -> Result<()> {
|
|
1184
|
+
println!("{}", serde_json::to_string_pretty(value)?);
|
|
1185
|
+
Ok(())
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
fn emit_text_or_json(output: OutputFormat, text: &str, value: serde_json::Value) -> Result<()> {
|
|
1189
|
+
match output {
|
|
1190
|
+
OutputFormat::Text => println!("{}", text),
|
|
1191
|
+
OutputFormat::Json => print_json(&value)?,
|
|
1192
|
+
}
|
|
1193
|
+
Ok(())
|
|
1194
|
+
}
|
|
1195
|
+
|
|
657
1196
|
fn now_epoch_ms() -> u128 {
|
|
658
1197
|
SystemTime::now()
|
|
659
1198
|
.duration_since(UNIX_EPOCH)
|
|
@@ -673,13 +1212,20 @@ fn resolve_config_root() -> Result<PathBuf> {
|
|
|
673
1212
|
}
|
|
674
1213
|
|
|
675
1214
|
fn config_path() -> Result<PathBuf> {
|
|
676
|
-
|
|
677
|
-
Ok(base.join(SESSION_DIR_NAME).join(SESSION_FILE_NAME))
|
|
1215
|
+
Ok(state_root_path()?.join(SESSION_FILE_NAME))
|
|
678
1216
|
}
|
|
679
1217
|
|
|
680
1218
|
fn update_cache_path() -> Result<PathBuf> {
|
|
1219
|
+
Ok(state_root_path()?.join(UPDATE_CACHE_FILE_NAME))
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
fn unreal_links_path() -> Result<PathBuf> {
|
|
1223
|
+
Ok(state_root_path()?.join(UNREAL_LINKS_FILE_NAME))
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
fn state_root_path() -> Result<PathBuf> {
|
|
681
1227
|
let base = resolve_config_root()?;
|
|
682
|
-
Ok(base.join(SESSION_DIR_NAME)
|
|
1228
|
+
Ok(base.join(SESSION_DIR_NAME))
|
|
683
1229
|
}
|
|
684
1230
|
|
|
685
1231
|
fn session_path_display() -> String {
|
|
@@ -716,82 +1262,347 @@ fn save_session(session: &SessionConfig) -> Result<()> {
|
|
|
716
1262
|
Ok(())
|
|
717
1263
|
}
|
|
718
1264
|
|
|
719
|
-
fn
|
|
720
|
-
let path =
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
"No active session at {}. Run `reallink login` first.",
|
|
724
|
-
path.display()
|
|
725
|
-
)
|
|
726
|
-
})?;
|
|
727
|
-
let session: SessionConfig = serde_json::from_slice(&raw)
|
|
728
|
-
.with_context(|| format!("Invalid session format in {}", path.display()))?;
|
|
729
|
-
Ok(session)
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
fn clear_session() -> Result<bool> {
|
|
733
|
-
let path = config_path()?;
|
|
734
|
-
if path.exists() {
|
|
735
|
-
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
|
736
|
-
return Ok(true);
|
|
1265
|
+
fn load_unreal_links() -> Result<UnrealLinksConfig> {
|
|
1266
|
+
let path = unreal_links_path()?;
|
|
1267
|
+
if !path.exists() {
|
|
1268
|
+
return Ok(UnrealLinksConfig::default());
|
|
737
1269
|
}
|
|
738
|
-
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1270
|
+
let raw = fs::read(&path)
|
|
1271
|
+
.with_context(|| format!("Failed to read unreal links {}", path.display()))?;
|
|
1272
|
+
let mut config: UnrealLinksConfig = serde_json::from_slice(&raw)
|
|
1273
|
+
.with_context(|| format!("Invalid unreal links format in {}", path.display()))?;
|
|
1274
|
+
if config.version == 0 {
|
|
1275
|
+
config.version = 1;
|
|
1276
|
+
}
|
|
1277
|
+
Ok(config)
|
|
745
1278
|
}
|
|
746
1279
|
|
|
747
|
-
fn
|
|
748
|
-
let path =
|
|
1280
|
+
fn save_unreal_links(config: &UnrealLinksConfig) -> Result<()> {
|
|
1281
|
+
let path = unreal_links_path()?;
|
|
749
1282
|
if let Some(parent) = path.parent() {
|
|
750
1283
|
fs::create_dir_all(parent)
|
|
751
1284
|
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
752
1285
|
}
|
|
753
|
-
let payload = serde_json::to_vec_pretty(
|
|
1286
|
+
let payload = serde_json::to_vec_pretty(config)?;
|
|
754
1287
|
write_atomic(&path, &payload)?;
|
|
755
1288
|
Ok(())
|
|
756
1289
|
}
|
|
757
1290
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1291
|
+
fn normalize_path_for_compare(path: &Path) -> String {
|
|
1292
|
+
let normalized = path.to_string_lossy().replace('\\', "/");
|
|
1293
|
+
if cfg!(windows) {
|
|
1294
|
+
normalized.to_ascii_lowercase()
|
|
1295
|
+
} else {
|
|
1296
|
+
normalized
|
|
762
1297
|
}
|
|
763
1298
|
}
|
|
764
1299
|
|
|
765
|
-
fn
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
.
|
|
1300
|
+
fn normalize_path_string_for_compare(path: &str) -> String {
|
|
1301
|
+
let normalized = path.replace('\\', "/");
|
|
1302
|
+
if cfg!(windows) {
|
|
1303
|
+
normalized.to_ascii_lowercase()
|
|
1304
|
+
} else {
|
|
1305
|
+
normalized
|
|
1306
|
+
}
|
|
769
1307
|
}
|
|
770
1308
|
|
|
771
|
-
fn
|
|
772
|
-
let
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
1309
|
+
fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf> {
|
|
1310
|
+
let absolute = if path.is_absolute() {
|
|
1311
|
+
path.to_path_buf()
|
|
1312
|
+
} else {
|
|
1313
|
+
std::env::current_dir()
|
|
1314
|
+
.with_context(|| "Failed to resolve current working directory")?
|
|
1315
|
+
.join(path)
|
|
1316
|
+
};
|
|
1317
|
+
fs::canonicalize(&absolute)
|
|
1318
|
+
.with_context(|| format!("Failed to resolve {} path {}", label, absolute.display()))
|
|
778
1319
|
}
|
|
779
1320
|
|
|
780
|
-
fn
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1321
|
+
fn resolve_uproject_path(path: &Path) -> Result<PathBuf> {
|
|
1322
|
+
let canonical = canonicalize_existing_path(path, "uproject")?;
|
|
1323
|
+
if canonical.is_file() {
|
|
1324
|
+
let is_uproject = canonical
|
|
1325
|
+
.extension()
|
|
1326
|
+
.and_then(|value| value.to_str())
|
|
1327
|
+
.map(|value| value.eq_ignore_ascii_case("uproject"))
|
|
1328
|
+
.unwrap_or(false);
|
|
1329
|
+
if !is_uproject {
|
|
1330
|
+
return Err(anyhow!(
|
|
1331
|
+
"Expected a .uproject file, got {}",
|
|
1332
|
+
canonical.display()
|
|
1333
|
+
));
|
|
1334
|
+
}
|
|
1335
|
+
return Ok(canonical);
|
|
784
1336
|
}
|
|
1337
|
+
if canonical.is_dir() {
|
|
1338
|
+
let mut entries: Vec<PathBuf> = fs::read_dir(&canonical)
|
|
1339
|
+
.with_context(|| format!("Failed to read directory {}", canonical.display()))?
|
|
1340
|
+
.filter_map(|entry| entry.ok().map(|item| item.path()))
|
|
1341
|
+
.filter(|entry| {
|
|
1342
|
+
entry
|
|
1343
|
+
.extension()
|
|
1344
|
+
.and_then(|value| value.to_str())
|
|
1345
|
+
.map(|value| value.eq_ignore_ascii_case("uproject"))
|
|
1346
|
+
.unwrap_or(false)
|
|
1347
|
+
})
|
|
1348
|
+
.collect();
|
|
1349
|
+
entries.sort();
|
|
1350
|
+
if entries.len() == 1 {
|
|
1351
|
+
return Ok(entries.remove(0));
|
|
1352
|
+
}
|
|
1353
|
+
if entries.is_empty() {
|
|
1354
|
+
return Err(anyhow!(
|
|
1355
|
+
"No .uproject file found in {}",
|
|
1356
|
+
canonical.display()
|
|
1357
|
+
));
|
|
1358
|
+
}
|
|
1359
|
+
return Err(anyhow!(
|
|
1360
|
+
"Multiple .uproject files found in {}; pass --uproject with explicit file path",
|
|
1361
|
+
canonical.display()
|
|
1362
|
+
));
|
|
1363
|
+
}
|
|
1364
|
+
Err(anyhow!(
|
|
1365
|
+
"Path {} is neither a file nor directory",
|
|
1366
|
+
canonical.display()
|
|
1367
|
+
))
|
|
785
1368
|
}
|
|
786
1369
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1370
|
+
fn candidate_editor_paths_from_engine_root(engine_root: &Path) -> Vec<PathBuf> {
|
|
1371
|
+
vec![
|
|
1372
|
+
engine_root
|
|
1373
|
+
.join("Engine")
|
|
1374
|
+
.join("Binaries")
|
|
1375
|
+
.join("Win64")
|
|
1376
|
+
.join("UnrealEditor.exe"),
|
|
1377
|
+
engine_root
|
|
1378
|
+
.join("Engine")
|
|
1379
|
+
.join("Binaries")
|
|
1380
|
+
.join("Win64")
|
|
1381
|
+
.join("UE4Editor.exe"),
|
|
1382
|
+
engine_root
|
|
1383
|
+
.join("Engine")
|
|
1384
|
+
.join("Binaries")
|
|
1385
|
+
.join("Linux")
|
|
1386
|
+
.join("UnrealEditor"),
|
|
1387
|
+
engine_root
|
|
1388
|
+
.join("Engine")
|
|
1389
|
+
.join("Binaries")
|
|
1390
|
+
.join("Linux")
|
|
1391
|
+
.join("UE4Editor"),
|
|
1392
|
+
engine_root
|
|
1393
|
+
.join("Engine")
|
|
1394
|
+
.join("Binaries")
|
|
1395
|
+
.join("Mac")
|
|
1396
|
+
.join("UnrealEditor.app")
|
|
1397
|
+
.join("Contents")
|
|
1398
|
+
.join("MacOS")
|
|
1399
|
+
.join("UnrealEditor"),
|
|
1400
|
+
engine_root
|
|
1401
|
+
.join("Engine")
|
|
1402
|
+
.join("Binaries")
|
|
1403
|
+
.join("Mac")
|
|
1404
|
+
.join("UE4Editor.app")
|
|
1405
|
+
.join("Contents")
|
|
1406
|
+
.join("MacOS")
|
|
1407
|
+
.join("UE4Editor"),
|
|
1408
|
+
]
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
fn resolve_editor_from_engine_root(engine_root: &Path) -> Result<PathBuf> {
|
|
1412
|
+
let root = canonicalize_existing_path(engine_root, "engine root")?;
|
|
1413
|
+
if !root.is_dir() {
|
|
1414
|
+
return Err(anyhow!(
|
|
1415
|
+
"Engine root must be a directory: {}",
|
|
1416
|
+
root.display()
|
|
1417
|
+
));
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
for candidate in candidate_editor_paths_from_engine_root(&root) {
|
|
1421
|
+
if candidate.exists() {
|
|
1422
|
+
return Ok(candidate);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
Err(anyhow!(
|
|
1427
|
+
"Could not find Unreal editor binary under {}. Pass --editor explicitly.",
|
|
1428
|
+
root.display()
|
|
1429
|
+
))
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
fn detect_engine_root_from_editor(editor_path: &Path) -> Option<PathBuf> {
|
|
1433
|
+
let parts: Vec<String> = editor_path
|
|
1434
|
+
.components()
|
|
1435
|
+
.map(|component| component.as_os_str().to_string_lossy().to_string())
|
|
1436
|
+
.collect();
|
|
1437
|
+
let mut engine_index = None;
|
|
1438
|
+
for (index, value) in parts.iter().enumerate() {
|
|
1439
|
+
if value.eq_ignore_ascii_case("Engine") {
|
|
1440
|
+
engine_index = Some(index);
|
|
1441
|
+
break;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
let index = engine_index?;
|
|
1445
|
+
let mut root = PathBuf::new();
|
|
1446
|
+
for segment in &parts[..index] {
|
|
1447
|
+
root.push(segment);
|
|
1448
|
+
}
|
|
1449
|
+
if root.as_os_str().is_empty() {
|
|
1450
|
+
None
|
|
1451
|
+
} else {
|
|
1452
|
+
Some(root)
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
fn resolve_editor_and_engine(
|
|
1457
|
+
explicit_editor: Option<PathBuf>,
|
|
1458
|
+
explicit_engine_root: Option<PathBuf>,
|
|
1459
|
+
) -> Result<(PathBuf, Option<PathBuf>)> {
|
|
1460
|
+
if let Some(editor) = explicit_editor {
|
|
1461
|
+
let editor_path = canonicalize_existing_path(&editor, "editor")?;
|
|
1462
|
+
if !editor_path.is_file() {
|
|
1463
|
+
return Err(anyhow!(
|
|
1464
|
+
"Editor path must be a file: {}",
|
|
1465
|
+
editor_path.display()
|
|
1466
|
+
));
|
|
1467
|
+
}
|
|
1468
|
+
let engine_root = if let Some(root) = explicit_engine_root {
|
|
1469
|
+
Some(canonicalize_existing_path(&root, "engine root")?)
|
|
1470
|
+
} else {
|
|
1471
|
+
detect_engine_root_from_editor(&editor_path)
|
|
1472
|
+
};
|
|
1473
|
+
return Ok((editor_path, engine_root));
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
if let Some(engine_root) = explicit_engine_root {
|
|
1477
|
+
let root = canonicalize_existing_path(&engine_root, "engine root")?;
|
|
1478
|
+
let editor_path = resolve_editor_from_engine_root(&root)?;
|
|
1479
|
+
return Ok((editor_path, Some(root)));
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
if let Ok(editor_env) = std::env::var("REALLINK_UNREAL_EDITOR") {
|
|
1483
|
+
let trimmed = editor_env.trim();
|
|
1484
|
+
if !trimmed.is_empty() {
|
|
1485
|
+
let editor_path = canonicalize_existing_path(Path::new(trimmed), "editor")?;
|
|
1486
|
+
let engine_root = detect_engine_root_from_editor(&editor_path);
|
|
1487
|
+
return Ok((editor_path, engine_root));
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
if let Ok(editor_env) = std::env::var("UE_EDITOR_PATH") {
|
|
1491
|
+
let trimmed = editor_env.trim();
|
|
1492
|
+
if !trimmed.is_empty() {
|
|
1493
|
+
let editor_path = canonicalize_existing_path(Path::new(trimmed), "editor")?;
|
|
1494
|
+
let engine_root = detect_engine_root_from_editor(&editor_path);
|
|
1495
|
+
return Ok((editor_path, engine_root));
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
if let Ok(engine_env) = std::env::var("REALLINK_UNREAL_ENGINE_ROOT") {
|
|
1499
|
+
let trimmed = engine_env.trim();
|
|
1500
|
+
if !trimmed.is_empty() {
|
|
1501
|
+
let root = canonicalize_existing_path(Path::new(trimmed), "engine root")?;
|
|
1502
|
+
let editor_path = resolve_editor_from_engine_root(&root)?;
|
|
1503
|
+
return Ok((editor_path, Some(root)));
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
if let Ok(engine_env) = std::env::var("UE_ENGINE_ROOT") {
|
|
1507
|
+
let trimmed = engine_env.trim();
|
|
1508
|
+
if !trimmed.is_empty() {
|
|
1509
|
+
let root = canonicalize_existing_path(Path::new(trimmed), "engine root")?;
|
|
1510
|
+
let editor_path = resolve_editor_from_engine_root(&root)?;
|
|
1511
|
+
return Ok((editor_path, Some(root)));
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
Err(anyhow!(
|
|
1516
|
+
"Unable to resolve Unreal editor. Pass --editor or --engine-root, or set REALLINK_UNREAL_EDITOR/REALLINK_UNREAL_ENGINE_ROOT."
|
|
1517
|
+
))
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
fn load_session() -> Result<SessionConfig> {
|
|
1521
|
+
let path = config_path()?;
|
|
1522
|
+
let raw = fs::read(&path).with_context(|| {
|
|
1523
|
+
format!(
|
|
1524
|
+
"No active session at {}. Run `reallink login` first.",
|
|
1525
|
+
path.display()
|
|
1526
|
+
)
|
|
1527
|
+
})?;
|
|
1528
|
+
let session: SessionConfig = serde_json::from_slice(&raw)
|
|
1529
|
+
.with_context(|| format!("Invalid session format in {}", path.display()))?;
|
|
1530
|
+
Ok(session)
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
fn clear_session() -> Result<bool> {
|
|
1534
|
+
let path = config_path()?;
|
|
1535
|
+
if path.exists() {
|
|
1536
|
+
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
|
1537
|
+
return Ok(true);
|
|
1538
|
+
}
|
|
1539
|
+
Ok(false)
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
fn load_update_cache() -> Option<UpdateCheckCache> {
|
|
1543
|
+
let path = update_cache_path().ok()?;
|
|
1544
|
+
let raw = fs::read(path).ok()?;
|
|
1545
|
+
serde_json::from_slice(&raw).ok()
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
fn save_update_cache(cache: &UpdateCheckCache) -> Result<()> {
|
|
1549
|
+
let path = update_cache_path()?;
|
|
1550
|
+
if let Some(parent) = path.parent() {
|
|
1551
|
+
fs::create_dir_all(parent)
|
|
1552
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
1553
|
+
}
|
|
1554
|
+
let payload = serde_json::to_vec_pretty(cache)?;
|
|
1555
|
+
write_atomic(&path, &payload)?;
|
|
1556
|
+
Ok(())
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async fn read_error_body(response: reqwest::Response) -> String {
|
|
1560
|
+
match response.text().await {
|
|
1561
|
+
Ok(text) if !text.trim().is_empty() => text,
|
|
1562
|
+
_ => "No response body".to_string(),
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
#[derive(Deserialize)]
|
|
1567
|
+
struct ApiErrorEnvelope {
|
|
1568
|
+
code: Option<String>,
|
|
1569
|
+
message: Option<String>,
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
fn parse_api_error(body: &str) -> Option<ApiErrorEnvelope> {
|
|
1573
|
+
serde_json::from_str::<ApiErrorEnvelope>(body).ok()
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
fn with_cli_headers(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
1577
|
+
request
|
|
1578
|
+
.header("x-reallink-client", "cli")
|
|
1579
|
+
.header("x-reallink-cli-version", env!("CARGO_PKG_VERSION"))
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
fn parse_semver_triplet(version: &str) -> Option<(u64, u64, u64)> {
|
|
1583
|
+
let core = version.trim().split('-').next()?;
|
|
1584
|
+
let mut parts = core.split('.');
|
|
1585
|
+
let major = parts.next()?.parse::<u64>().ok()?;
|
|
1586
|
+
let minor = parts.next().unwrap_or("0").parse::<u64>().ok()?;
|
|
1587
|
+
let patch = parts.next().unwrap_or("0").parse::<u64>().ok()?;
|
|
1588
|
+
Some((major, minor, patch))
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
fn is_newer_version(current: &str, latest: &str) -> bool {
|
|
1592
|
+
match (parse_semver_triplet(current), parse_semver_triplet(latest)) {
|
|
1593
|
+
(Some(current_parts), Some(latest_parts)) => latest_parts > current_parts,
|
|
1594
|
+
_ => false,
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
async fn fetch_latest_cli_version(client: &reqwest::Client) -> Option<String> {
|
|
1599
|
+
let timeout_ms = std::env::var("REALLINK_UPDATE_CHECK_TIMEOUT_MS")
|
|
1600
|
+
.ok()
|
|
1601
|
+
.and_then(|value| value.parse::<u64>().ok())
|
|
1602
|
+
.filter(|value| *value > 0)
|
|
1603
|
+
.unwrap_or(DEFAULT_VERSION_FETCH_TIMEOUT_MS);
|
|
1604
|
+
let response = with_cli_headers(client.get("https://registry.npmjs.org/reallink-cli/latest"))
|
|
1605
|
+
.timeout(Duration::from_millis(timeout_ms))
|
|
795
1606
|
.send()
|
|
796
1607
|
.await
|
|
797
1608
|
.ok()?;
|
|
@@ -810,6 +1621,7 @@ async fn maybe_notify_update(
|
|
|
810
1621
|
client: &reqwest::Client,
|
|
811
1622
|
force_refresh: bool,
|
|
812
1623
|
allow_network_fetch: bool,
|
|
1624
|
+
output: OutputFormat,
|
|
813
1625
|
) {
|
|
814
1626
|
if std::env::var("REALLINK_DISABLE_AUTO_UPDATE_CHECK")
|
|
815
1627
|
.map(|value| value == "1")
|
|
@@ -844,10 +1656,12 @@ async fn maybe_notify_update(
|
|
|
844
1656
|
let current = env!("CARGO_PKG_VERSION");
|
|
845
1657
|
if let Some(latest) = latest_version {
|
|
846
1658
|
if is_newer_version(current, &latest) {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1659
|
+
if output == OutputFormat::Text {
|
|
1660
|
+
eprintln!(
|
|
1661
|
+
"Update available: {} -> {}. Run `reallink self-update`.",
|
|
1662
|
+
current, latest
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
851
1665
|
}
|
|
852
1666
|
}
|
|
853
1667
|
}
|
|
@@ -987,65 +1801,130 @@ async fn existing_session_identity_for_base_url(
|
|
|
987
1801
|
.map(|email| email.to_string())
|
|
988
1802
|
}
|
|
989
1803
|
|
|
990
|
-
|
|
1804
|
+
fn default_login_scopes() -> Vec<String> {
|
|
1805
|
+
generated::contract::CLI_DEFAULT_LOGIN_SCOPES
|
|
1806
|
+
.iter()
|
|
1807
|
+
.map(|value| (*value).to_string())
|
|
1808
|
+
.collect()
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
fn default_login_scopes_with_tools() -> Vec<String> {
|
|
1812
|
+
generated::contract::CLI_DEFAULT_LOGIN_SCOPES_WITH_TOOLS
|
|
1813
|
+
.iter()
|
|
1814
|
+
.map(|value| (*value).to_string())
|
|
1815
|
+
.collect()
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
async fn login_command(
|
|
1819
|
+
client: &reqwest::Client,
|
|
1820
|
+
args: LoginArgs,
|
|
1821
|
+
output: OutputFormat,
|
|
1822
|
+
) -> Result<()> {
|
|
991
1823
|
let base_url = normalize_base_url(&args.base_url);
|
|
992
1824
|
if !args.force {
|
|
993
1825
|
if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
|
|
994
|
-
|
|
995
|
-
|
|
1826
|
+
let payload = serde_json::json!({
|
|
1827
|
+
"ok": true,
|
|
1828
|
+
"alreadyLoggedIn": true,
|
|
1829
|
+
"baseUrl": base_url,
|
|
1830
|
+
"email": email,
|
|
1831
|
+
"message": "Use `reallink logout` to sign out or `reallink login --force` to replace this session."
|
|
1832
|
+
});
|
|
1833
|
+
emit_text_or_json(
|
|
1834
|
+
output,
|
|
1835
|
+
&format!(
|
|
1836
|
+
"Already logged in. Use `reallink logout` to sign out or `reallink login --force` to replace this session."
|
|
1837
|
+
),
|
|
1838
|
+
payload,
|
|
1839
|
+
)?;
|
|
996
1840
|
return Ok(());
|
|
997
1841
|
}
|
|
998
1842
|
}
|
|
999
1843
|
|
|
1000
|
-
let
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
"assets:write".to_string(),
|
|
1006
|
-
"trace:read".to_string(),
|
|
1007
|
-
"trace:write".to_string(),
|
|
1008
|
-
"tools:read".to_string(),
|
|
1009
|
-
"tools:write".to_string(),
|
|
1010
|
-
"tools:run".to_string(),
|
|
1011
|
-
"org:admin".to_string(),
|
|
1012
|
-
"project:admin".to_string(),
|
|
1013
|
-
]
|
|
1844
|
+
let (initial_scope, fallback_scope) = if args.scope.is_empty() {
|
|
1845
|
+
(
|
|
1846
|
+
default_login_scopes_with_tools(),
|
|
1847
|
+
Some(default_login_scopes()),
|
|
1848
|
+
)
|
|
1014
1849
|
} else {
|
|
1015
|
-
args.scope
|
|
1850
|
+
(args.scope, None)
|
|
1016
1851
|
};
|
|
1017
1852
|
|
|
1018
|
-
let
|
|
1853
|
+
let mut selected_scope = initial_scope;
|
|
1854
|
+
let mut device_code_response =
|
|
1019
1855
|
with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
|
|
1020
1856
|
.json(&DeviceCodeRequest {
|
|
1021
1857
|
client_id: args.client_id.clone(),
|
|
1022
|
-
scope,
|
|
1858
|
+
scope: selected_scope.clone(),
|
|
1023
1859
|
})
|
|
1024
1860
|
.send()
|
|
1025
1861
|
.await?;
|
|
1026
1862
|
|
|
1027
1863
|
if !device_code_response.status().is_success() {
|
|
1028
1864
|
let body = read_error_body(device_code_response).await;
|
|
1029
|
-
|
|
1865
|
+
let can_fallback = fallback_scope.is_some()
|
|
1866
|
+
&& body.contains("VALIDATION_ERROR")
|
|
1867
|
+
&& body.contains("tools:");
|
|
1868
|
+
if can_fallback {
|
|
1869
|
+
if output == OutputFormat::Text {
|
|
1870
|
+
eprintln!(
|
|
1871
|
+
"Server rejected tool scopes for device-flow bootstrap; retrying with compatibility scopes."
|
|
1872
|
+
);
|
|
1873
|
+
}
|
|
1874
|
+
selected_scope = fallback_scope.unwrap_or_default();
|
|
1875
|
+
device_code_response =
|
|
1876
|
+
with_cli_headers(client.post(format!("{}/auth/device/code", base_url)))
|
|
1877
|
+
.json(&DeviceCodeRequest {
|
|
1878
|
+
client_id: args.client_id.clone(),
|
|
1879
|
+
scope: selected_scope.clone(),
|
|
1880
|
+
})
|
|
1881
|
+
.send()
|
|
1882
|
+
.await?;
|
|
1883
|
+
if !device_code_response.status().is_success() {
|
|
1884
|
+
let retry_body = read_error_body(device_code_response).await;
|
|
1885
|
+
return Err(anyhow!("Failed to start device flow: {}", retry_body));
|
|
1886
|
+
}
|
|
1887
|
+
} else {
|
|
1888
|
+
return Err(anyhow!("Failed to start device flow: {}", body));
|
|
1889
|
+
}
|
|
1030
1890
|
}
|
|
1031
1891
|
|
|
1032
1892
|
let device_code: DeviceCodeResponse = device_code_response.json().await?;
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1893
|
+
let browser_opened = webbrowser::open(&device_code.verification_uri_complete).is_ok();
|
|
1894
|
+
if output == OutputFormat::Text {
|
|
1895
|
+
println!("Open this URL in your browser and approve the login:");
|
|
1896
|
+
println!("{}", device_code.verification_uri_complete);
|
|
1897
|
+
println!("User code: {}", device_code.user_code);
|
|
1898
|
+
if browser_opened {
|
|
1899
|
+
println!("Browser opened for device approval.");
|
|
1900
|
+
} else {
|
|
1901
|
+
println!("Could not open browser automatically. Open the URL manually.");
|
|
1902
|
+
}
|
|
1903
|
+
} else {
|
|
1904
|
+
print_json(&serde_json::json!({
|
|
1905
|
+
"ok": true,
|
|
1906
|
+
"phase": "authorization_pending",
|
|
1907
|
+
"baseUrl": base_url,
|
|
1908
|
+
"verificationUri": device_code.verification_uri,
|
|
1909
|
+
"verificationUriComplete": device_code.verification_uri_complete,
|
|
1910
|
+
"userCode": device_code.user_code,
|
|
1911
|
+
"browserOpened": browser_opened,
|
|
1912
|
+
"expiresInSeconds": device_code.expires_in,
|
|
1913
|
+
"pollIntervalSeconds": device_code.interval,
|
|
1914
|
+
"scope": selected_scope
|
|
1915
|
+
}))?;
|
|
1039
1916
|
}
|
|
1040
1917
|
|
|
1041
1918
|
let expires_at = std::time::Instant::now() + Duration::from_secs(device_code.expires_in);
|
|
1042
1919
|
let mut poll_interval = Duration::from_secs(device_code.interval.max(1));
|
|
1043
1920
|
let mut pending_polls = 0u32;
|
|
1044
|
-
|
|
1921
|
+
if output == OutputFormat::Text {
|
|
1922
|
+
println!("Waiting for approval (press Ctrl+C to cancel)");
|
|
1923
|
+
}
|
|
1045
1924
|
|
|
1046
1925
|
loop {
|
|
1047
1926
|
if std::time::Instant::now() >= expires_at {
|
|
1048
|
-
if pending_polls > 0 {
|
|
1927
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
1049
1928
|
println!();
|
|
1050
1929
|
}
|
|
1051
1930
|
return Err(anyhow!("Device code expired before approval"));
|
|
@@ -1065,7 +1944,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1065
1944
|
|
|
1066
1945
|
if token_response.status().is_success() {
|
|
1067
1946
|
let tokens: DeviceTokenSuccess = token_response.json().await?;
|
|
1068
|
-
if pending_polls > 0 {
|
|
1947
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
1069
1948
|
println!();
|
|
1070
1949
|
}
|
|
1071
1950
|
let session = SessionConfig {
|
|
@@ -1076,8 +1955,20 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1076
1955
|
updated_at_epoch_ms: now_epoch_ms(),
|
|
1077
1956
|
};
|
|
1078
1957
|
save_session(&session)?;
|
|
1079
|
-
|
|
1080
|
-
|
|
1958
|
+
let path = session_path_display();
|
|
1959
|
+
let payload = serde_json::json!({
|
|
1960
|
+
"ok": true,
|
|
1961
|
+
"phase": "authenticated",
|
|
1962
|
+
"baseUrl": base_url,
|
|
1963
|
+
"sessionPath": path,
|
|
1964
|
+
"sessionId": session.session_id
|
|
1965
|
+
});
|
|
1966
|
+
if output == OutputFormat::Text {
|
|
1967
|
+
println!("Login successful.");
|
|
1968
|
+
println!("Session stored at {}", session_path_display());
|
|
1969
|
+
} else {
|
|
1970
|
+
print_json(&payload)?;
|
|
1971
|
+
}
|
|
1081
1972
|
return Ok(());
|
|
1082
1973
|
}
|
|
1083
1974
|
|
|
@@ -1090,19 +1981,23 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1090
1981
|
match error_payload.error.as_str() {
|
|
1091
1982
|
"authorization_pending" => {
|
|
1092
1983
|
pending_polls = pending_polls.saturating_add(1);
|
|
1093
|
-
|
|
1094
|
-
|
|
1984
|
+
if output == OutputFormat::Text {
|
|
1985
|
+
print!(".");
|
|
1986
|
+
let _ = io::stdout().flush();
|
|
1987
|
+
}
|
|
1095
1988
|
continue;
|
|
1096
1989
|
}
|
|
1097
1990
|
"slow_down" => {
|
|
1098
1991
|
poll_interval += Duration::from_secs(1);
|
|
1099
1992
|
pending_polls = pending_polls.saturating_add(1);
|
|
1100
|
-
|
|
1101
|
-
|
|
1993
|
+
if output == OutputFormat::Text {
|
|
1994
|
+
print!("+");
|
|
1995
|
+
let _ = io::stdout().flush();
|
|
1996
|
+
}
|
|
1102
1997
|
continue;
|
|
1103
1998
|
}
|
|
1104
1999
|
_ => {
|
|
1105
|
-
if pending_polls > 0 {
|
|
2000
|
+
if output == OutputFormat::Text && pending_polls > 0 {
|
|
1106
2001
|
println!();
|
|
1107
2002
|
}
|
|
1108
2003
|
return Err(anyhow!(
|
|
@@ -1115,13 +2010,17 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
1115
2010
|
}
|
|
1116
2011
|
}
|
|
1117
2012
|
|
|
1118
|
-
async fn logout_command(client: &reqwest::Client) -> Result<()> {
|
|
2013
|
+
async fn logout_command(client: &reqwest::Client, output: OutputFormat) -> Result<()> {
|
|
1119
2014
|
let path_display = session_path_display();
|
|
1120
2015
|
let mut session = match load_session() {
|
|
1121
2016
|
Ok(session) => session,
|
|
1122
2017
|
Err(_) => {
|
|
1123
|
-
|
|
1124
|
-
|
|
2018
|
+
let payload = serde_json::json!({
|
|
2019
|
+
"ok": true,
|
|
2020
|
+
"alreadyLoggedOut": true,
|
|
2021
|
+
"sessionPath": path_display
|
|
2022
|
+
});
|
|
2023
|
+
emit_text_or_json(output, "You are already logged out.", payload)?;
|
|
1125
2024
|
return Ok(());
|
|
1126
2025
|
}
|
|
1127
2026
|
};
|
|
@@ -1137,31 +2036,296 @@ async fn logout_command(client: &reqwest::Client) -> Result<()> {
|
|
|
1137
2036
|
}
|
|
1138
2037
|
Ok(response) => {
|
|
1139
2038
|
let body = read_error_body(response).await;
|
|
1140
|
-
|
|
2039
|
+
if output == OutputFormat::Text {
|
|
2040
|
+
eprintln!("Warning: remote logout request failed: {}", body);
|
|
2041
|
+
}
|
|
1141
2042
|
}
|
|
1142
2043
|
Err(error) => {
|
|
1143
|
-
|
|
2044
|
+
if output == OutputFormat::Text {
|
|
2045
|
+
eprintln!("Warning: remote logout request failed: {}", error);
|
|
2046
|
+
}
|
|
1144
2047
|
}
|
|
1145
2048
|
}
|
|
1146
2049
|
|
|
1147
2050
|
let removed = clear_session()?;
|
|
1148
2051
|
if !removed {
|
|
1149
|
-
|
|
2052
|
+
let payload = serde_json::json!({
|
|
2053
|
+
"ok": true,
|
|
2054
|
+
"alreadyLoggedOut": true,
|
|
2055
|
+
"sessionPath": path_display
|
|
2056
|
+
});
|
|
2057
|
+
emit_text_or_json(output, "Local session was already cleared.", payload)?;
|
|
1150
2058
|
return Ok(());
|
|
1151
2059
|
}
|
|
1152
2060
|
|
|
1153
|
-
|
|
2061
|
+
let payload = serde_json::json!({
|
|
2062
|
+
"ok": true,
|
|
2063
|
+
"sessionPath": path_display,
|
|
2064
|
+
"remoteRevoked": remote_revoked,
|
|
2065
|
+
"remoteUnavailable": remote_unavailable
|
|
2066
|
+
});
|
|
2067
|
+
if output == OutputFormat::Text {
|
|
2068
|
+
if remote_revoked {
|
|
2069
|
+
println!(
|
|
2070
|
+
"Logged out. Server session revoked and local session removed from {}.",
|
|
2071
|
+
session_path_display()
|
|
2072
|
+
);
|
|
2073
|
+
} else if remote_unavailable {
|
|
2074
|
+
println!(
|
|
2075
|
+
"Logged out locally. Removed session from {}.",
|
|
2076
|
+
session_path_display()
|
|
2077
|
+
);
|
|
2078
|
+
println!("Server logout endpoint is not available on this API deployment yet.");
|
|
2079
|
+
} else {
|
|
2080
|
+
println!(
|
|
2081
|
+
"Logged out locally. Removed session from {}.",
|
|
2082
|
+
session_path_display()
|
|
2083
|
+
);
|
|
2084
|
+
}
|
|
2085
|
+
} else {
|
|
2086
|
+
print_json(&payload)?;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
Ok(())
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
fn sanitize_cli_args(raw: &[String]) -> Vec<String> {
|
|
2093
|
+
let sensitive_flags = [
|
|
2094
|
+
"--input-json",
|
|
2095
|
+
"--input-file",
|
|
2096
|
+
"--metadata-file",
|
|
2097
|
+
"--token",
|
|
2098
|
+
"--refresh-token",
|
|
2099
|
+
"--password",
|
|
2100
|
+
"--secret",
|
|
2101
|
+
];
|
|
2102
|
+
let mut sanitized = Vec::with_capacity(raw.len());
|
|
2103
|
+
let mut redact_next = false;
|
|
2104
|
+
for value in raw {
|
|
2105
|
+
if redact_next {
|
|
2106
|
+
sanitized.push("<redacted>".to_string());
|
|
2107
|
+
redact_next = false;
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
if let Some((flag, _rest)) = value.split_once('=') {
|
|
2111
|
+
if sensitive_flags.iter().any(|candidate| candidate == &flag) {
|
|
2112
|
+
sanitized.push(format!("{}=<redacted>", flag));
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
if sensitive_flags.iter().any(|candidate| candidate == value) {
|
|
2117
|
+
sanitized.push(value.clone());
|
|
2118
|
+
redact_next = true;
|
|
2119
|
+
continue;
|
|
2120
|
+
}
|
|
2121
|
+
sanitized.push(value.clone());
|
|
2122
|
+
}
|
|
2123
|
+
sanitized
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
fn command_summary_for_logs() -> String {
|
|
2127
|
+
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
2128
|
+
let sanitized = sanitize_cli_args(&args);
|
|
2129
|
+
if sanitized.is_empty() {
|
|
2130
|
+
"reallink".to_string()
|
|
2131
|
+
} else {
|
|
2132
|
+
format!("reallink {}", sanitized.join(" "))
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
fn append_runtime_log_event(
|
|
2137
|
+
command: &str,
|
|
2138
|
+
message: &str,
|
|
2139
|
+
level: &str,
|
|
2140
|
+
duration_ms: Option<u128>,
|
|
2141
|
+
exit_code: Option<i32>,
|
|
2142
|
+
) {
|
|
2143
|
+
let state_root = match state_root_path() {
|
|
2144
|
+
Ok(path) => path,
|
|
2145
|
+
Err(_) => return,
|
|
2146
|
+
};
|
|
2147
|
+
let event = logs::RuntimeLogEvent {
|
|
2148
|
+
ts_epoch_ms: now_epoch_ms(),
|
|
2149
|
+
level: level.to_string(),
|
|
2150
|
+
command: command.to_string(),
|
|
2151
|
+
message: message.to_string(),
|
|
2152
|
+
duration_ms,
|
|
2153
|
+
exit_code,
|
|
2154
|
+
};
|
|
2155
|
+
let _ = logs::append_runtime_event(&state_root, &event);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
fn record_cli_crash_report(command: &str, error: &anyhow::Error) {
|
|
2159
|
+
let state_root = match state_root_path() {
|
|
2160
|
+
Ok(path) => path,
|
|
2161
|
+
Err(_) => return,
|
|
2162
|
+
};
|
|
2163
|
+
let report = logs::CrashReport {
|
|
2164
|
+
ts_epoch_ms: now_epoch_ms(),
|
|
2165
|
+
command: command.to_string(),
|
|
2166
|
+
message: error.to_string(),
|
|
2167
|
+
detail: Some(format!("{:#}", error)),
|
|
2168
|
+
};
|
|
2169
|
+
let _ = logs::write_crash_report(&state_root, &report);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
async fn logs_status_command(output: OutputFormat) -> Result<()> {
|
|
2173
|
+
let status = logs::status(&state_root_path()?)?;
|
|
2174
|
+
let payload = serde_json::to_value(&status)?;
|
|
2175
|
+
if output == OutputFormat::Text {
|
|
2176
|
+
println!(
|
|
2177
|
+
"Logs root: {}\nRuntime log: {}\nCrash dir: {}\nUpload consent: {}\nCrash reports: {}",
|
|
2178
|
+
status.logs_root,
|
|
2179
|
+
status.runtime_log_path,
|
|
2180
|
+
status.crash_dir,
|
|
2181
|
+
if status.consent.upload_enabled {
|
|
2182
|
+
"enabled"
|
|
2183
|
+
} else {
|
|
2184
|
+
"disabled"
|
|
2185
|
+
},
|
|
2186
|
+
status.crash_report_count
|
|
2187
|
+
);
|
|
2188
|
+
} else {
|
|
2189
|
+
print_json(&payload)?;
|
|
2190
|
+
}
|
|
2191
|
+
Ok(())
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
async fn logs_consent_command(args: LogsConsentArgs, output: OutputFormat) -> Result<()> {
|
|
2195
|
+
let state_root = state_root_path()?;
|
|
2196
|
+
let consent = if args.enable {
|
|
2197
|
+
logs::set_upload_consent(&state_root, true)?
|
|
2198
|
+
} else if args.disable {
|
|
2199
|
+
logs::set_upload_consent(&state_root, false)?
|
|
2200
|
+
} else {
|
|
2201
|
+
logs::load_consent(&state_root)?
|
|
2202
|
+
};
|
|
2203
|
+
let payload = serde_json::json!({
|
|
2204
|
+
"ok": true,
|
|
2205
|
+
"consent": consent
|
|
2206
|
+
});
|
|
2207
|
+
if output == OutputFormat::Text {
|
|
1154
2208
|
println!(
|
|
1155
|
-
"
|
|
1156
|
-
|
|
2209
|
+
"Log upload consent is {}.",
|
|
2210
|
+
if consent.upload_enabled {
|
|
2211
|
+
"enabled"
|
|
2212
|
+
} else {
|
|
2213
|
+
"disabled"
|
|
2214
|
+
}
|
|
1157
2215
|
);
|
|
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.");
|
|
1161
2216
|
} else {
|
|
1162
|
-
|
|
2217
|
+
print_json(&payload)?;
|
|
2218
|
+
}
|
|
2219
|
+
Ok(())
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
async fn logs_tail_command(args: LogsTailArgs, output: OutputFormat) -> Result<()> {
|
|
2223
|
+
let lines = logs::read_runtime_tail(&state_root_path()?, args.lines)?;
|
|
2224
|
+
if output == OutputFormat::Text {
|
|
2225
|
+
for line in lines {
|
|
2226
|
+
println!("{}", line);
|
|
2227
|
+
}
|
|
2228
|
+
} else {
|
|
2229
|
+
print_json(&serde_json::json!({
|
|
2230
|
+
"ok": true,
|
|
2231
|
+
"lines": lines
|
|
2232
|
+
}))?;
|
|
2233
|
+
}
|
|
2234
|
+
Ok(())
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
async fn logs_upload_command(
|
|
2238
|
+
client: &reqwest::Client,
|
|
2239
|
+
args: LogsUploadArgs,
|
|
2240
|
+
output: OutputFormat,
|
|
2241
|
+
) -> Result<()> {
|
|
2242
|
+
let state_root = state_root_path()?;
|
|
2243
|
+
let consent = logs::load_consent(&state_root)?;
|
|
2244
|
+
if !consent.upload_enabled {
|
|
2245
|
+
return Err(anyhow!(
|
|
2246
|
+
"Log upload consent is disabled. Run `reallink logs consent --enable` first."
|
|
2247
|
+
));
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
let candidates =
|
|
2251
|
+
logs::list_upload_candidates(&state_root, args.include_runtime, args.include_crash)?;
|
|
2252
|
+
if args.dry_run {
|
|
2253
|
+
let payload = serde_json::json!({
|
|
2254
|
+
"ok": true,
|
|
2255
|
+
"dryRun": true,
|
|
2256
|
+
"count": candidates.len(),
|
|
2257
|
+
"candidates": candidates
|
|
2258
|
+
});
|
|
2259
|
+
emit_text_or_json(
|
|
2260
|
+
output,
|
|
2261
|
+
&format!("Dry run complete. {} log files are ready to upload.", payload["count"]),
|
|
2262
|
+
payload,
|
|
2263
|
+
)?;
|
|
2264
|
+
return Ok(());
|
|
2265
|
+
}
|
|
2266
|
+
if candidates.is_empty() {
|
|
2267
|
+
let payload = serde_json::json!({
|
|
2268
|
+
"ok": true,
|
|
2269
|
+
"uploaded": [],
|
|
2270
|
+
"count": 0
|
|
2271
|
+
});
|
|
2272
|
+
emit_text_or_json(output, "No local log files to upload.", payload)?;
|
|
2273
|
+
return Ok(());
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
let mut session = load_session()?;
|
|
2277
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2278
|
+
let mut uploaded = Vec::new();
|
|
2279
|
+
|
|
2280
|
+
for candidate in candidates {
|
|
2281
|
+
let bytes = fs::read(&candidate.local_path).with_context(|| {
|
|
2282
|
+
format!("Failed to read log file {}", candidate.local_path.display())
|
|
2283
|
+
})?;
|
|
2284
|
+
let asset = upload_asset_via_intent(
|
|
2285
|
+
client,
|
|
2286
|
+
&mut session,
|
|
2287
|
+
&args.project_id,
|
|
2288
|
+
&candidate.remote_path,
|
|
2289
|
+
bytes,
|
|
2290
|
+
&candidate.content_type,
|
|
2291
|
+
&args.asset_type,
|
|
2292
|
+
&args.visibility,
|
|
2293
|
+
)
|
|
2294
|
+
.await?;
|
|
2295
|
+
|
|
2296
|
+
if args.clear_on_success {
|
|
2297
|
+
let file_name = candidate
|
|
2298
|
+
.local_path
|
|
2299
|
+
.file_name()
|
|
2300
|
+
.and_then(|value| value.to_str())
|
|
2301
|
+
.unwrap_or_default();
|
|
2302
|
+
if file_name.eq_ignore_ascii_case("runtime.jsonl") {
|
|
2303
|
+
fs::write(&candidate.local_path, b"").with_context(|| {
|
|
2304
|
+
format!("Failed to clear runtime log {}", candidate.local_path.display())
|
|
2305
|
+
})?;
|
|
2306
|
+
} else {
|
|
2307
|
+
let _ = fs::remove_file(&candidate.local_path);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
uploaded.push(serde_json::json!({
|
|
2312
|
+
"localPath": candidate.local_path.display().to_string(),
|
|
2313
|
+
"remotePath": candidate.remote_path,
|
|
2314
|
+
"asset": asset
|
|
2315
|
+
}));
|
|
1163
2316
|
}
|
|
1164
2317
|
|
|
2318
|
+
save_session(&session)?;
|
|
2319
|
+
let payload = serde_json::json!({
|
|
2320
|
+
"ok": true,
|
|
2321
|
+
"count": uploaded.len(),
|
|
2322
|
+
"uploaded": uploaded
|
|
2323
|
+
});
|
|
2324
|
+
emit_text_or_json(
|
|
2325
|
+
output,
|
|
2326
|
+
&format!("Uploaded {} log files.", payload["count"]),
|
|
2327
|
+
payload,
|
|
2328
|
+
)?;
|
|
1165
2329
|
Ok(())
|
|
1166
2330
|
}
|
|
1167
2331
|
|
|
@@ -1247,29 +2411,22 @@ async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -
|
|
|
1247
2411
|
Ok(())
|
|
1248
2412
|
}
|
|
1249
2413
|
|
|
1250
|
-
async fn
|
|
2414
|
+
async fn org_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
|
|
1251
2415
|
let mut session = load_session()?;
|
|
1252
2416
|
apply_base_url_override(&mut session, args.base_url);
|
|
1253
2417
|
|
|
1254
|
-
let
|
|
1255
|
-
Some(org_id) if !org_id.trim().is_empty() => {
|
|
1256
|
-
format!("/core/projects?orgId={}", org_id.trim())
|
|
1257
|
-
}
|
|
1258
|
-
_ => "/core/projects".to_string(),
|
|
1259
|
-
};
|
|
1260
|
-
|
|
1261
|
-
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2418
|
+
let response = authed_request(client, &mut session, Method::GET, "/core/orgs", None).await?;
|
|
1262
2419
|
if !response.status().is_success() {
|
|
1263
2420
|
let body = read_error_body(response).await;
|
|
1264
|
-
return Err(anyhow!("
|
|
2421
|
+
return Err(anyhow!("org list failed: {}", body));
|
|
1265
2422
|
}
|
|
1266
|
-
let payload:
|
|
1267
|
-
println!("{}", serde_json::to_string_pretty(&payload.
|
|
2423
|
+
let payload: ListOrgsResponse = response.json().await?;
|
|
2424
|
+
println!("{}", serde_json::to_string_pretty(&payload.orgs)?);
|
|
1268
2425
|
save_session(&session)?;
|
|
1269
2426
|
Ok(())
|
|
1270
2427
|
}
|
|
1271
2428
|
|
|
1272
|
-
async fn
|
|
2429
|
+
async fn org_create_command(client: &reqwest::Client, args: OrgCreateArgs) -> Result<()> {
|
|
1273
2430
|
let mut session = load_session()?;
|
|
1274
2431
|
apply_base_url_override(&mut session, args.base_url);
|
|
1275
2432
|
|
|
@@ -1277,48 +2434,341 @@ async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArg
|
|
|
1277
2434
|
client,
|
|
1278
2435
|
&mut session,
|
|
1279
2436
|
Method::POST,
|
|
1280
|
-
"/core/
|
|
2437
|
+
"/core/orgs",
|
|
1281
2438
|
Some(serde_json::json!({
|
|
1282
|
-
"
|
|
1283
|
-
"name": args.name,
|
|
1284
|
-
"description": args.description
|
|
2439
|
+
"name": args.name
|
|
1285
2440
|
})),
|
|
1286
2441
|
)
|
|
1287
2442
|
.await?;
|
|
1288
2443
|
if !response.status().is_success() {
|
|
1289
2444
|
let body = read_error_body(response).await;
|
|
1290
|
-
return Err(anyhow!("
|
|
2445
|
+
return Err(anyhow!("org create failed: {}", body));
|
|
1291
2446
|
}
|
|
1292
|
-
let payload:
|
|
1293
|
-
println!("{}", serde_json::to_string_pretty(&payload.
|
|
2447
|
+
let payload: OrgResponse = response.json().await?;
|
|
2448
|
+
println!("{}", serde_json::to_string_pretty(&payload.org)?);
|
|
1294
2449
|
save_session(&session)?;
|
|
1295
2450
|
Ok(())
|
|
1296
2451
|
}
|
|
1297
2452
|
|
|
1298
|
-
async fn
|
|
2453
|
+
async fn org_get_command(client: &reqwest::Client, args: OrgGetArgs) -> Result<()> {
|
|
1299
2454
|
let mut session = load_session()?;
|
|
1300
2455
|
apply_base_url_override(&mut session, args.base_url);
|
|
1301
2456
|
|
|
1302
|
-
let path = format!("/core/
|
|
2457
|
+
let path = format!("/core/orgs/{}", args.org_id);
|
|
1303
2458
|
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1304
2459
|
if !response.status().is_success() {
|
|
1305
2460
|
let body = read_error_body(response).await;
|
|
1306
|
-
return Err(anyhow!("
|
|
2461
|
+
return Err(anyhow!("org get failed: {}", body));
|
|
1307
2462
|
}
|
|
1308
|
-
let payload:
|
|
1309
|
-
println!("{}", serde_json::to_string_pretty(&payload
|
|
2463
|
+
let payload: OrgResponse = response.json().await?;
|
|
2464
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1310
2465
|
save_session(&session)?;
|
|
1311
2466
|
Ok(())
|
|
1312
2467
|
}
|
|
1313
2468
|
|
|
1314
|
-
async fn
|
|
2469
|
+
async fn org_update_command(client: &reqwest::Client, args: OrgUpdateArgs) -> Result<()> {
|
|
1315
2470
|
let mut session = load_session()?;
|
|
1316
2471
|
apply_base_url_override(&mut session, args.base_url);
|
|
1317
2472
|
|
|
1318
|
-
let
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
2473
|
+
let path = format!("/core/orgs/{}", args.org_id);
|
|
2474
|
+
let response = authed_request(
|
|
2475
|
+
client,
|
|
2476
|
+
&mut session,
|
|
2477
|
+
Method::PATCH,
|
|
2478
|
+
&path,
|
|
2479
|
+
Some(serde_json::json!({
|
|
2480
|
+
"name": args.name
|
|
2481
|
+
})),
|
|
2482
|
+
)
|
|
2483
|
+
.await?;
|
|
2484
|
+
if !response.status().is_success() {
|
|
2485
|
+
let body = read_error_body(response).await;
|
|
2486
|
+
return Err(anyhow!("org update failed: {}", body));
|
|
2487
|
+
}
|
|
2488
|
+
let payload: OrgResponse = response.json().await?;
|
|
2489
|
+
println!("{}", serde_json::to_string_pretty(&payload.org)?);
|
|
2490
|
+
save_session(&session)?;
|
|
2491
|
+
Ok(())
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
async fn org_delete_command(client: &reqwest::Client, args: OrgDeleteArgs) -> Result<()> {
|
|
2495
|
+
let mut session = load_session()?;
|
|
2496
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2497
|
+
|
|
2498
|
+
let path = format!("/core/orgs/{}", args.org_id);
|
|
2499
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
2500
|
+
if !response.status().is_success() {
|
|
2501
|
+
let body = read_error_body(response).await;
|
|
2502
|
+
return Err(anyhow!("org delete failed: {}", body));
|
|
2503
|
+
}
|
|
2504
|
+
let payload: serde_json::Value = response.json().await?;
|
|
2505
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
2506
|
+
save_session(&session)?;
|
|
2507
|
+
Ok(())
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
async fn org_invites_command(client: &reqwest::Client, args: OrgInvitesArgs) -> Result<()> {
|
|
2511
|
+
let mut session = load_session()?;
|
|
2512
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2513
|
+
|
|
2514
|
+
let path = format!("/core/orgs/{}/invites", args.org_id);
|
|
2515
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2516
|
+
if !response.status().is_success() {
|
|
2517
|
+
let body = read_error_body(response).await;
|
|
2518
|
+
if let Some(api_error) = parse_api_error(&body) {
|
|
2519
|
+
match api_error.code.as_deref() {
|
|
2520
|
+
Some("CLERK_ORG_NOT_LINKED") => {
|
|
2521
|
+
return Err(anyhow!(
|
|
2522
|
+
"org invites unavailable: {}. Action: create this organization from a Clerk-authenticated session, then retry.",
|
|
2523
|
+
api_error
|
|
2524
|
+
.message
|
|
2525
|
+
.unwrap_or_else(|| "organization is not linked to Clerk".to_string())
|
|
2526
|
+
));
|
|
2527
|
+
}
|
|
2528
|
+
Some("CLERK_NOT_CONFIGURED") => {
|
|
2529
|
+
return Err(anyhow!(
|
|
2530
|
+
"org invites unavailable: {}. Action: configure CLERK_SECRET_KEY on the API deployment.",
|
|
2531
|
+
api_error
|
|
2532
|
+
.message
|
|
2533
|
+
.unwrap_or_else(|| "Clerk is not configured".to_string())
|
|
2534
|
+
));
|
|
2535
|
+
}
|
|
2536
|
+
_ => {}
|
|
2537
|
+
}
|
|
2538
|
+
}
|
|
2539
|
+
return Err(anyhow!("org invites failed: {}", body));
|
|
2540
|
+
}
|
|
2541
|
+
let payload: ListOrgInvitesResponse = response.json().await?;
|
|
2542
|
+
println!("{}", serde_json::to_string_pretty(&payload.invites)?);
|
|
2543
|
+
save_session(&session)?;
|
|
2544
|
+
Ok(())
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
async fn org_invite_command(client: &reqwest::Client, args: OrgInviteArgs) -> Result<()> {
|
|
2548
|
+
let mut session = load_session()?;
|
|
2549
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2550
|
+
|
|
2551
|
+
let role = args.role.trim().to_lowercase();
|
|
2552
|
+
if role != "member" && role != "admin" {
|
|
2553
|
+
return Err(anyhow!("org invite role must be either 'member' or 'admin'"));
|
|
2554
|
+
}
|
|
2555
|
+
if args.expires_in_days == 0 || args.expires_in_days > 30 {
|
|
2556
|
+
return Err(anyhow!("org invite expires_in_days must be between 1 and 30"));
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
let path = format!("/core/orgs/{}/invites", args.org_id);
|
|
2560
|
+
let response = authed_request(
|
|
2561
|
+
client,
|
|
2562
|
+
&mut session,
|
|
2563
|
+
Method::POST,
|
|
2564
|
+
&path,
|
|
2565
|
+
Some(serde_json::json!({
|
|
2566
|
+
"email": args.email.trim(),
|
|
2567
|
+
"role": role,
|
|
2568
|
+
"expiresInDays": args.expires_in_days
|
|
2569
|
+
})),
|
|
2570
|
+
)
|
|
2571
|
+
.await?;
|
|
2572
|
+
if !response.status().is_success() {
|
|
2573
|
+
let body = read_error_body(response).await;
|
|
2574
|
+
if let Some(api_error) = parse_api_error(&body) {
|
|
2575
|
+
match api_error.code.as_deref() {
|
|
2576
|
+
Some("CLERK_USER_NOT_LINKED") => {
|
|
2577
|
+
return Err(anyhow!(
|
|
2578
|
+
"org invite unavailable: {}. Action: login via Clerk on the web once, then rerun this command.",
|
|
2579
|
+
api_error
|
|
2580
|
+
.message
|
|
2581
|
+
.unwrap_or_else(|| "current user is not linked to Clerk".to_string())
|
|
2582
|
+
));
|
|
2583
|
+
}
|
|
2584
|
+
Some("CLERK_ORG_NOT_LINKED") => {
|
|
2585
|
+
return Err(anyhow!(
|
|
2586
|
+
"org invite unavailable: {}. Action: create this organization from a Clerk-authenticated session, then retry.",
|
|
2587
|
+
api_error
|
|
2588
|
+
.message
|
|
2589
|
+
.unwrap_or_else(|| "organization is not linked to Clerk".to_string())
|
|
2590
|
+
));
|
|
2591
|
+
}
|
|
2592
|
+
Some("CLERK_NOT_CONFIGURED") => {
|
|
2593
|
+
return Err(anyhow!(
|
|
2594
|
+
"org invite unavailable: {}. Action: configure CLERK_SECRET_KEY on the API deployment.",
|
|
2595
|
+
api_error
|
|
2596
|
+
.message
|
|
2597
|
+
.unwrap_or_else(|| "Clerk is not configured".to_string())
|
|
2598
|
+
));
|
|
2599
|
+
}
|
|
2600
|
+
_ => {}
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
return Err(anyhow!("org invite failed: {}", body));
|
|
2604
|
+
}
|
|
2605
|
+
let payload: OrgInviteResponse = response.json().await?;
|
|
2606
|
+
println!("{}", serde_json::to_string_pretty(&payload.invite)?);
|
|
2607
|
+
save_session(&session)?;
|
|
2608
|
+
Ok(())
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
async fn org_members_command(client: &reqwest::Client, args: OrgMembersArgs) -> Result<()> {
|
|
2612
|
+
let mut session = load_session()?;
|
|
2613
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2614
|
+
|
|
2615
|
+
let path = format!("/core/orgs/{}/members", args.org_id);
|
|
2616
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2617
|
+
if !response.status().is_success() {
|
|
2618
|
+
let body = read_error_body(response).await;
|
|
2619
|
+
return Err(anyhow!("org members failed: {}", body));
|
|
2620
|
+
}
|
|
2621
|
+
let payload: ListOrgMembersResponse = response.json().await?;
|
|
2622
|
+
println!("{}", serde_json::to_string_pretty(&payload.members)?);
|
|
2623
|
+
save_session(&session)?;
|
|
2624
|
+
Ok(())
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
async fn org_add_member_command(client: &reqwest::Client, args: OrgAddMemberArgs) -> Result<()> {
|
|
2628
|
+
let mut session = load_session()?;
|
|
2629
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2630
|
+
|
|
2631
|
+
let path = format!("/core/orgs/{}/members", args.org_id);
|
|
2632
|
+
let response = authed_request(
|
|
2633
|
+
client,
|
|
2634
|
+
&mut session,
|
|
2635
|
+
Method::POST,
|
|
2636
|
+
&path,
|
|
2637
|
+
Some(serde_json::json!({
|
|
2638
|
+
"email": args.email,
|
|
2639
|
+
"role": args.role
|
|
2640
|
+
})),
|
|
2641
|
+
)
|
|
2642
|
+
.await?;
|
|
2643
|
+
if !response.status().is_success() {
|
|
2644
|
+
let body = read_error_body(response).await;
|
|
2645
|
+
return Err(anyhow!("org add member failed: {}", body));
|
|
2646
|
+
}
|
|
2647
|
+
let payload: OrgMemberResponse = response.json().await?;
|
|
2648
|
+
println!("{}", serde_json::to_string_pretty(&payload.member)?);
|
|
2649
|
+
save_session(&session)?;
|
|
2650
|
+
Ok(())
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
async fn org_update_member_command(client: &reqwest::Client, args: OrgUpdateMemberArgs) -> Result<()> {
|
|
2654
|
+
let mut session = load_session()?;
|
|
2655
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2656
|
+
|
|
2657
|
+
let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
|
|
2658
|
+
let response = authed_request(
|
|
2659
|
+
client,
|
|
2660
|
+
&mut session,
|
|
2661
|
+
Method::PATCH,
|
|
2662
|
+
&path,
|
|
2663
|
+
Some(serde_json::json!({
|
|
2664
|
+
"role": args.role
|
|
2665
|
+
})),
|
|
2666
|
+
)
|
|
2667
|
+
.await?;
|
|
2668
|
+
if !response.status().is_success() {
|
|
2669
|
+
let body = read_error_body(response).await;
|
|
2670
|
+
return Err(anyhow!("org update member failed: {}", body));
|
|
2671
|
+
}
|
|
2672
|
+
let payload: OrgMemberResponse = response.json().await?;
|
|
2673
|
+
println!("{}", serde_json::to_string_pretty(&payload.member)?);
|
|
2674
|
+
save_session(&session)?;
|
|
2675
|
+
Ok(())
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
async fn org_remove_member_command(client: &reqwest::Client, args: OrgRemoveMemberArgs) -> Result<()> {
|
|
2679
|
+
let mut session = load_session()?;
|
|
2680
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2681
|
+
|
|
2682
|
+
let path = format!("/core/orgs/{}/members/{}", args.org_id, args.user_id);
|
|
2683
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
2684
|
+
if !response.status().is_success() {
|
|
2685
|
+
let body = read_error_body(response).await;
|
|
2686
|
+
return Err(anyhow!("org remove member failed: {}", body));
|
|
2687
|
+
}
|
|
2688
|
+
let payload: serde_json::Value = response.json().await?;
|
|
2689
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
2690
|
+
save_session(&session)?;
|
|
2691
|
+
Ok(())
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -> Result<()> {
|
|
2695
|
+
let mut session = load_session()?;
|
|
2696
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2697
|
+
|
|
2698
|
+
let path = match args.org_id {
|
|
2699
|
+
Some(org_id) if !org_id.trim().is_empty() => {
|
|
2700
|
+
format!("/core/projects?orgId={}", org_id.trim())
|
|
2701
|
+
}
|
|
2702
|
+
_ => "/core/projects".to_string(),
|
|
2703
|
+
};
|
|
2704
|
+
|
|
2705
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2706
|
+
if !response.status().is_success() {
|
|
2707
|
+
let body = read_error_body(response).await;
|
|
2708
|
+
return Err(anyhow!("project list failed: {}", body));
|
|
2709
|
+
}
|
|
2710
|
+
let payload: ListProjectsResponse = response.json().await?;
|
|
2711
|
+
println!("{}", serde_json::to_string_pretty(&payload.projects)?);
|
|
2712
|
+
save_session(&session)?;
|
|
2713
|
+
Ok(())
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArgs) -> Result<()> {
|
|
2717
|
+
let mut session = load_session()?;
|
|
2718
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2719
|
+
|
|
2720
|
+
let mut body = serde_json::Map::new();
|
|
2721
|
+
body.insert("orgId".to_string(), serde_json::Value::String(args.org_id));
|
|
2722
|
+
body.insert("name".to_string(), serde_json::Value::String(args.name));
|
|
2723
|
+
if let Some(description) = args.description {
|
|
2724
|
+
body.insert(
|
|
2725
|
+
"description".to_string(),
|
|
2726
|
+
serde_json::Value::String(description),
|
|
2727
|
+
);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
let response = authed_request(
|
|
2731
|
+
client,
|
|
2732
|
+
&mut session,
|
|
2733
|
+
Method::POST,
|
|
2734
|
+
"/core/projects",
|
|
2735
|
+
Some(serde_json::Value::Object(body)),
|
|
2736
|
+
)
|
|
2737
|
+
.await?;
|
|
2738
|
+
if !response.status().is_success() {
|
|
2739
|
+
let body = read_error_body(response).await;
|
|
2740
|
+
return Err(anyhow!("project create failed: {}", body));
|
|
2741
|
+
}
|
|
2742
|
+
let payload: ProjectResponse = response.json().await?;
|
|
2743
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
2744
|
+
save_session(&session)?;
|
|
2745
|
+
Ok(())
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) -> Result<()> {
|
|
2749
|
+
let mut session = load_session()?;
|
|
2750
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2751
|
+
|
|
2752
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
2753
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2754
|
+
if !response.status().is_success() {
|
|
2755
|
+
let body = read_error_body(response).await;
|
|
2756
|
+
return Err(anyhow!("project get failed: {}", body));
|
|
2757
|
+
}
|
|
2758
|
+
let payload: ProjectResponse = response.json().await?;
|
|
2759
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
2760
|
+
save_session(&session)?;
|
|
2761
|
+
Ok(())
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArgs) -> Result<()> {
|
|
2765
|
+
let mut session = load_session()?;
|
|
2766
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2767
|
+
|
|
2768
|
+
let mut body = serde_json::Map::new();
|
|
2769
|
+
if let Some(name) = args.name {
|
|
2770
|
+
body.insert("name".to_string(), serde_json::Value::String(name));
|
|
2771
|
+
}
|
|
1322
2772
|
if args.clear_description {
|
|
1323
2773
|
body.insert("description".to_string(), serde_json::Value::Null);
|
|
1324
2774
|
} else if let Some(description) = args.description {
|
|
@@ -1328,44 +2778,1315 @@ async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArg
|
|
|
1328
2778
|
);
|
|
1329
2779
|
}
|
|
1330
2780
|
|
|
1331
|
-
if body.is_empty() {
|
|
2781
|
+
if body.is_empty() {
|
|
2782
|
+
return Err(anyhow!(
|
|
2783
|
+
"project update requires at least one field (--name, --description, or --clear-description)"
|
|
2784
|
+
));
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
2788
|
+
let response = authed_request(
|
|
2789
|
+
client,
|
|
2790
|
+
&mut session,
|
|
2791
|
+
Method::PATCH,
|
|
2792
|
+
&path,
|
|
2793
|
+
Some(serde_json::Value::Object(body)),
|
|
2794
|
+
)
|
|
2795
|
+
.await?;
|
|
2796
|
+
if !response.status().is_success() {
|
|
2797
|
+
let body = read_error_body(response).await;
|
|
2798
|
+
return Err(anyhow!("project update failed: {}", body));
|
|
2799
|
+
}
|
|
2800
|
+
let payload: ProjectResponse = response.json().await?;
|
|
2801
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
2802
|
+
save_session(&session)?;
|
|
2803
|
+
Ok(())
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArgs) -> Result<()> {
|
|
2807
|
+
let mut session = load_session()?;
|
|
2808
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2809
|
+
|
|
2810
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
2811
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
2812
|
+
if !response.status().is_success() {
|
|
2813
|
+
let body = read_error_body(response).await;
|
|
2814
|
+
return Err(anyhow!("project delete failed: {}", body));
|
|
2815
|
+
}
|
|
2816
|
+
let payload: serde_json::Value = response.json().await?;
|
|
2817
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
2818
|
+
save_session(&session)?;
|
|
2819
|
+
Ok(())
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
async fn project_members_command(client: &reqwest::Client, args: ProjectMembersArgs) -> Result<()> {
|
|
2823
|
+
let mut session = load_session()?;
|
|
2824
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2825
|
+
|
|
2826
|
+
let path = format!("/core/projects/{}/members", args.project_id);
|
|
2827
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
2828
|
+
if !response.status().is_success() {
|
|
2829
|
+
let body = read_error_body(response).await;
|
|
2830
|
+
return Err(anyhow!("project members failed: {}", body));
|
|
2831
|
+
}
|
|
2832
|
+
let payload: ListProjectMembersResponse = response.json().await?;
|
|
2833
|
+
println!("{}", serde_json::to_string_pretty(&payload.members)?);
|
|
2834
|
+
save_session(&session)?;
|
|
2835
|
+
Ok(())
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
async fn project_add_member_command(client: &reqwest::Client, args: ProjectAddMemberArgs) -> Result<()> {
|
|
2839
|
+
let mut session = load_session()?;
|
|
2840
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2841
|
+
|
|
2842
|
+
let path = format!("/core/projects/{}/members", args.project_id);
|
|
2843
|
+
let response = authed_request(
|
|
2844
|
+
client,
|
|
2845
|
+
&mut session,
|
|
2846
|
+
Method::POST,
|
|
2847
|
+
&path,
|
|
2848
|
+
Some(serde_json::json!({
|
|
2849
|
+
"email": args.email,
|
|
2850
|
+
"role": args.role
|
|
2851
|
+
})),
|
|
2852
|
+
)
|
|
2853
|
+
.await?;
|
|
2854
|
+
if !response.status().is_success() {
|
|
2855
|
+
let body = read_error_body(response).await;
|
|
2856
|
+
return Err(anyhow!("project add member failed: {}", body));
|
|
2857
|
+
}
|
|
2858
|
+
let payload: ProjectMemberResponse = response.json().await?;
|
|
2859
|
+
println!("{}", serde_json::to_string_pretty(&payload.member)?);
|
|
2860
|
+
save_session(&session)?;
|
|
2861
|
+
Ok(())
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
async fn project_update_member_command(
|
|
2865
|
+
client: &reqwest::Client,
|
|
2866
|
+
args: ProjectUpdateMemberArgs,
|
|
2867
|
+
) -> Result<()> {
|
|
2868
|
+
let mut session = load_session()?;
|
|
2869
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2870
|
+
|
|
2871
|
+
let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
|
|
2872
|
+
let response = authed_request(
|
|
2873
|
+
client,
|
|
2874
|
+
&mut session,
|
|
2875
|
+
Method::PATCH,
|
|
2876
|
+
&path,
|
|
2877
|
+
Some(serde_json::json!({
|
|
2878
|
+
"role": args.role
|
|
2879
|
+
})),
|
|
2880
|
+
)
|
|
2881
|
+
.await?;
|
|
2882
|
+
if !response.status().is_success() {
|
|
2883
|
+
let body = read_error_body(response).await;
|
|
2884
|
+
return Err(anyhow!("project update member failed: {}", body));
|
|
2885
|
+
}
|
|
2886
|
+
let payload: ProjectMemberResponse = response.json().await?;
|
|
2887
|
+
println!("{}", serde_json::to_string_pretty(&payload.member)?);
|
|
2888
|
+
save_session(&session)?;
|
|
2889
|
+
Ok(())
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
async fn project_remove_member_command(
|
|
2893
|
+
client: &reqwest::Client,
|
|
2894
|
+
args: ProjectRemoveMemberArgs,
|
|
2895
|
+
) -> Result<()> {
|
|
2896
|
+
let mut session = load_session()?;
|
|
2897
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
2898
|
+
|
|
2899
|
+
let path = format!("/core/projects/{}/members/{}", args.project_id, args.user_id);
|
|
2900
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
2901
|
+
if !response.status().is_success() {
|
|
2902
|
+
let body = read_error_body(response).await;
|
|
2903
|
+
return Err(anyhow!("project remove member failed: {}", body));
|
|
2904
|
+
}
|
|
2905
|
+
let payload: serde_json::Value = response.json().await?;
|
|
2906
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
2907
|
+
save_session(&session)?;
|
|
2908
|
+
Ok(())
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
async fn verify_project_access(
|
|
2912
|
+
client: &reqwest::Client,
|
|
2913
|
+
session: &mut SessionConfig,
|
|
2914
|
+
project_id: &str,
|
|
2915
|
+
) -> Result<()> {
|
|
2916
|
+
let path = format!("/core/projects/{}", project_id);
|
|
2917
|
+
let response = authed_request(client, session, Method::GET, &path, None).await?;
|
|
2918
|
+
if response.status().is_success() {
|
|
2919
|
+
return Ok(());
|
|
2920
|
+
}
|
|
2921
|
+
let body = read_error_body(response).await;
|
|
2922
|
+
Err(anyhow!(
|
|
2923
|
+
"project verification failed for {}: {}",
|
|
2924
|
+
project_id,
|
|
2925
|
+
body
|
|
2926
|
+
))
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
fn file_name_component(path: &str) -> String {
|
|
2930
|
+
Path::new(path)
|
|
2931
|
+
.file_name()
|
|
2932
|
+
.and_then(|value| value.to_str())
|
|
2933
|
+
.unwrap_or(path)
|
|
2934
|
+
.to_string()
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
fn detect_local_runtime_platform_arch() -> (String, String) {
|
|
2938
|
+
let platform = match std::env::consts::OS {
|
|
2939
|
+
"windows" => "win32",
|
|
2940
|
+
"macos" => "darwin",
|
|
2941
|
+
"linux" => "linux",
|
|
2942
|
+
other => other,
|
|
2943
|
+
}
|
|
2944
|
+
.to_string();
|
|
2945
|
+
let arch = match std::env::consts::ARCH {
|
|
2946
|
+
"x86_64" => "x64",
|
|
2947
|
+
"aarch64" => "arm64",
|
|
2948
|
+
other => other,
|
|
2949
|
+
}
|
|
2950
|
+
.to_string();
|
|
2951
|
+
(platform, arch)
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
fn build_unreal_link_manifest_payload(
|
|
2955
|
+
link: &UnrealLinkRecord,
|
|
2956
|
+
include_local_paths: bool,
|
|
2957
|
+
) -> serde_json::Value {
|
|
2958
|
+
let mut payload = serde_json::json!({
|
|
2959
|
+
"schemaVersion": 1,
|
|
2960
|
+
"provider": "unreal",
|
|
2961
|
+
"projectId": link.project_id,
|
|
2962
|
+
"uprojectFile": file_name_component(&link.uproject_path),
|
|
2963
|
+
"projectRootName": file_name_component(&link.project_root),
|
|
2964
|
+
"editorBinary": file_name_component(&link.editor_path),
|
|
2965
|
+
"engineRootName": link.engine_root.as_ref().map(|value| file_name_component(value)),
|
|
2966
|
+
"updatedAtEpochMs": link.updated_at_epoch_ms
|
|
2967
|
+
});
|
|
2968
|
+
|
|
2969
|
+
if include_local_paths {
|
|
2970
|
+
payload["localPaths"] = serde_json::json!({
|
|
2971
|
+
"uprojectPath": link.uproject_path,
|
|
2972
|
+
"projectRoot": link.project_root,
|
|
2973
|
+
"editorPath": link.editor_path,
|
|
2974
|
+
"engineRoot": link.engine_root
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
payload
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
async fn sync_unreal_link_manifest_asset(
|
|
2981
|
+
client: &reqwest::Client,
|
|
2982
|
+
session: &mut SessionConfig,
|
|
2983
|
+
link: &UnrealLinkRecord,
|
|
2984
|
+
include_local_paths: bool,
|
|
2985
|
+
) -> Result<AssetRecord> {
|
|
2986
|
+
let payload = build_unreal_link_manifest_payload(link, include_local_paths);
|
|
2987
|
+
let bytes = serde_json::to_vec_pretty(&payload)?;
|
|
2988
|
+
upload_asset_via_intent(
|
|
2989
|
+
client,
|
|
2990
|
+
session,
|
|
2991
|
+
&link.project_id,
|
|
2992
|
+
".reallink/link/unreal-link.latest.json",
|
|
2993
|
+
bytes,
|
|
2994
|
+
"application/json",
|
|
2995
|
+
"other",
|
|
2996
|
+
"private",
|
|
2997
|
+
)
|
|
2998
|
+
.await
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
pub(crate) async fn link_unreal_command(client: &reqwest::Client, args: LinkUnrealArgs) -> Result<()> {
|
|
3002
|
+
let uproject_path = resolve_uproject_path(&args.uproject)?;
|
|
3003
|
+
let project_root = uproject_path
|
|
3004
|
+
.parent()
|
|
3005
|
+
.ok_or_else(|| anyhow!("Invalid uproject path {}", uproject_path.display()))?
|
|
3006
|
+
.to_path_buf();
|
|
3007
|
+
let (editor_path, engine_root_path) = resolve_editor_and_engine(args.editor, args.engine_root)?;
|
|
3008
|
+
|
|
3009
|
+
let now = now_epoch_ms();
|
|
3010
|
+
let link_record = UnrealLinkRecord {
|
|
3011
|
+
project_id: args.project_id.clone(),
|
|
3012
|
+
uproject_path: uproject_path.display().to_string(),
|
|
3013
|
+
project_root: project_root.display().to_string(),
|
|
3014
|
+
engine_root: engine_root_path
|
|
3015
|
+
.as_ref()
|
|
3016
|
+
.map(|value| value.display().to_string()),
|
|
3017
|
+
editor_path: editor_path.display().to_string(),
|
|
3018
|
+
created_at_epoch_ms: now,
|
|
3019
|
+
updated_at_epoch_ms: now,
|
|
3020
|
+
};
|
|
3021
|
+
|
|
3022
|
+
let mut synced_asset: Option<AssetRecord> = None;
|
|
3023
|
+
if !args.no_verify_remote || args.sync_project {
|
|
3024
|
+
let mut session = load_session()?;
|
|
3025
|
+
apply_base_url_override(&mut session, args.base_url.clone());
|
|
3026
|
+
if !args.no_verify_remote {
|
|
3027
|
+
verify_project_access(client, &mut session, &args.project_id).await?;
|
|
3028
|
+
}
|
|
3029
|
+
if args.sync_project {
|
|
3030
|
+
let synced = sync_unreal_link_manifest_asset(
|
|
3031
|
+
client,
|
|
3032
|
+
&mut session,
|
|
3033
|
+
&link_record,
|
|
3034
|
+
args.include_local_paths,
|
|
3035
|
+
)
|
|
3036
|
+
.await?;
|
|
3037
|
+
synced_asset = Some(synced);
|
|
3038
|
+
}
|
|
3039
|
+
save_session(&session)?;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
let uproject_cmp = normalize_path_for_compare(&uproject_path);
|
|
3043
|
+
let mut config = load_unreal_links()?;
|
|
3044
|
+
config.links.retain(|entry| {
|
|
3045
|
+
entry.project_id != args.project_id
|
|
3046
|
+
&& normalize_path_string_for_compare(&entry.uproject_path) != uproject_cmp
|
|
3047
|
+
});
|
|
3048
|
+
config.links.push(link_record.clone());
|
|
3049
|
+
|
|
3050
|
+
if args.set_default || config.default_project_id.is_none() {
|
|
3051
|
+
config.default_project_id = Some(args.project_id.clone());
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
save_unreal_links(&config)?;
|
|
3055
|
+
println!(
|
|
3056
|
+
"{}",
|
|
3057
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3058
|
+
"ok": true,
|
|
3059
|
+
"defaultProjectId": config.default_project_id,
|
|
3060
|
+
"link": config.links.iter().find(|entry| entry.project_id == args.project_id),
|
|
3061
|
+
"syncedAssetId": synced_asset.as_ref().map(|entry| entry.id.clone()),
|
|
3062
|
+
"syncedManifestPath": if args.sync_project { Some(".reallink/link/unreal-link.latest.json") } else { None }
|
|
3063
|
+
}))?
|
|
3064
|
+
);
|
|
3065
|
+
Ok(())
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
pub(crate) async fn link_list_command() -> Result<()> {
|
|
3069
|
+
let config = load_unreal_links()?;
|
|
3070
|
+
println!("{}", serde_json::to_string_pretty(&config)?);
|
|
3071
|
+
Ok(())
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
pub(crate) async fn link_use_command(args: LinkUseArgs) -> Result<()> {
|
|
3075
|
+
let mut config = load_unreal_links()?;
|
|
3076
|
+
if !config
|
|
3077
|
+
.links
|
|
3078
|
+
.iter()
|
|
3079
|
+
.any(|entry| entry.project_id == args.project_id)
|
|
3080
|
+
{
|
|
3081
|
+
return Err(anyhow!(
|
|
3082
|
+
"No link found for project {}. Run `reallink link unreal ...` first.",
|
|
3083
|
+
args.project_id
|
|
3084
|
+
));
|
|
3085
|
+
}
|
|
3086
|
+
config.default_project_id = Some(args.project_id.clone());
|
|
3087
|
+
save_unreal_links(&config)?;
|
|
3088
|
+
println!(
|
|
3089
|
+
"{}",
|
|
3090
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3091
|
+
"ok": true,
|
|
3092
|
+
"defaultProjectId": args.project_id
|
|
3093
|
+
}))?
|
|
3094
|
+
);
|
|
3095
|
+
Ok(())
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
fn normalize_match_path(path: &Path) -> String {
|
|
3099
|
+
if let Ok(canonical) = canonicalize_existing_path(path, "uproject") {
|
|
3100
|
+
return normalize_path_for_compare(&canonical);
|
|
3101
|
+
}
|
|
3102
|
+
let absolute = if path.is_absolute() {
|
|
3103
|
+
path.to_path_buf()
|
|
3104
|
+
} else {
|
|
3105
|
+
match std::env::current_dir() {
|
|
3106
|
+
Ok(dir) => dir.join(path),
|
|
3107
|
+
Err(_) => path.to_path_buf(),
|
|
3108
|
+
}
|
|
3109
|
+
};
|
|
3110
|
+
normalize_path_for_compare(&absolute)
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
pub(crate) async fn link_remove_command(args: LinkRemoveArgs) -> Result<()> {
|
|
3114
|
+
if args.project_id.is_none() && args.uproject.is_none() {
|
|
3115
|
+
return Err(anyhow!(
|
|
3116
|
+
"Provide --project-id or --uproject to remove a link."
|
|
3117
|
+
));
|
|
3118
|
+
}
|
|
3119
|
+
|
|
3120
|
+
let mut config = load_unreal_links()?;
|
|
3121
|
+
let before = config.links.len();
|
|
3122
|
+
let target_uproject_cmp = args
|
|
3123
|
+
.uproject
|
|
3124
|
+
.as_ref()
|
|
3125
|
+
.map(|value| normalize_match_path(value));
|
|
3126
|
+
config.links.retain(|entry| {
|
|
3127
|
+
if let Some(project_id) = args.project_id.as_ref() {
|
|
3128
|
+
if &entry.project_id == project_id {
|
|
3129
|
+
return false;
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
if let Some(target_cmp) = target_uproject_cmp.as_ref() {
|
|
3133
|
+
if normalize_path_string_for_compare(&entry.uproject_path) == *target_cmp {
|
|
3134
|
+
return false;
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
true
|
|
3138
|
+
});
|
|
3139
|
+
let removed = before.saturating_sub(config.links.len());
|
|
3140
|
+
if removed == 0 {
|
|
3141
|
+
return Err(anyhow!("No matching link found."));
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
if let Some(default_id) = config.default_project_id.clone() {
|
|
3145
|
+
let has_default = config
|
|
3146
|
+
.links
|
|
3147
|
+
.iter()
|
|
3148
|
+
.any(|entry| entry.project_id == default_id);
|
|
3149
|
+
if !has_default {
|
|
3150
|
+
config.default_project_id = config.links.first().map(|entry| entry.project_id.clone());
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
save_unreal_links(&config)?;
|
|
3155
|
+
println!(
|
|
3156
|
+
"{}",
|
|
3157
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3158
|
+
"ok": true,
|
|
3159
|
+
"removed": removed,
|
|
3160
|
+
"defaultProjectId": config.default_project_id
|
|
3161
|
+
}))?
|
|
3162
|
+
);
|
|
3163
|
+
Ok(())
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
fn resolve_link_target(
|
|
3167
|
+
config: &UnrealLinksConfig,
|
|
3168
|
+
project_id: Option<&str>,
|
|
3169
|
+
uproject: Option<&Path>,
|
|
3170
|
+
) -> Result<UnrealLinkRecord> {
|
|
3171
|
+
if let Some(project_id) = project_id {
|
|
3172
|
+
let entry = config
|
|
3173
|
+
.links
|
|
3174
|
+
.iter()
|
|
3175
|
+
.find(|item| item.project_id == project_id)
|
|
3176
|
+
.ok_or_else(|| anyhow!("No link found for project {}", project_id))?;
|
|
3177
|
+
return Ok(entry.clone());
|
|
3178
|
+
}
|
|
3179
|
+
if let Some(uproject) = uproject {
|
|
3180
|
+
let target_cmp = normalize_match_path(uproject);
|
|
3181
|
+
let entry = config
|
|
3182
|
+
.links
|
|
3183
|
+
.iter()
|
|
3184
|
+
.find(|item| normalize_path_string_for_compare(&item.uproject_path) == target_cmp)
|
|
3185
|
+
.ok_or_else(|| anyhow!("No link found for uproject {}", uproject.display()))?;
|
|
3186
|
+
return Ok(entry.clone());
|
|
3187
|
+
}
|
|
3188
|
+
if let Some(default_project_id) = config.default_project_id.as_ref() {
|
|
3189
|
+
if let Some(entry) = config
|
|
3190
|
+
.links
|
|
3191
|
+
.iter()
|
|
3192
|
+
.find(|item| &item.project_id == default_project_id)
|
|
3193
|
+
{
|
|
3194
|
+
return Ok(entry.clone());
|
|
3195
|
+
}
|
|
3196
|
+
}
|
|
3197
|
+
if config.links.len() == 1 {
|
|
3198
|
+
return Ok(config.links[0].clone());
|
|
3199
|
+
}
|
|
3200
|
+
Err(anyhow!(
|
|
3201
|
+
"No target specified. Use --project-id or set a default link with `reallink link use --project-id ...`."
|
|
3202
|
+
))
|
|
3203
|
+
}
|
|
3204
|
+
|
|
3205
|
+
fn unreal_manifest_local_path(link: &UnrealLinkRecord) -> PathBuf {
|
|
3206
|
+
PathBuf::from(&link.project_root)
|
|
3207
|
+
.join(".reallink")
|
|
3208
|
+
.join("link")
|
|
3209
|
+
.join("unreal-link.latest.json")
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
fn push_doctor_check(
|
|
3213
|
+
checks: &mut Vec<serde_json::Value>,
|
|
3214
|
+
errors: &mut Vec<String>,
|
|
3215
|
+
warnings: &mut Vec<String>,
|
|
3216
|
+
key: &str,
|
|
3217
|
+
ok: bool,
|
|
3218
|
+
detail: impl Into<String>,
|
|
3219
|
+
warning_only: bool,
|
|
3220
|
+
) {
|
|
3221
|
+
let detail_text = detail.into();
|
|
3222
|
+
let status = if ok {
|
|
3223
|
+
"ok"
|
|
3224
|
+
} else if warning_only {
|
|
3225
|
+
"warn"
|
|
3226
|
+
} else {
|
|
3227
|
+
"error"
|
|
3228
|
+
};
|
|
3229
|
+
if !ok {
|
|
3230
|
+
if warning_only {
|
|
3231
|
+
warnings.push(format!("{}: {}", key, detail_text));
|
|
3232
|
+
} else {
|
|
3233
|
+
errors.push(format!("{}: {}", key, detail_text));
|
|
3234
|
+
}
|
|
3235
|
+
}
|
|
3236
|
+
checks.push(serde_json::json!({
|
|
3237
|
+
"key": key,
|
|
3238
|
+
"status": status,
|
|
3239
|
+
"detail": detail_text
|
|
3240
|
+
}));
|
|
3241
|
+
}
|
|
3242
|
+
|
|
3243
|
+
pub(crate) async fn link_paths_command(args: LinkPathsArgs) -> Result<()> {
|
|
3244
|
+
let config = load_unreal_links()?;
|
|
3245
|
+
if config.links.is_empty() {
|
|
3246
|
+
return Err(anyhow!(
|
|
3247
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
3248
|
+
));
|
|
3249
|
+
}
|
|
3250
|
+
let target = resolve_link_target(
|
|
3251
|
+
&config,
|
|
3252
|
+
args.project_id.as_deref(),
|
|
3253
|
+
args.uproject.as_deref(),
|
|
3254
|
+
)?;
|
|
3255
|
+
let project_plugins_dir = PathBuf::from(&target.project_root).join("Plugins");
|
|
3256
|
+
let engine_plugins_dir = target.engine_root.as_ref().map(|root| {
|
|
3257
|
+
PathBuf::from(root)
|
|
3258
|
+
.join("Engine")
|
|
3259
|
+
.join("Plugins")
|
|
3260
|
+
.join("Marketplace")
|
|
3261
|
+
});
|
|
3262
|
+
let manifest_path = unreal_manifest_local_path(&target);
|
|
3263
|
+
|
|
3264
|
+
print_json(&serde_json::json!({
|
|
3265
|
+
"ok": true,
|
|
3266
|
+
"projectId": target.project_id,
|
|
3267
|
+
"paths": {
|
|
3268
|
+
"uprojectPath": target.uproject_path,
|
|
3269
|
+
"projectRoot": target.project_root,
|
|
3270
|
+
"editorPath": target.editor_path,
|
|
3271
|
+
"engineRoot": target.engine_root,
|
|
3272
|
+
"projectPluginsDir": project_plugins_dir.display().to_string(),
|
|
3273
|
+
"enginePluginsDir": engine_plugins_dir.as_ref().map(|value| value.display().to_string()),
|
|
3274
|
+
"manifestPath": manifest_path.display().to_string(),
|
|
3275
|
+
"localLinkConfigPath": unreal_links_path()?.display().to_string()
|
|
3276
|
+
},
|
|
3277
|
+
"remoteManifestAssetPath": ".reallink/link/unreal-link.latest.json"
|
|
3278
|
+
}))?;
|
|
3279
|
+
Ok(())
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
pub(crate) async fn link_doctor_command(client: &reqwest::Client, args: LinkDoctorArgs) -> Result<()> {
|
|
3283
|
+
let config = load_unreal_links()?;
|
|
3284
|
+
if config.links.is_empty() {
|
|
3285
|
+
return Err(anyhow!(
|
|
3286
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
3287
|
+
));
|
|
3288
|
+
}
|
|
3289
|
+
let target = resolve_link_target(
|
|
3290
|
+
&config,
|
|
3291
|
+
args.project_id.as_deref(),
|
|
3292
|
+
args.uproject.as_deref(),
|
|
3293
|
+
)?;
|
|
3294
|
+
|
|
3295
|
+
let mut checks: Vec<serde_json::Value> = Vec::new();
|
|
3296
|
+
let mut warnings: Vec<String> = Vec::new();
|
|
3297
|
+
let mut errors: Vec<String> = Vec::new();
|
|
3298
|
+
|
|
3299
|
+
let project_root = PathBuf::from(&target.project_root);
|
|
3300
|
+
let uproject_path = PathBuf::from(&target.uproject_path);
|
|
3301
|
+
let editor_path = PathBuf::from(&target.editor_path);
|
|
3302
|
+
let engine_root = target.engine_root.as_ref().map(PathBuf::from);
|
|
3303
|
+
let project_plugins_dir = project_root.join("Plugins");
|
|
3304
|
+
let engine_plugins_dir = engine_root
|
|
3305
|
+
.as_ref()
|
|
3306
|
+
.map(|root| root.join("Engine").join("Plugins").join("Marketplace"));
|
|
3307
|
+
let manifest_path = unreal_manifest_local_path(&target);
|
|
3308
|
+
|
|
3309
|
+
push_doctor_check(
|
|
3310
|
+
&mut checks,
|
|
3311
|
+
&mut errors,
|
|
3312
|
+
&mut warnings,
|
|
3313
|
+
"projectRoot.exists",
|
|
3314
|
+
project_root.exists() && project_root.is_dir(),
|
|
3315
|
+
format!("projectRoot={}", project_root.display()),
|
|
3316
|
+
false,
|
|
3317
|
+
);
|
|
3318
|
+
let uproject_ext_ok = uproject_path
|
|
3319
|
+
.extension()
|
|
3320
|
+
.and_then(|value| value.to_str())
|
|
3321
|
+
.map(|value| value.eq_ignore_ascii_case("uproject"))
|
|
3322
|
+
.unwrap_or(false);
|
|
3323
|
+
push_doctor_check(
|
|
3324
|
+
&mut checks,
|
|
3325
|
+
&mut errors,
|
|
3326
|
+
&mut warnings,
|
|
3327
|
+
"uproject.valid",
|
|
3328
|
+
uproject_path.exists() && uproject_path.is_file() && uproject_ext_ok,
|
|
3329
|
+
format!("uprojectPath={}", uproject_path.display()),
|
|
3330
|
+
false,
|
|
3331
|
+
);
|
|
3332
|
+
push_doctor_check(
|
|
3333
|
+
&mut checks,
|
|
3334
|
+
&mut errors,
|
|
3335
|
+
&mut warnings,
|
|
3336
|
+
"editor.valid",
|
|
3337
|
+
editor_path.exists() && editor_path.is_file(),
|
|
3338
|
+
format!("editorPath={}", editor_path.display()),
|
|
3339
|
+
false,
|
|
3340
|
+
);
|
|
3341
|
+
if let Some(root) = engine_root.as_ref() {
|
|
3342
|
+
let root_ok = root.exists() && root.is_dir();
|
|
3343
|
+
push_doctor_check(
|
|
3344
|
+
&mut checks,
|
|
3345
|
+
&mut errors,
|
|
3346
|
+
&mut warnings,
|
|
3347
|
+
"engineRoot.valid",
|
|
3348
|
+
root_ok,
|
|
3349
|
+
format!("engineRoot={}", root.display()),
|
|
3350
|
+
false,
|
|
3351
|
+
);
|
|
3352
|
+
let binaries_path = root.join("Engine").join("Binaries");
|
|
3353
|
+
push_doctor_check(
|
|
3354
|
+
&mut checks,
|
|
3355
|
+
&mut errors,
|
|
3356
|
+
&mut warnings,
|
|
3357
|
+
"engineRoot.layout",
|
|
3358
|
+
binaries_path.exists() && binaries_path.is_dir(),
|
|
3359
|
+
format!("expected Engine/Binaries under {}", root.display()),
|
|
3360
|
+
true,
|
|
3361
|
+
);
|
|
3362
|
+
} else {
|
|
3363
|
+
push_doctor_check(
|
|
3364
|
+
&mut checks,
|
|
3365
|
+
&mut errors,
|
|
3366
|
+
&mut warnings,
|
|
3367
|
+
"engineRoot.present",
|
|
3368
|
+
false,
|
|
3369
|
+
"engineRoot not set on this link; plugin engine-scope operations are unavailable",
|
|
3370
|
+
true,
|
|
3371
|
+
);
|
|
3372
|
+
}
|
|
3373
|
+
push_doctor_check(
|
|
3374
|
+
&mut checks,
|
|
3375
|
+
&mut errors,
|
|
3376
|
+
&mut warnings,
|
|
3377
|
+
"plugins.projectDir",
|
|
3378
|
+
project_plugins_dir.exists() && project_plugins_dir.is_dir(),
|
|
3379
|
+
format!("projectPluginsDir={}", project_plugins_dir.display()),
|
|
3380
|
+
true,
|
|
3381
|
+
);
|
|
3382
|
+
if let Some(engine_plugins) = engine_plugins_dir.as_ref() {
|
|
3383
|
+
push_doctor_check(
|
|
3384
|
+
&mut checks,
|
|
3385
|
+
&mut errors,
|
|
3386
|
+
&mut warnings,
|
|
3387
|
+
"plugins.engineDir",
|
|
3388
|
+
engine_plugins.exists() && engine_plugins.is_dir(),
|
|
3389
|
+
format!("enginePluginsDir={}", engine_plugins.display()),
|
|
3390
|
+
true,
|
|
3391
|
+
);
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
if manifest_path.exists() {
|
|
3395
|
+
let manifest_ok = fs::read_to_string(&manifest_path)
|
|
3396
|
+
.ok()
|
|
3397
|
+
.and_then(|value| json5::from_str::<serde_json::Value>(&value).ok())
|
|
3398
|
+
.is_some();
|
|
3399
|
+
push_doctor_check(
|
|
3400
|
+
&mut checks,
|
|
3401
|
+
&mut errors,
|
|
3402
|
+
&mut warnings,
|
|
3403
|
+
"manifest.local",
|
|
3404
|
+
manifest_ok,
|
|
3405
|
+
format!("manifestPath={}", manifest_path.display()),
|
|
3406
|
+
true,
|
|
3407
|
+
);
|
|
3408
|
+
} else {
|
|
3409
|
+
push_doctor_check(
|
|
3410
|
+
&mut checks,
|
|
3411
|
+
&mut errors,
|
|
3412
|
+
&mut warnings,
|
|
3413
|
+
"manifest.local",
|
|
3414
|
+
false,
|
|
3415
|
+
format!("manifestPath={} (missing)", manifest_path.display()),
|
|
3416
|
+
true,
|
|
3417
|
+
);
|
|
3418
|
+
}
|
|
3419
|
+
|
|
3420
|
+
if args.verify_remote {
|
|
3421
|
+
let (remote_ok, remote_detail) = match load_session() {
|
|
3422
|
+
Ok(mut session) => {
|
|
3423
|
+
apply_base_url_override(&mut session, args.base_url.clone());
|
|
3424
|
+
let outcome = if let Err(error) =
|
|
3425
|
+
verify_project_access(client, &mut session, &target.project_id).await
|
|
3426
|
+
{
|
|
3427
|
+
(false, error.to_string())
|
|
3428
|
+
} else {
|
|
3429
|
+
(
|
|
3430
|
+
true,
|
|
3431
|
+
format!(
|
|
3432
|
+
"projectId={} verified against {}",
|
|
3433
|
+
target.project_id, session.base_url
|
|
3434
|
+
),
|
|
3435
|
+
)
|
|
3436
|
+
};
|
|
3437
|
+
let _ = save_session(&session);
|
|
3438
|
+
outcome
|
|
3439
|
+
}
|
|
3440
|
+
Err(error) => (false, format!("no active session: {}", error)),
|
|
3441
|
+
};
|
|
3442
|
+
push_doctor_check(
|
|
3443
|
+
&mut checks,
|
|
3444
|
+
&mut errors,
|
|
3445
|
+
&mut warnings,
|
|
3446
|
+
"remote.verify",
|
|
3447
|
+
remote_ok,
|
|
3448
|
+
remote_detail,
|
|
3449
|
+
false,
|
|
3450
|
+
);
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
let ok = errors.is_empty();
|
|
3454
|
+
print_json(&serde_json::json!({
|
|
3455
|
+
"ok": ok,
|
|
3456
|
+
"projectId": target.project_id,
|
|
3457
|
+
"checks": checks,
|
|
3458
|
+
"warnings": warnings,
|
|
3459
|
+
"errors": errors,
|
|
3460
|
+
"paths": {
|
|
3461
|
+
"uprojectPath": uproject_path.display().to_string(),
|
|
3462
|
+
"projectRoot": project_root.display().to_string(),
|
|
3463
|
+
"editorPath": editor_path.display().to_string(),
|
|
3464
|
+
"engineRoot": engine_root.as_ref().map(|value| value.display().to_string()),
|
|
3465
|
+
"projectPluginsDir": project_plugins_dir.display().to_string(),
|
|
3466
|
+
"enginePluginsDir": engine_plugins_dir.as_ref().map(|value| value.display().to_string()),
|
|
3467
|
+
"manifestPath": manifest_path.display().to_string()
|
|
3468
|
+
}
|
|
3469
|
+
}))?;
|
|
3470
|
+
Ok(())
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
pub(crate) async fn link_open_command(args: LinkOpenArgs) -> Result<()> {
|
|
3474
|
+
let config = load_unreal_links()?;
|
|
3475
|
+
if config.links.is_empty() {
|
|
3476
|
+
return Err(anyhow!(
|
|
3477
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
3478
|
+
));
|
|
3479
|
+
}
|
|
3480
|
+
let target = resolve_link_target(
|
|
3481
|
+
&config,
|
|
3482
|
+
args.project_id.as_deref(),
|
|
3483
|
+
args.uproject.as_deref(),
|
|
3484
|
+
)?;
|
|
3485
|
+
let editor_path = PathBuf::from(&target.editor_path);
|
|
3486
|
+
let uproject_path = PathBuf::from(&target.uproject_path);
|
|
3487
|
+
|
|
3488
|
+
if !editor_path.exists() {
|
|
3489
|
+
return Err(anyhow!(
|
|
3490
|
+
"Editor path does not exist anymore: {}",
|
|
3491
|
+
editor_path.display()
|
|
3492
|
+
));
|
|
3493
|
+
}
|
|
3494
|
+
if !uproject_path.exists() {
|
|
3495
|
+
return Err(anyhow!(
|
|
3496
|
+
"uproject path does not exist anymore: {}",
|
|
3497
|
+
uproject_path.display()
|
|
3498
|
+
));
|
|
3499
|
+
}
|
|
3500
|
+
|
|
3501
|
+
let mut command = Command::new(&editor_path);
|
|
3502
|
+
command.arg(&uproject_path);
|
|
3503
|
+
for argument in args.extra_arg {
|
|
3504
|
+
command.arg(argument);
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
if args.wait {
|
|
3508
|
+
let status = command
|
|
3509
|
+
.status()
|
|
3510
|
+
.with_context(|| format!("Failed to run Unreal editor {}", editor_path.display()))?;
|
|
3511
|
+
if !status.success() {
|
|
3512
|
+
return Err(anyhow!("Unreal editor exited with {}", status));
|
|
3513
|
+
}
|
|
3514
|
+
println!(
|
|
3515
|
+
"{}",
|
|
3516
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3517
|
+
"ok": true,
|
|
3518
|
+
"projectId": target.project_id,
|
|
3519
|
+
"waited": true,
|
|
3520
|
+
"status": status.code()
|
|
3521
|
+
}))?
|
|
3522
|
+
);
|
|
3523
|
+
} else {
|
|
3524
|
+
let child = command
|
|
3525
|
+
.spawn()
|
|
3526
|
+
.with_context(|| format!("Failed to launch Unreal editor {}", editor_path.display()))?;
|
|
3527
|
+
println!(
|
|
3528
|
+
"{}",
|
|
3529
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3530
|
+
"ok": true,
|
|
3531
|
+
"projectId": target.project_id,
|
|
3532
|
+
"waited": false,
|
|
3533
|
+
"pid": child.id()
|
|
3534
|
+
}))?
|
|
3535
|
+
);
|
|
3536
|
+
}
|
|
3537
|
+
|
|
3538
|
+
Ok(())
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
pub(crate) async fn link_run_command(args: LinkRunArgs) -> Result<()> {
|
|
3542
|
+
let config = load_unreal_links()?;
|
|
3543
|
+
if config.links.is_empty() {
|
|
3544
|
+
return Err(anyhow!(
|
|
3545
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
3546
|
+
));
|
|
3547
|
+
}
|
|
3548
|
+
let target = resolve_link_target(
|
|
3549
|
+
&config,
|
|
3550
|
+
args.project_id.as_deref(),
|
|
3551
|
+
args.uproject.as_deref(),
|
|
3552
|
+
)?;
|
|
3553
|
+
let editor_path = PathBuf::from(&target.editor_path);
|
|
3554
|
+
let uproject_path = PathBuf::from(&target.uproject_path);
|
|
3555
|
+
|
|
3556
|
+
if !editor_path.exists() {
|
|
3557
|
+
return Err(anyhow!(
|
|
3558
|
+
"Editor path does not exist anymore: {}",
|
|
3559
|
+
editor_path.display()
|
|
3560
|
+
));
|
|
3561
|
+
}
|
|
3562
|
+
if !uproject_path.exists() {
|
|
3563
|
+
return Err(anyhow!(
|
|
3564
|
+
"uproject path does not exist anymore: {}",
|
|
3565
|
+
uproject_path.display()
|
|
3566
|
+
));
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
let mut command = Command::new(&editor_path);
|
|
3570
|
+
command.arg(&uproject_path);
|
|
3571
|
+
if let Some(commandlet) = args.commandlet.as_ref() {
|
|
3572
|
+
let normalized = commandlet.trim();
|
|
3573
|
+
if normalized.is_empty() {
|
|
3574
|
+
return Err(anyhow!("--commandlet cannot be empty"));
|
|
3575
|
+
}
|
|
3576
|
+
command.arg(format!("-run={}", normalized));
|
|
3577
|
+
}
|
|
3578
|
+
if args.log {
|
|
3579
|
+
command.arg("-log");
|
|
3580
|
+
}
|
|
3581
|
+
if args.headless {
|
|
3582
|
+
command.arg("-unattended");
|
|
3583
|
+
command.arg("-nop4");
|
|
3584
|
+
command.arg("-nosplash");
|
|
3585
|
+
command.arg("-nullrhi");
|
|
3586
|
+
}
|
|
3587
|
+
for argument in args.extra_arg {
|
|
3588
|
+
command.arg(argument);
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
if args.no_wait {
|
|
3592
|
+
let child = command
|
|
3593
|
+
.spawn()
|
|
3594
|
+
.with_context(|| format!("Failed to launch Unreal editor {}", editor_path.display()))?;
|
|
3595
|
+
println!(
|
|
3596
|
+
"{}",
|
|
3597
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3598
|
+
"ok": true,
|
|
3599
|
+
"projectId": target.project_id,
|
|
3600
|
+
"mode": if args.commandlet.is_some() { "commandlet" } else { "editor" },
|
|
3601
|
+
"waited": false,
|
|
3602
|
+
"pid": child.id()
|
|
3603
|
+
}))?
|
|
3604
|
+
);
|
|
3605
|
+
return Ok(());
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
let status = command
|
|
3609
|
+
.status()
|
|
3610
|
+
.with_context(|| format!("Failed to run Unreal editor {}", editor_path.display()))?;
|
|
3611
|
+
if !status.success() {
|
|
3612
|
+
return Err(anyhow!("Unreal editor exited with {}", status));
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
println!(
|
|
3616
|
+
"{}",
|
|
3617
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
3618
|
+
"ok": true,
|
|
3619
|
+
"projectId": target.project_id,
|
|
3620
|
+
"mode": if args.commandlet.is_some() { "commandlet" } else { "editor" },
|
|
3621
|
+
"waited": true,
|
|
3622
|
+
"status": status.code()
|
|
3623
|
+
}))?
|
|
3624
|
+
);
|
|
3625
|
+
Ok(())
|
|
3626
|
+
}
|
|
3627
|
+
|
|
3628
|
+
fn normalize_public_bucket_base(base_url: &str) -> Result<String> {
|
|
3629
|
+
let trimmed = base_url.trim().trim_end_matches('/');
|
|
3630
|
+
if trimmed.is_empty() {
|
|
3631
|
+
return Err(anyhow!("Plugin base URL is empty"));
|
|
3632
|
+
}
|
|
3633
|
+
let parsed = reqwest::Url::parse(trimmed)
|
|
3634
|
+
.with_context(|| format!("Invalid plugin base URL {}", trimmed))?;
|
|
3635
|
+
Ok(parsed.to_string().trim_end_matches('/').to_string())
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
fn compose_plugin_archive_url(base_url: &str, name: &str, version: &str) -> Result<String> {
|
|
3639
|
+
let plugin = name.trim();
|
|
3640
|
+
let release = version.trim();
|
|
3641
|
+
if plugin.is_empty() {
|
|
3642
|
+
return Err(anyhow!("Plugin name is required"));
|
|
3643
|
+
}
|
|
3644
|
+
if release.is_empty() {
|
|
3645
|
+
return Err(anyhow!("Plugin version is required"));
|
|
3646
|
+
}
|
|
3647
|
+
if plugin.contains('/') || plugin.contains('\\') {
|
|
3648
|
+
return Err(anyhow!("Plugin name cannot include path separators"));
|
|
3649
|
+
}
|
|
3650
|
+
let base = normalize_public_bucket_base(base_url)?;
|
|
3651
|
+
Ok(format!("{}/{}/{}/{}.zip", base, plugin, release, plugin))
|
|
3652
|
+
}
|
|
3653
|
+
|
|
3654
|
+
fn normalize_sha256_hex(input: &str) -> Result<String> {
|
|
3655
|
+
let cleaned = input.trim().to_ascii_lowercase();
|
|
3656
|
+
if cleaned.len() != 64 {
|
|
3657
|
+
return Err(anyhow!(
|
|
3658
|
+
"SHA-256 must be a 64-character hex string, got length {}",
|
|
3659
|
+
cleaned.len()
|
|
3660
|
+
));
|
|
3661
|
+
}
|
|
3662
|
+
if !cleaned
|
|
3663
|
+
.chars()
|
|
3664
|
+
.all(|value| matches!(value, '0'..='9' | 'a'..='f'))
|
|
3665
|
+
{
|
|
3666
|
+
return Err(anyhow!("SHA-256 contains non-hex characters"));
|
|
3667
|
+
}
|
|
3668
|
+
Ok(cleaned)
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
async fn fetch_plugin_index_value(
|
|
3672
|
+
client: &reqwest::Client,
|
|
3673
|
+
base_url: &str,
|
|
3674
|
+
index_path: &str,
|
|
3675
|
+
) -> Result<serde_json::Value> {
|
|
3676
|
+
let base = normalize_public_bucket_base(base_url)?;
|
|
3677
|
+
let normalized_index_path = index_path.trim().trim_start_matches('/');
|
|
3678
|
+
if normalized_index_path.is_empty() {
|
|
3679
|
+
return Err(anyhow!("index path cannot be empty"));
|
|
3680
|
+
}
|
|
3681
|
+
let url = format!("{}/{}", base, normalized_index_path);
|
|
3682
|
+
let response = with_cli_headers(client.get(url.clone()))
|
|
3683
|
+
.send()
|
|
3684
|
+
.await
|
|
3685
|
+
.with_context(|| format!("Failed to fetch plugin index {}", url))?;
|
|
3686
|
+
if !response.status().is_success() {
|
|
3687
|
+
let body = read_error_body(response).await;
|
|
3688
|
+
return Err(anyhow!("plugin index fetch failed: {}", body));
|
|
3689
|
+
}
|
|
3690
|
+
let body = response
|
|
3691
|
+
.text()
|
|
3692
|
+
.await
|
|
3693
|
+
.with_context(|| format!("Failed to read plugin index {}", url))?;
|
|
3694
|
+
serde_json::from_str(&body)
|
|
3695
|
+
.or_else(|_| json5::from_str(&body))
|
|
3696
|
+
.with_context(|| format!("Plugin index is not valid JSON/JSONC: {}", url))
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
async fn fetch_plugin_index(
|
|
3700
|
+
client: &reqwest::Client,
|
|
3701
|
+
base_url: &str,
|
|
3702
|
+
index_path: &str,
|
|
3703
|
+
) -> Result<PluginIndexFile> {
|
|
3704
|
+
let value = fetch_plugin_index_value(client, base_url, index_path).await?;
|
|
3705
|
+
let parsed: PluginIndexFile = serde_json::from_value(value)
|
|
3706
|
+
.with_context(|| "Plugin index does not match expected schema")?;
|
|
3707
|
+
Ok(parsed)
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
fn resolve_plugin_from_index(
|
|
3711
|
+
index: &PluginIndexFile,
|
|
3712
|
+
name: &str,
|
|
3713
|
+
requested_version: &str,
|
|
3714
|
+
base_url: &str,
|
|
3715
|
+
) -> Result<(String, String, Option<String>)> {
|
|
3716
|
+
let plugin_name = name.trim();
|
|
3717
|
+
let version_request = requested_version.trim();
|
|
3718
|
+
if plugin_name.is_empty() {
|
|
3719
|
+
return Err(anyhow!("Plugin name is required"));
|
|
3720
|
+
}
|
|
3721
|
+
if version_request.is_empty() {
|
|
3722
|
+
return Err(anyhow!("Plugin version is required"));
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
let plugin = index
|
|
3726
|
+
.plugins
|
|
3727
|
+
.iter()
|
|
3728
|
+
.find(|entry| entry.name.eq_ignore_ascii_case(plugin_name))
|
|
3729
|
+
.ok_or_else(|| anyhow!("Plugin {} not found in index", plugin_name))?;
|
|
3730
|
+
|
|
3731
|
+
let resolved_version = if version_request.eq_ignore_ascii_case("latest") {
|
|
3732
|
+
plugin
|
|
3733
|
+
.latest
|
|
3734
|
+
.as_ref()
|
|
3735
|
+
.map(|value| value.trim().to_string())
|
|
3736
|
+
.or_else(|| {
|
|
3737
|
+
plugin
|
|
3738
|
+
.versions
|
|
3739
|
+
.first()
|
|
3740
|
+
.map(|entry| entry.version.trim().to_string())
|
|
3741
|
+
})
|
|
3742
|
+
.ok_or_else(|| anyhow!("Plugin {} has no versions in index", plugin_name))?
|
|
3743
|
+
} else {
|
|
3744
|
+
version_request.to_string()
|
|
3745
|
+
};
|
|
3746
|
+
|
|
3747
|
+
let version_entry = plugin
|
|
3748
|
+
.versions
|
|
3749
|
+
.iter()
|
|
3750
|
+
.find(|entry| entry.version.trim() == resolved_version)
|
|
3751
|
+
.ok_or_else(|| {
|
|
3752
|
+
anyhow!(
|
|
3753
|
+
"Version {} for plugin {} not found in index",
|
|
3754
|
+
resolved_version,
|
|
3755
|
+
plugin_name
|
|
3756
|
+
)
|
|
3757
|
+
})?;
|
|
3758
|
+
|
|
3759
|
+
let archive_url = if let Some(url) = version_entry.archive_url.as_ref() {
|
|
3760
|
+
let trimmed = url.trim();
|
|
3761
|
+
if trimmed.is_empty() {
|
|
3762
|
+
compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
|
|
3763
|
+
} else {
|
|
3764
|
+
trimmed.to_string()
|
|
3765
|
+
}
|
|
3766
|
+
} else {
|
|
3767
|
+
compose_plugin_archive_url(base_url, plugin_name, &resolved_version)?
|
|
3768
|
+
};
|
|
3769
|
+
|
|
3770
|
+
let expected_sha256 = version_entry
|
|
3771
|
+
.sha256
|
|
3772
|
+
.as_ref()
|
|
3773
|
+
.map(|value| normalize_sha256_hex(value))
|
|
3774
|
+
.transpose()?;
|
|
3775
|
+
|
|
3776
|
+
Ok((resolved_version, archive_url, expected_sha256))
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
fn compute_sha256_hex(path: &Path) -> Result<String> {
|
|
3780
|
+
let mut file =
|
|
3781
|
+
fs::File::open(path).with_context(|| format!("Failed to open {}", path.display()))?;
|
|
3782
|
+
let mut hasher = sha2::Sha256::new();
|
|
3783
|
+
let mut buffer = [0u8; 64 * 1024];
|
|
3784
|
+
loop {
|
|
3785
|
+
let read = file
|
|
3786
|
+
.read(&mut buffer)
|
|
3787
|
+
.with_context(|| format!("Failed reading {}", path.display()))?;
|
|
3788
|
+
if read == 0 {
|
|
3789
|
+
break;
|
|
3790
|
+
}
|
|
3791
|
+
hasher.update(&buffer[..read]);
|
|
3792
|
+
}
|
|
3793
|
+
let digest = hasher.finalize();
|
|
3794
|
+
Ok(format!("{:x}", digest))
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
async fn download_plugin_archive_to_file(
|
|
3798
|
+
client: &reqwest::Client,
|
|
3799
|
+
url: &str,
|
|
3800
|
+
destination: &Path,
|
|
3801
|
+
) -> Result<()> {
|
|
3802
|
+
if let Some(parent) = destination.parent() {
|
|
3803
|
+
tokio_fs::create_dir_all(parent)
|
|
3804
|
+
.await
|
|
3805
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
let response = with_cli_headers(client.get(url.to_string()))
|
|
3809
|
+
.send()
|
|
3810
|
+
.await
|
|
3811
|
+
.with_context(|| format!("Failed to download plugin archive {}", url))?;
|
|
3812
|
+
if !response.status().is_success() {
|
|
3813
|
+
let body = read_error_body(response).await;
|
|
3814
|
+
return Err(anyhow!("Plugin download failed: {}", body));
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
let mut file = tokio_fs::File::create(destination)
|
|
3818
|
+
.await
|
|
3819
|
+
.with_context(|| format!("Failed to create {}", destination.display()))?;
|
|
3820
|
+
let mut stream = response;
|
|
3821
|
+
while let Some(chunk) = stream
|
|
3822
|
+
.chunk()
|
|
3823
|
+
.await
|
|
3824
|
+
.with_context(|| format!("Failed while downloading {}", url))?
|
|
3825
|
+
{
|
|
3826
|
+
file.write_all(&chunk)
|
|
3827
|
+
.await
|
|
3828
|
+
.with_context(|| format!("Failed writing {}", destination.display()))?;
|
|
3829
|
+
}
|
|
3830
|
+
file.flush()
|
|
3831
|
+
.await
|
|
3832
|
+
.with_context(|| format!("Failed to finalize {}", destination.display()))?;
|
|
3833
|
+
Ok(())
|
|
3834
|
+
}
|
|
3835
|
+
|
|
3836
|
+
fn extract_zip_file(zip_path: &Path, destination: &Path) -> Result<()> {
|
|
3837
|
+
let file = fs::File::open(zip_path)
|
|
3838
|
+
.with_context(|| format!("Failed to open {}", zip_path.display()))?;
|
|
3839
|
+
let mut archive = zip::ZipArchive::new(file).with_context(|| "Failed to read zip archive")?;
|
|
3840
|
+
|
|
3841
|
+
for index in 0..archive.len() {
|
|
3842
|
+
let mut file = archive
|
|
3843
|
+
.by_index(index)
|
|
3844
|
+
.with_context(|| format!("Failed to read zip entry {}", index))?;
|
|
3845
|
+
let Some(name) = file.enclosed_name().map(|value| value.to_path_buf()) else {
|
|
3846
|
+
continue;
|
|
3847
|
+
};
|
|
3848
|
+
let output_path = destination.join(name);
|
|
3849
|
+
|
|
3850
|
+
if file.is_dir() {
|
|
3851
|
+
fs::create_dir_all(&output_path)
|
|
3852
|
+
.with_context(|| format!("Failed to create {}", output_path.display()))?;
|
|
3853
|
+
continue;
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
if let Some(parent) = output_path.parent() {
|
|
3857
|
+
fs::create_dir_all(parent)
|
|
3858
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
let mut output_file = fs::File::create(&output_path)
|
|
3862
|
+
.with_context(|| format!("Failed to create {}", output_path.display()))?;
|
|
3863
|
+
io::copy(&mut file, &mut output_file)
|
|
3864
|
+
.with_context(|| format!("Failed to extract {}", output_path.display()))?;
|
|
3865
|
+
}
|
|
3866
|
+
Ok(())
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3869
|
+
fn collect_uplugin_roots(root: &Path) -> Result<Vec<PathBuf>> {
|
|
3870
|
+
let mut stack = vec![root.to_path_buf()];
|
|
3871
|
+
let mut roots: Vec<PathBuf> = Vec::new();
|
|
3872
|
+
|
|
3873
|
+
while let Some(path) = stack.pop() {
|
|
3874
|
+
if !path.is_dir() {
|
|
3875
|
+
continue;
|
|
3876
|
+
}
|
|
3877
|
+
for entry in
|
|
3878
|
+
fs::read_dir(&path).with_context(|| format!("Failed to read {}", path.display()))?
|
|
3879
|
+
{
|
|
3880
|
+
let entry = entry?;
|
|
3881
|
+
let child = entry.path();
|
|
3882
|
+
if child.is_dir() {
|
|
3883
|
+
stack.push(child);
|
|
3884
|
+
continue;
|
|
3885
|
+
}
|
|
3886
|
+
let is_uplugin = child
|
|
3887
|
+
.extension()
|
|
3888
|
+
.and_then(|value| value.to_str())
|
|
3889
|
+
.map(|value| value.eq_ignore_ascii_case("uplugin"))
|
|
3890
|
+
.unwrap_or(false);
|
|
3891
|
+
if is_uplugin {
|
|
3892
|
+
if let Some(parent) = child.parent() {
|
|
3893
|
+
roots.push(parent.to_path_buf());
|
|
3894
|
+
}
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
roots.sort();
|
|
3900
|
+
roots.dedup_by(|left, right| {
|
|
3901
|
+
normalize_path_for_compare(left) == normalize_path_for_compare(right)
|
|
3902
|
+
});
|
|
3903
|
+
Ok(roots)
|
|
3904
|
+
}
|
|
3905
|
+
|
|
3906
|
+
fn find_plugin_root_from_extracted(extracted_root: &Path) -> Result<PathBuf> {
|
|
3907
|
+
let roots = collect_uplugin_roots(extracted_root)?;
|
|
3908
|
+
if roots.is_empty() {
|
|
1332
3909
|
return Err(anyhow!(
|
|
1333
|
-
"
|
|
3910
|
+
"Plugin archive does not contain a .uplugin descriptor"
|
|
1334
3911
|
));
|
|
1335
3912
|
}
|
|
3913
|
+
if roots.len() > 1 {
|
|
3914
|
+
return Err(anyhow!(
|
|
3915
|
+
"Plugin archive contains multiple .uplugin roots; expected one"
|
|
3916
|
+
));
|
|
3917
|
+
}
|
|
3918
|
+
canonicalize_existing_path(&roots[0], "plugin root")
|
|
3919
|
+
}
|
|
1336
3920
|
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
let body = read_error_body(response).await;
|
|
1348
|
-
return Err(anyhow!("project update failed: {}", body));
|
|
3921
|
+
fn remove_existing_path(path: &Path) -> Result<()> {
|
|
3922
|
+
if !path.exists() {
|
|
3923
|
+
return Ok(());
|
|
3924
|
+
}
|
|
3925
|
+
if path.is_dir() {
|
|
3926
|
+
fs::remove_dir_all(path)
|
|
3927
|
+
.with_context(|| format!("Failed to remove directory {}", path.display()))?;
|
|
3928
|
+
} else {
|
|
3929
|
+
fs::remove_file(path)
|
|
3930
|
+
.with_context(|| format!("Failed to remove file {}", path.display()))?;
|
|
1349
3931
|
}
|
|
1350
|
-
let payload: ProjectResponse = response.json().await?;
|
|
1351
|
-
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
1352
|
-
save_session(&session)?;
|
|
1353
3932
|
Ok(())
|
|
1354
3933
|
}
|
|
1355
3934
|
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
3935
|
+
fn copy_dir_recursive(source: &Path, destination: &Path) -> Result<()> {
|
|
3936
|
+
fs::create_dir_all(destination)
|
|
3937
|
+
.with_context(|| format!("Failed to create {}", destination.display()))?;
|
|
3938
|
+
for entry in
|
|
3939
|
+
fs::read_dir(source).with_context(|| format!("Failed to read {}", source.display()))?
|
|
3940
|
+
{
|
|
3941
|
+
let entry = entry?;
|
|
3942
|
+
let child = entry.path();
|
|
3943
|
+
let target = destination.join(entry.file_name());
|
|
3944
|
+
if child.is_dir() {
|
|
3945
|
+
copy_dir_recursive(&child, &target)?;
|
|
3946
|
+
} else {
|
|
3947
|
+
if let Some(parent) = target.parent() {
|
|
3948
|
+
fs::create_dir_all(parent)
|
|
3949
|
+
.with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
3950
|
+
}
|
|
3951
|
+
fs::copy(&child, &target).with_context(|| {
|
|
3952
|
+
format!("Failed to copy {} to {}", child.display(), target.display())
|
|
3953
|
+
})?;
|
|
3954
|
+
}
|
|
3955
|
+
}
|
|
3956
|
+
Ok(())
|
|
3957
|
+
}
|
|
1359
3958
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
3959
|
+
pub(crate) async fn link_plugin_install_command(
|
|
3960
|
+
client: &reqwest::Client,
|
|
3961
|
+
args: LinkPluginInstallArgs,
|
|
3962
|
+
) -> Result<()> {
|
|
3963
|
+
let config = load_unreal_links()?;
|
|
3964
|
+
if config.links.is_empty() {
|
|
3965
|
+
return Err(anyhow!(
|
|
3966
|
+
"No local links configured. Run `reallink link unreal ...` first."
|
|
3967
|
+
));
|
|
1365
3968
|
}
|
|
1366
|
-
let
|
|
1367
|
-
|
|
1368
|
-
|
|
3969
|
+
let target = resolve_link_target(
|
|
3970
|
+
&config,
|
|
3971
|
+
args.project_id.as_deref(),
|
|
3972
|
+
args.uproject.as_deref(),
|
|
3973
|
+
)?;
|
|
3974
|
+
|
|
3975
|
+
let plugin_name = args.name.trim();
|
|
3976
|
+
if plugin_name.is_empty() {
|
|
3977
|
+
return Err(anyhow!("Plugin name is required"));
|
|
3978
|
+
}
|
|
3979
|
+
if plugin_name.contains('/') || plugin_name.contains('\\') {
|
|
3980
|
+
return Err(anyhow!("Plugin name cannot include path separators"));
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
let mut expected_sha256 = args
|
|
3984
|
+
.sha256
|
|
3985
|
+
.as_ref()
|
|
3986
|
+
.map(|value| normalize_sha256_hex(value))
|
|
3987
|
+
.transpose()?;
|
|
3988
|
+
let mut resolved_version = args.version.trim().to_string();
|
|
3989
|
+
if resolved_version.is_empty() {
|
|
3990
|
+
return Err(anyhow!("Plugin version is required"));
|
|
3991
|
+
}
|
|
3992
|
+
|
|
3993
|
+
let archive_url = if let Some(url) = args.url {
|
|
3994
|
+
let trimmed = url.trim();
|
|
3995
|
+
if trimmed.is_empty() {
|
|
3996
|
+
return Err(anyhow!("--url cannot be empty"));
|
|
3997
|
+
}
|
|
3998
|
+
trimmed.to_string()
|
|
3999
|
+
} else if args.use_index {
|
|
4000
|
+
let index = fetch_plugin_index(client, &args.base_url, &args.index_path).await?;
|
|
4001
|
+
let (resolved, url, index_sha256) =
|
|
4002
|
+
resolve_plugin_from_index(&index, plugin_name, &resolved_version, &args.base_url)?;
|
|
4003
|
+
if expected_sha256.is_none() {
|
|
4004
|
+
expected_sha256 = index_sha256;
|
|
4005
|
+
}
|
|
4006
|
+
resolved_version = resolved;
|
|
4007
|
+
url
|
|
4008
|
+
} else {
|
|
4009
|
+
compose_plugin_archive_url(&args.base_url, plugin_name, &resolved_version)?
|
|
4010
|
+
};
|
|
4011
|
+
|
|
4012
|
+
let plugin_root_dir = if args.engine {
|
|
4013
|
+
let engine_root = target.engine_root.as_ref().ok_or_else(|| {
|
|
4014
|
+
anyhow!("Selected link has no engine_root; relink with --engine-root or --editor")
|
|
4015
|
+
})?;
|
|
4016
|
+
let root = PathBuf::from(engine_root);
|
|
4017
|
+
root.join("Engine").join("Plugins").join("Marketplace")
|
|
4018
|
+
} else {
|
|
4019
|
+
PathBuf::from(&target.project_root).join("Plugins")
|
|
4020
|
+
};
|
|
4021
|
+
fs::create_dir_all(&plugin_root_dir)
|
|
4022
|
+
.with_context(|| format!("Failed to create {}", plugin_root_dir.display()))?;
|
|
4023
|
+
let destination_dir = plugin_root_dir.join(plugin_name);
|
|
4024
|
+
|
|
4025
|
+
if destination_dir.exists() && !args.force {
|
|
4026
|
+
return Err(anyhow!(
|
|
4027
|
+
"Plugin destination already exists: {} (use --force to overwrite)",
|
|
4028
|
+
destination_dir.display()
|
|
4029
|
+
));
|
|
4030
|
+
}
|
|
4031
|
+
if destination_dir.exists() && args.force {
|
|
4032
|
+
remove_existing_path(&destination_dir)?;
|
|
4033
|
+
}
|
|
4034
|
+
|
|
4035
|
+
let temp_root = std::env::temp_dir().join(format!(
|
|
4036
|
+
"reallink-plugin-install-{}-{}",
|
|
4037
|
+
plugin_name,
|
|
4038
|
+
now_epoch_ms()
|
|
4039
|
+
));
|
|
4040
|
+
if temp_root.exists() {
|
|
4041
|
+
remove_existing_path(&temp_root)?;
|
|
4042
|
+
}
|
|
4043
|
+
fs::create_dir_all(&temp_root)
|
|
4044
|
+
.with_context(|| format!("Failed to create {}", temp_root.display()))?;
|
|
4045
|
+
let archive_path = temp_root.join("plugin.zip");
|
|
4046
|
+
let temp_extract_root = temp_root.join("extract");
|
|
4047
|
+
|
|
4048
|
+
download_plugin_archive_to_file(client, &archive_url, &archive_path).await?;
|
|
4049
|
+
let archive_sha256 = compute_sha256_hex(&archive_path)?;
|
|
4050
|
+
if let Some(expected) = expected_sha256.as_ref() {
|
|
4051
|
+
if &archive_sha256 != expected {
|
|
4052
|
+
let _ = remove_existing_path(&temp_root);
|
|
4053
|
+
return Err(anyhow!(
|
|
4054
|
+
"Plugin checksum mismatch (expected {}, got {})",
|
|
4055
|
+
expected,
|
|
4056
|
+
archive_sha256
|
|
4057
|
+
));
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
fs::create_dir_all(&temp_extract_root)
|
|
4061
|
+
.with_context(|| format!("Failed to create {}", temp_extract_root.display()))?;
|
|
4062
|
+
extract_zip_file(&archive_path, &temp_extract_root)?;
|
|
4063
|
+
let plugin_source_root = find_plugin_root_from_extracted(&temp_extract_root)?;
|
|
4064
|
+
copy_dir_recursive(&plugin_source_root, &destination_dir)?;
|
|
4065
|
+
let _ = remove_existing_path(&temp_root);
|
|
4066
|
+
|
|
4067
|
+
println!(
|
|
4068
|
+
"{}",
|
|
4069
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
4070
|
+
"ok": true,
|
|
4071
|
+
"projectId": target.project_id,
|
|
4072
|
+
"plugin": plugin_name,
|
|
4073
|
+
"version": resolved_version,
|
|
4074
|
+
"scope": if args.engine { "engine" } else { "project" },
|
|
4075
|
+
"archiveUrl": archive_url,
|
|
4076
|
+
"archiveSha256": archive_sha256,
|
|
4077
|
+
"verifiedSha256": expected_sha256,
|
|
4078
|
+
"installedTo": destination_dir.display().to_string()
|
|
4079
|
+
}))?
|
|
4080
|
+
);
|
|
4081
|
+
Ok(())
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
pub(crate) async fn link_plugin_list_command(
|
|
4085
|
+
client: &reqwest::Client,
|
|
4086
|
+
args: LinkPluginListArgs,
|
|
4087
|
+
) -> Result<()> {
|
|
4088
|
+
let parsed = fetch_plugin_index_value(client, &args.base_url, &args.index_path).await?;
|
|
4089
|
+
println!("{}", serde_json::to_string_pretty(&parsed)?);
|
|
1369
4090
|
Ok(())
|
|
1370
4091
|
}
|
|
1371
4092
|
|
|
@@ -1535,29 +4256,51 @@ async fn file_list_command(client: &reqwest::Client, args: FileListArgs) -> Resu
|
|
|
1535
4256
|
let mut session = load_session()?;
|
|
1536
4257
|
apply_base_url_override(&mut session, args.base_url);
|
|
1537
4258
|
|
|
1538
|
-
let
|
|
1539
|
-
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1540
|
-
if !response.status().is_success() {
|
|
1541
|
-
let body = read_error_body(response).await;
|
|
1542
|
-
return Err(anyhow!("file list failed: {}", body));
|
|
1543
|
-
}
|
|
1544
|
-
let mut payload: ListAssetsResponse = response.json().await?;
|
|
4259
|
+
let mut query_parts = vec![format!("projectId={}", args.project_id)];
|
|
1545
4260
|
if let Some(prefix) = args.path {
|
|
1546
4261
|
let cleaned = clean_virtual_path(&prefix);
|
|
1547
4262
|
if !cleaned.is_empty() {
|
|
1548
|
-
|
|
1549
|
-
payload.assets = payload
|
|
1550
|
-
.assets
|
|
1551
|
-
.into_iter()
|
|
1552
|
-
.filter(|asset| asset.file_name == cleaned || asset.file_name.starts_with(&strict))
|
|
1553
|
-
.collect();
|
|
4263
|
+
query_parts.push(format!("path={}", cleaned));
|
|
1554
4264
|
}
|
|
1555
4265
|
}
|
|
4266
|
+
if let Some(offset) = args.offset {
|
|
4267
|
+
query_parts.push(format!("offset={}", offset));
|
|
4268
|
+
}
|
|
4269
|
+
if let Some(limit) = args.limit {
|
|
4270
|
+
query_parts.push(format!("limit={}", limit));
|
|
4271
|
+
}
|
|
4272
|
+
if let Some(include_folder_markers) = args.include_folder_markers {
|
|
4273
|
+
query_parts.push(format!("includeFolderMarkers={}", include_folder_markers));
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
let path = format!("/assets?{}", query_parts.join("&"));
|
|
4277
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
4278
|
+
if !response.status().is_success() {
|
|
4279
|
+
let body = read_error_body(response).await;
|
|
4280
|
+
return Err(anyhow!("file list failed: {}", body));
|
|
4281
|
+
}
|
|
4282
|
+
let payload: ListAssetsResponse = response.json().await?;
|
|
1556
4283
|
println!("{}", serde_json::to_string_pretty(&payload.assets)?);
|
|
1557
4284
|
save_session(&session)?;
|
|
1558
4285
|
Ok(())
|
|
1559
4286
|
}
|
|
1560
4287
|
|
|
4288
|
+
async fn file_tree_command(client: &reqwest::Client, args: FileTreeArgs) -> Result<()> {
|
|
4289
|
+
let mut session = load_session()?;
|
|
4290
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
4291
|
+
|
|
4292
|
+
let path = format!("/assets/tree?projectId={}", args.project_id);
|
|
4293
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
4294
|
+
if !response.status().is_success() {
|
|
4295
|
+
let body = read_error_body(response).await;
|
|
4296
|
+
return Err(anyhow!("file tree failed: {}", body));
|
|
4297
|
+
}
|
|
4298
|
+
let payload: serde_json::Value = response.json().await?;
|
|
4299
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
4300
|
+
save_session(&session)?;
|
|
4301
|
+
Ok(())
|
|
4302
|
+
}
|
|
4303
|
+
|
|
1561
4304
|
async fn file_get_command(client: &reqwest::Client, args: FileGetArgs) -> Result<()> {
|
|
1562
4305
|
let mut session = load_session()?;
|
|
1563
4306
|
apply_base_url_override(&mut session, args.base_url);
|
|
@@ -1590,6 +4333,77 @@ async fn file_stat_command(client: &reqwest::Client, args: FileStatArgs) -> Resu
|
|
|
1590
4333
|
Ok(())
|
|
1591
4334
|
}
|
|
1592
4335
|
|
|
4336
|
+
fn infer_thumbnail_extension(content_type: Option<&str>) -> &'static str {
|
|
4337
|
+
let normalized = content_type.unwrap_or("").to_ascii_lowercase();
|
|
4338
|
+
if normalized.contains("image/png") {
|
|
4339
|
+
"png"
|
|
4340
|
+
} else if normalized.contains("image/jpeg") || normalized.contains("image/jpg") {
|
|
4341
|
+
"jpg"
|
|
4342
|
+
} else if normalized.contains("image/webp") {
|
|
4343
|
+
"webp"
|
|
4344
|
+
} else if normalized.contains("image/svg+xml") {
|
|
4345
|
+
"svg"
|
|
4346
|
+
} else {
|
|
4347
|
+
"bin"
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4351
|
+
async fn file_thumbnail_command(client: &reqwest::Client, args: FileThumbnailArgs) -> Result<()> {
|
|
4352
|
+
let mut session = load_session()?;
|
|
4353
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
4354
|
+
|
|
4355
|
+
let size = args.size.as_str();
|
|
4356
|
+
let path = format!("/assets/{}/thumbnail/{}", args.asset_id, size);
|
|
4357
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
4358
|
+
if !response.status().is_success() {
|
|
4359
|
+
let body = read_error_body(response).await;
|
|
4360
|
+
return Err(anyhow!("file thumbnail failed: {}", body));
|
|
4361
|
+
}
|
|
4362
|
+
|
|
4363
|
+
let content_type = response
|
|
4364
|
+
.headers()
|
|
4365
|
+
.get("content-type")
|
|
4366
|
+
.and_then(|value| value.to_str().ok())
|
|
4367
|
+
.map(|value| value.to_string());
|
|
4368
|
+
let bytes = response.bytes().await?;
|
|
4369
|
+
let output_path = args.output_path.unwrap_or_else(|| {
|
|
4370
|
+
PathBuf::from(format!(
|
|
4371
|
+
"{}-{}.{}",
|
|
4372
|
+
args.asset_id,
|
|
4373
|
+
size,
|
|
4374
|
+
infer_thumbnail_extension(content_type.as_deref())
|
|
4375
|
+
))
|
|
4376
|
+
});
|
|
4377
|
+
if let Some(parent) = output_path.parent() {
|
|
4378
|
+
if !parent.as_os_str().is_empty() {
|
|
4379
|
+
tokio_fs::create_dir_all(parent).await.with_context(|| {
|
|
4380
|
+
format!(
|
|
4381
|
+
"Failed to create output directory {}",
|
|
4382
|
+
parent.display()
|
|
4383
|
+
)
|
|
4384
|
+
})?;
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
tokio_fs::write(&output_path, &bytes)
|
|
4388
|
+
.await
|
|
4389
|
+
.with_context(|| format!("Failed to write thumbnail {}", output_path.display()))?;
|
|
4390
|
+
|
|
4391
|
+
println!(
|
|
4392
|
+
"{}",
|
|
4393
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
4394
|
+
"ok": true,
|
|
4395
|
+
"assetId": args.asset_id,
|
|
4396
|
+
"size": size,
|
|
4397
|
+
"output": output_path.display().to_string(),
|
|
4398
|
+
"bytesWritten": bytes.len(),
|
|
4399
|
+
"contentType": content_type
|
|
4400
|
+
}))?
|
|
4401
|
+
);
|
|
4402
|
+
|
|
4403
|
+
save_session(&session)?;
|
|
4404
|
+
Ok(())
|
|
4405
|
+
}
|
|
4406
|
+
|
|
1593
4407
|
async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs) -> Result<()> {
|
|
1594
4408
|
let mut session = load_session()?;
|
|
1595
4409
|
apply_base_url_override(&mut session, args.base_url);
|
|
@@ -1605,7 +4419,7 @@ async fn file_download_command(client: &reqwest::Client, args: FileDownloadArgs)
|
|
|
1605
4419
|
let fallback_name = base_name_from_virtual_path(&metadata_payload.asset.file_name);
|
|
1606
4420
|
|
|
1607
4421
|
let mut output_path = args
|
|
1608
|
-
.
|
|
4422
|
+
.output_path
|
|
1609
4423
|
.unwrap_or_else(|| PathBuf::from(fallback_name.as_str()));
|
|
1610
4424
|
if output_path.exists() && output_path.is_dir() {
|
|
1611
4425
|
output_path = output_path.join(fallback_name.as_str());
|
|
@@ -1728,73 +4542,189 @@ async fn file_upload_command(client: &reqwest::Client, args: FileUploadArgs) ->
|
|
|
1728
4542
|
return Err(anyhow!("resolved remote file name is empty"));
|
|
1729
4543
|
}
|
|
1730
4544
|
|
|
1731
|
-
let asset = upload_asset_via_intent(
|
|
4545
|
+
let asset = upload_asset_via_intent(
|
|
4546
|
+
client,
|
|
4547
|
+
&mut session,
|
|
4548
|
+
&args.project_id,
|
|
4549
|
+
&remote_name,
|
|
4550
|
+
bytes,
|
|
4551
|
+
"application/octet-stream",
|
|
4552
|
+
&args.asset_type,
|
|
4553
|
+
&args.visibility,
|
|
4554
|
+
)
|
|
4555
|
+
.await?;
|
|
4556
|
+
println!("{}", serde_json::to_string_pretty(&asset)?);
|
|
4557
|
+
save_session(&session)?;
|
|
4558
|
+
Ok(())
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
async fn file_mkdir_command(client: &reqwest::Client, args: FileMkdirArgs) -> Result<()> {
|
|
4562
|
+
let mut session = load_session()?;
|
|
4563
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
4564
|
+
|
|
4565
|
+
let folder = clean_virtual_path(&args.path);
|
|
4566
|
+
if folder.is_empty() {
|
|
4567
|
+
return Err(anyhow!("folder path is empty"));
|
|
4568
|
+
}
|
|
4569
|
+
let response = authed_request(
|
|
4570
|
+
client,
|
|
4571
|
+
&mut session,
|
|
4572
|
+
Method::POST,
|
|
4573
|
+
"/assets/folder",
|
|
4574
|
+
Some(serde_json::json!({
|
|
4575
|
+
"projectId": args.project_id,
|
|
4576
|
+
"path": folder,
|
|
4577
|
+
"visibility": args.visibility
|
|
4578
|
+
})),
|
|
4579
|
+
)
|
|
4580
|
+
.await?;
|
|
4581
|
+
if !response.status().is_success() {
|
|
4582
|
+
let body = read_error_body(response).await;
|
|
4583
|
+
return Err(anyhow!("file mkdir failed: {}", body));
|
|
4584
|
+
}
|
|
4585
|
+
let payload: serde_json::Value = response.json().await?;
|
|
4586
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
4587
|
+
save_session(&session)?;
|
|
4588
|
+
Ok(())
|
|
4589
|
+
}
|
|
4590
|
+
|
|
4591
|
+
async fn file_move_command(client: &reqwest::Client, args: FileMoveArgs) -> Result<()> {
|
|
4592
|
+
let mut session = load_session()?;
|
|
4593
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
4594
|
+
|
|
4595
|
+
let file_name = clean_virtual_path(&args.file_name);
|
|
4596
|
+
if file_name.is_empty() {
|
|
4597
|
+
return Err(anyhow!("file_name is empty"));
|
|
4598
|
+
}
|
|
4599
|
+
let path = format!("/assets/{}", args.asset_id);
|
|
4600
|
+
let response = authed_request(
|
|
4601
|
+
client,
|
|
4602
|
+
&mut session,
|
|
4603
|
+
Method::PATCH,
|
|
4604
|
+
&path,
|
|
4605
|
+
Some(serde_json::json!({
|
|
4606
|
+
"fileName": file_name
|
|
4607
|
+
})),
|
|
4608
|
+
)
|
|
4609
|
+
.await?;
|
|
4610
|
+
if !response.status().is_success() {
|
|
4611
|
+
let body = read_error_body(response).await;
|
|
4612
|
+
return Err(anyhow!("file move failed: {}", body));
|
|
4613
|
+
}
|
|
4614
|
+
let payload: AssetResponse = response.json().await?;
|
|
4615
|
+
println!("{}", serde_json::to_string_pretty(&payload.asset)?);
|
|
4616
|
+
save_session(&session)?;
|
|
4617
|
+
Ok(())
|
|
4618
|
+
}
|
|
4619
|
+
|
|
4620
|
+
async fn file_set_command(client: &reqwest::Client, args: FileSetArgs) -> Result<()> {
|
|
4621
|
+
let mut session = load_session()?;
|
|
4622
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
4623
|
+
|
|
4624
|
+
if args.file_name.is_none() && args.asset_type.is_none() && args.visibility.is_none() {
|
|
4625
|
+
return Err(anyhow!(
|
|
4626
|
+
"At least one of --file-name, --asset-type, or --visibility is required"
|
|
4627
|
+
));
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4630
|
+
let mut body = serde_json::Map::new();
|
|
4631
|
+
if let Some(file_name) = args.file_name {
|
|
4632
|
+
let normalized = clean_virtual_path(&file_name);
|
|
4633
|
+
if normalized.is_empty() {
|
|
4634
|
+
return Err(anyhow!("--file-name resolved to an empty path"));
|
|
4635
|
+
}
|
|
4636
|
+
body.insert(
|
|
4637
|
+
"fileName".to_string(),
|
|
4638
|
+
serde_json::Value::String(normalized),
|
|
4639
|
+
);
|
|
4640
|
+
}
|
|
4641
|
+
if let Some(asset_type) = args.asset_type {
|
|
4642
|
+
body.insert("assetType".to_string(), serde_json::Value::String(asset_type));
|
|
4643
|
+
}
|
|
4644
|
+
if let Some(visibility) = args.visibility {
|
|
4645
|
+
body.insert("visibility".to_string(), serde_json::Value::String(visibility));
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
let path = format!("/assets/{}", args.asset_id);
|
|
4649
|
+
let response = authed_request(
|
|
1732
4650
|
client,
|
|
1733
4651
|
&mut session,
|
|
1734
|
-
|
|
1735
|
-
&
|
|
1736
|
-
|
|
1737
|
-
"application/octet-stream",
|
|
1738
|
-
&args.asset_type,
|
|
1739
|
-
&args.visibility,
|
|
4652
|
+
Method::PATCH,
|
|
4653
|
+
&path,
|
|
4654
|
+
Some(serde_json::Value::Object(body)),
|
|
1740
4655
|
)
|
|
1741
4656
|
.await?;
|
|
1742
|
-
|
|
4657
|
+
if !response.status().is_success() {
|
|
4658
|
+
let body = read_error_body(response).await;
|
|
4659
|
+
return Err(anyhow!("file set failed: {}", body));
|
|
4660
|
+
}
|
|
4661
|
+
let payload: AssetResponse = response.json().await?;
|
|
4662
|
+
println!("{}", serde_json::to_string_pretty(&payload.asset)?);
|
|
1743
4663
|
save_session(&session)?;
|
|
1744
4664
|
Ok(())
|
|
1745
4665
|
}
|
|
1746
4666
|
|
|
1747
|
-
async fn
|
|
4667
|
+
async fn file_move_folder_command(client: &reqwest::Client, args: FileMoveFolderArgs) -> Result<()> {
|
|
1748
4668
|
let mut session = load_session()?;
|
|
1749
4669
|
apply_base_url_override(&mut session, args.base_url);
|
|
1750
4670
|
|
|
1751
|
-
let
|
|
1752
|
-
if
|
|
1753
|
-
return Err(anyhow!("
|
|
4671
|
+
let source_path = clean_virtual_path(&args.source_path);
|
|
4672
|
+
if source_path.is_empty() {
|
|
4673
|
+
return Err(anyhow!("source_path is empty"));
|
|
1754
4674
|
}
|
|
1755
|
-
let
|
|
1756
|
-
|
|
1757
|
-
let
|
|
4675
|
+
let target_path = clean_virtual_path(&args.target_path);
|
|
4676
|
+
|
|
4677
|
+
let response = authed_request(
|
|
1758
4678
|
client,
|
|
1759
4679
|
&mut session,
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
4680
|
+
Method::POST,
|
|
4681
|
+
"/assets/folder/move",
|
|
4682
|
+
Some(serde_json::json!({
|
|
4683
|
+
"projectId": args.project_id,
|
|
4684
|
+
"sourcePath": source_path,
|
|
4685
|
+
"targetPath": target_path
|
|
4686
|
+
})),
|
|
1766
4687
|
)
|
|
1767
4688
|
.await?;
|
|
1768
|
-
|
|
4689
|
+
if !response.status().is_success() {
|
|
4690
|
+
let body = read_error_body(response).await;
|
|
4691
|
+
return Err(anyhow!("file move-folder failed: {}", body));
|
|
4692
|
+
}
|
|
4693
|
+
let payload: serde_json::Value = response.json().await?;
|
|
4694
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1769
4695
|
save_session(&session)?;
|
|
1770
4696
|
Ok(())
|
|
1771
4697
|
}
|
|
1772
4698
|
|
|
1773
|
-
async fn
|
|
4699
|
+
async fn file_rmdir_command(client: &reqwest::Client, args: FileRemoveFolderArgs) -> Result<()> {
|
|
1774
4700
|
let mut session = load_session()?;
|
|
1775
4701
|
apply_base_url_override(&mut session, args.base_url);
|
|
1776
4702
|
|
|
1777
|
-
let
|
|
1778
|
-
if
|
|
1779
|
-
return Err(anyhow!("
|
|
4703
|
+
let folder_path = clean_virtual_path(&args.path);
|
|
4704
|
+
if folder_path.is_empty() {
|
|
4705
|
+
return Err(anyhow!("path is empty"));
|
|
1780
4706
|
}
|
|
1781
|
-
|
|
4707
|
+
|
|
1782
4708
|
let response = authed_request(
|
|
1783
4709
|
client,
|
|
1784
4710
|
&mut session,
|
|
1785
|
-
Method::
|
|
1786
|
-
|
|
4711
|
+
Method::POST,
|
|
4712
|
+
"/assets/folder/delete",
|
|
1787
4713
|
Some(serde_json::json!({
|
|
1788
|
-
"
|
|
4714
|
+
"projectId": args.project_id,
|
|
4715
|
+
"path": folder_path,
|
|
4716
|
+
"recursive": args.recursive,
|
|
4717
|
+
"dryRun": args.dry_run,
|
|
4718
|
+
"includeFolderMarkers": args.include_folder_markers
|
|
1789
4719
|
})),
|
|
1790
4720
|
)
|
|
1791
4721
|
.await?;
|
|
1792
4722
|
if !response.status().is_success() {
|
|
1793
4723
|
let body = read_error_body(response).await;
|
|
1794
|
-
return Err(anyhow!("file
|
|
4724
|
+
return Err(anyhow!("file rmdir failed: {}", body));
|
|
1795
4725
|
}
|
|
1796
|
-
let payload:
|
|
1797
|
-
println!("{}", serde_json::to_string_pretty(&payload
|
|
4726
|
+
let payload: serde_json::Value = response.json().await?;
|
|
4727
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1798
4728
|
save_session(&session)?;
|
|
1799
4729
|
Ok(())
|
|
1800
4730
|
}
|
|
@@ -1865,6 +4795,43 @@ async fn tool_register_command(client: &reqwest::Client, args: ToolRegisterArgs)
|
|
|
1865
4795
|
Ok(())
|
|
1866
4796
|
}
|
|
1867
4797
|
|
|
4798
|
+
async fn tool_publish_command(client: &reqwest::Client, args: ToolPublishArgs) -> Result<()> {
|
|
4799
|
+
let mut session = load_session()?;
|
|
4800
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
4801
|
+
|
|
4802
|
+
let mut body = serde_json::Map::new();
|
|
4803
|
+
if let Some(channel) = args.channel {
|
|
4804
|
+
body.insert("channel".to_string(), serde_json::Value::String(channel));
|
|
4805
|
+
}
|
|
4806
|
+
if let Some(visibility) = args.visibility {
|
|
4807
|
+
body.insert("visibility".to_string(), serde_json::Value::String(visibility));
|
|
4808
|
+
}
|
|
4809
|
+
if let Some(notes) = args.notes {
|
|
4810
|
+
body.insert("notes".to_string(), serde_json::Value::String(notes));
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
let path = format!("/tools/definitions/{}/publish", args.tool_id);
|
|
4814
|
+
let response = authed_request(
|
|
4815
|
+
client,
|
|
4816
|
+
&mut session,
|
|
4817
|
+
Method::POST,
|
|
4818
|
+
&path,
|
|
4819
|
+
Some(serde_json::Value::Object(body)),
|
|
4820
|
+
)
|
|
4821
|
+
.await?;
|
|
4822
|
+
if !response.status().is_success() {
|
|
4823
|
+
let body = read_error_body(response).await;
|
|
4824
|
+
return Err(anyhow!("tool publish failed: {}", body));
|
|
4825
|
+
}
|
|
4826
|
+
let payload: serde_json::Value = response.json().await?;
|
|
4827
|
+
println!(
|
|
4828
|
+
"{}",
|
|
4829
|
+
serde_json::to_string_pretty(payload.get("definition").unwrap_or(&payload))?
|
|
4830
|
+
);
|
|
4831
|
+
save_session(&session)?;
|
|
4832
|
+
Ok(())
|
|
4833
|
+
}
|
|
4834
|
+
|
|
1868
4835
|
async fn tool_set_entitlement_command(
|
|
1869
4836
|
client: &reqwest::Client,
|
|
1870
4837
|
mut session: SessionConfig,
|
|
@@ -1986,23 +4953,222 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
|
|
|
1986
4953
|
));
|
|
1987
4954
|
}
|
|
1988
4955
|
|
|
1989
|
-
let input_value = if let Some(path) = args.input_file {
|
|
1990
|
-
load_jsonc_file(&path, "tool run input")?
|
|
1991
|
-
} else if let Some(input_json) = args.input_json {
|
|
1992
|
-
parse_jsonc_str(&input_json, "tool run input")?
|
|
1993
|
-
} else {
|
|
1994
|
-
serde_json::Value::Object(serde_json::Map::new())
|
|
1995
|
-
};
|
|
4956
|
+
let input_value = if let Some(path) = args.input_file {
|
|
4957
|
+
load_jsonc_file(&path, "tool run input")?
|
|
4958
|
+
} else if let Some(input_json) = args.input_json {
|
|
4959
|
+
parse_jsonc_str(&input_json, "tool run input")?
|
|
4960
|
+
} else {
|
|
4961
|
+
serde_json::Value::Object(serde_json::Map::new())
|
|
4962
|
+
};
|
|
4963
|
+
|
|
4964
|
+
let input_object =
|
|
4965
|
+
serde_json::Value::Object(parse_object_from_value(input_value, "tool run input")?);
|
|
4966
|
+
|
|
4967
|
+
let mut body = serde_json::Map::new();
|
|
4968
|
+
body.insert(
|
|
4969
|
+
"toolId".to_string(),
|
|
4970
|
+
serde_json::Value::String(args.tool_id),
|
|
4971
|
+
);
|
|
4972
|
+
body.insert("input".to_string(), input_object);
|
|
4973
|
+
if let Some(org_id) = args.org_id {
|
|
4974
|
+
body.insert("orgId".to_string(), serde_json::Value::String(org_id));
|
|
4975
|
+
}
|
|
4976
|
+
if let Some(project_id) = args.project_id {
|
|
4977
|
+
body.insert(
|
|
4978
|
+
"projectId".to_string(),
|
|
4979
|
+
serde_json::Value::String(project_id),
|
|
4980
|
+
);
|
|
4981
|
+
}
|
|
4982
|
+
let mut metadata_map = if let Some(path) = args.metadata_file {
|
|
4983
|
+
let metadata = load_jsonc_file(&path, "tool run metadata")?;
|
|
4984
|
+
parse_object_from_value(metadata, "tool run metadata")?
|
|
4985
|
+
} else {
|
|
4986
|
+
serde_json::Map::new()
|
|
4987
|
+
};
|
|
4988
|
+
if let Some(idempotency_key) = args.idempotency_key {
|
|
4989
|
+
let normalized = idempotency_key.trim();
|
|
4990
|
+
if !normalized.is_empty() {
|
|
4991
|
+
metadata_map.insert(
|
|
4992
|
+
"idempotencyKey".to_string(),
|
|
4993
|
+
serde_json::Value::String(normalized.to_string()),
|
|
4994
|
+
);
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4997
|
+
if !metadata_map.is_empty() {
|
|
4998
|
+
body.insert(
|
|
4999
|
+
"metadata".to_string(),
|
|
5000
|
+
serde_json::Value::Object(metadata_map),
|
|
5001
|
+
);
|
|
5002
|
+
}
|
|
5003
|
+
|
|
5004
|
+
let response = authed_request(
|
|
5005
|
+
client,
|
|
5006
|
+
&mut session,
|
|
5007
|
+
Method::POST,
|
|
5008
|
+
"/tools/runs",
|
|
5009
|
+
Some(serde_json::Value::Object(body)),
|
|
5010
|
+
)
|
|
5011
|
+
.await?;
|
|
5012
|
+
if !response.status().is_success() {
|
|
5013
|
+
let body = read_error_body(response).await;
|
|
5014
|
+
return Err(anyhow!("tool run failed: {}", body));
|
|
5015
|
+
}
|
|
5016
|
+
let payload: serde_json::Value = response.json().await?;
|
|
5017
|
+
println!(
|
|
5018
|
+
"{}",
|
|
5019
|
+
serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
|
|
5020
|
+
);
|
|
5021
|
+
save_session(&session)?;
|
|
5022
|
+
Ok(())
|
|
5023
|
+
}
|
|
5024
|
+
|
|
5025
|
+
async fn tool_runs_command(client: &reqwest::Client, args: ToolRunsArgs) -> Result<()> {
|
|
5026
|
+
let mut session = load_session()?;
|
|
5027
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
5028
|
+
|
|
5029
|
+
let mut query_parts: Vec<String> = Vec::new();
|
|
5030
|
+
if let Some(tool_id) = args.tool_id {
|
|
5031
|
+
query_parts.push(format!("toolId={}", tool_id));
|
|
5032
|
+
}
|
|
5033
|
+
if let Some(project_id) = args.project_id {
|
|
5034
|
+
query_parts.push(format!("projectId={}", project_id));
|
|
5035
|
+
}
|
|
5036
|
+
if let Some(requested_by_user_id) = args.requested_by_user_id {
|
|
5037
|
+
query_parts.push(format!("requestedByUserId={}", requested_by_user_id));
|
|
5038
|
+
}
|
|
5039
|
+
if let Some(status) = args.status {
|
|
5040
|
+
query_parts.push(format!("status={}", status));
|
|
5041
|
+
}
|
|
5042
|
+
|
|
5043
|
+
let path = if query_parts.is_empty() {
|
|
5044
|
+
"/tools/runs".to_string()
|
|
5045
|
+
} else {
|
|
5046
|
+
format!("/tools/runs?{}", query_parts.join("&"))
|
|
5047
|
+
};
|
|
5048
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
5049
|
+
if !response.status().is_success() {
|
|
5050
|
+
let body = read_error_body(response).await;
|
|
5051
|
+
return Err(anyhow!("tool runs failed: {}", body));
|
|
5052
|
+
}
|
|
5053
|
+
let payload: serde_json::Value = response.json().await?;
|
|
5054
|
+
println!(
|
|
5055
|
+
"{}",
|
|
5056
|
+
serde_json::to_string_pretty(payload.get("runs").unwrap_or(&payload))?
|
|
5057
|
+
);
|
|
5058
|
+
save_session(&session)?;
|
|
5059
|
+
Ok(())
|
|
5060
|
+
}
|
|
5061
|
+
|
|
5062
|
+
async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) -> Result<()> {
|
|
5063
|
+
let mut session = load_session()?;
|
|
5064
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
5065
|
+
|
|
5066
|
+
let path = format!("/tools/runs/{}", args.run_id);
|
|
5067
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
5068
|
+
if !response.status().is_success() {
|
|
5069
|
+
let body = read_error_body(response).await;
|
|
5070
|
+
return Err(anyhow!("tool get-run failed: {}", body));
|
|
5071
|
+
}
|
|
5072
|
+
let payload: serde_json::Value = response.json().await?;
|
|
5073
|
+
println!(
|
|
5074
|
+
"{}",
|
|
5075
|
+
serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
|
|
5076
|
+
);
|
|
5077
|
+
save_session(&session)?;
|
|
5078
|
+
Ok(())
|
|
5079
|
+
}
|
|
5080
|
+
|
|
5081
|
+
async fn tool_run_events_command(client: &reqwest::Client, args: ToolRunEventsArgs) -> Result<()> {
|
|
5082
|
+
let mut session = load_session()?;
|
|
5083
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
5084
|
+
|
|
5085
|
+
let mut path = format!("/tools/runs/{}/events", args.run_id);
|
|
5086
|
+
let mut query_parts: Vec<String> = Vec::new();
|
|
5087
|
+
if let Some(limit) = args.limit {
|
|
5088
|
+
query_parts.push(format!("limit={}", limit));
|
|
5089
|
+
}
|
|
5090
|
+
if let Some(status) = args.status {
|
|
5091
|
+
query_parts.push(format!("status={}", status));
|
|
5092
|
+
}
|
|
5093
|
+
if let Some(stage_prefix) = args.stage_prefix {
|
|
5094
|
+
query_parts.push(format!("stagePrefix={}", stage_prefix));
|
|
5095
|
+
}
|
|
5096
|
+
if let Some(since) = args.since {
|
|
5097
|
+
query_parts.push(format!("since={}", since));
|
|
5098
|
+
}
|
|
5099
|
+
if let Some(until) = args.until {
|
|
5100
|
+
query_parts.push(format!("until={}", until));
|
|
5101
|
+
}
|
|
5102
|
+
if !query_parts.is_empty() {
|
|
5103
|
+
path.push_str(&format!("?{}", query_parts.join("&")));
|
|
5104
|
+
}
|
|
5105
|
+
|
|
5106
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
5107
|
+
if !response.status().is_success() {
|
|
5108
|
+
let body = read_error_body(response).await;
|
|
5109
|
+
return Err(anyhow!("tool run-events failed: {}", body));
|
|
5110
|
+
}
|
|
5111
|
+
let payload: serde_json::Value = response.json().await?;
|
|
5112
|
+
println!(
|
|
5113
|
+
"{}",
|
|
5114
|
+
serde_json::to_string_pretty(payload.get("events").unwrap_or(&payload))?
|
|
5115
|
+
);
|
|
5116
|
+
save_session(&session)?;
|
|
5117
|
+
Ok(())
|
|
5118
|
+
}
|
|
5119
|
+
|
|
5120
|
+
async fn tool_local_catalog_command(client: &reqwest::Client, args: ToolLocalCatalogArgs) -> Result<()> {
|
|
5121
|
+
let mut session = load_session()?;
|
|
5122
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
5123
|
+
|
|
5124
|
+
let (default_platform, default_arch) = detect_local_runtime_platform_arch();
|
|
5125
|
+
let platform = args.platform.unwrap_or(default_platform);
|
|
5126
|
+
let arch = args.arch.unwrap_or(default_arch);
|
|
5127
|
+
|
|
5128
|
+
let mut query_parts = vec![
|
|
5129
|
+
format!("platform={}", platform),
|
|
5130
|
+
format!("arch={}", arch),
|
|
5131
|
+
];
|
|
5132
|
+
if let Some(org_id) = args.org_id {
|
|
5133
|
+
query_parts.push(format!("orgId={}", org_id));
|
|
5134
|
+
}
|
|
5135
|
+
if let Some(project_id) = args.project_id {
|
|
5136
|
+
query_parts.push(format!("projectId={}", project_id));
|
|
5137
|
+
}
|
|
5138
|
+
let path = format!("/tools/local/catalog?{}", query_parts.join("&"));
|
|
5139
|
+
|
|
5140
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
5141
|
+
if !response.status().is_success() {
|
|
5142
|
+
let body = read_error_body(response).await;
|
|
5143
|
+
return Err(anyhow!("tool local catalog failed: {}", body));
|
|
5144
|
+
}
|
|
5145
|
+
let payload: serde_json::Value = response.json().await?;
|
|
5146
|
+
println!(
|
|
5147
|
+
"{}",
|
|
5148
|
+
serde_json::to_string_pretty(payload.get("tools").unwrap_or(&payload))?
|
|
5149
|
+
);
|
|
5150
|
+
save_session(&session)?;
|
|
5151
|
+
Ok(())
|
|
5152
|
+
}
|
|
5153
|
+
|
|
5154
|
+
async fn tool_local_install_command(client: &reqwest::Client, args: ToolLocalInstallArgs) -> Result<()> {
|
|
5155
|
+
let mut session = load_session()?;
|
|
5156
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1996
5157
|
|
|
1997
|
-
let
|
|
1998
|
-
|
|
5158
|
+
let (default_platform, default_arch) = detect_local_runtime_platform_arch();
|
|
5159
|
+
let platform = args.platform.unwrap_or(default_platform);
|
|
5160
|
+
let arch = args.arch.unwrap_or(default_arch);
|
|
1999
5161
|
|
|
2000
5162
|
let mut body = serde_json::Map::new();
|
|
2001
5163
|
body.insert(
|
|
2002
5164
|
"toolId".to_string(),
|
|
2003
|
-
serde_json::Value::String(args.tool_id),
|
|
5165
|
+
serde_json::Value::String(args.tool_id.clone()),
|
|
2004
5166
|
);
|
|
2005
|
-
body.insert(
|
|
5167
|
+
body.insert(
|
|
5168
|
+
"platform".to_string(),
|
|
5169
|
+
serde_json::Value::String(platform.clone()),
|
|
5170
|
+
);
|
|
5171
|
+
body.insert("arch".to_string(), serde_json::Value::String(arch.clone()));
|
|
2006
5172
|
if let Some(org_id) = args.org_id {
|
|
2007
5173
|
body.insert("orgId".to_string(), serde_json::Value::String(org_id));
|
|
2008
5174
|
}
|
|
@@ -2012,95 +5178,231 @@ async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result
|
|
|
2012
5178
|
serde_json::Value::String(project_id),
|
|
2013
5179
|
);
|
|
2014
5180
|
}
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
parse_object_from_value(metadata, "tool run metadata")?
|
|
2018
|
-
} else {
|
|
2019
|
-
serde_json::Map::new()
|
|
2020
|
-
};
|
|
2021
|
-
if let Some(idempotency_key) = args.idempotency_key {
|
|
2022
|
-
let normalized = idempotency_key.trim();
|
|
2023
|
-
if !normalized.is_empty() {
|
|
2024
|
-
metadata_map.insert(
|
|
2025
|
-
"idempotencyKey".to_string(),
|
|
2026
|
-
serde_json::Value::String(normalized.to_string()),
|
|
2027
|
-
);
|
|
2028
|
-
}
|
|
2029
|
-
}
|
|
2030
|
-
if !metadata_map.is_empty() {
|
|
2031
|
-
body.insert(
|
|
2032
|
-
"metadata".to_string(),
|
|
2033
|
-
serde_json::Value::Object(metadata_map),
|
|
2034
|
-
);
|
|
5181
|
+
if let Some(version) = args.version {
|
|
5182
|
+
body.insert("version".to_string(), serde_json::Value::String(version));
|
|
2035
5183
|
}
|
|
2036
5184
|
|
|
2037
5185
|
let response = authed_request(
|
|
2038
5186
|
client,
|
|
2039
5187
|
&mut session,
|
|
2040
5188
|
Method::POST,
|
|
2041
|
-
"/tools/
|
|
5189
|
+
"/tools/local/install-intent",
|
|
2042
5190
|
Some(serde_json::Value::Object(body)),
|
|
2043
5191
|
)
|
|
2044
5192
|
.await?;
|
|
2045
5193
|
if !response.status().is_success() {
|
|
2046
5194
|
let body = read_error_body(response).await;
|
|
2047
|
-
return Err(anyhow!("tool
|
|
5195
|
+
return Err(anyhow!("tool local install-intent failed: {}", body));
|
|
2048
5196
|
}
|
|
5197
|
+
|
|
2049
5198
|
let payload: serde_json::Value = response.json().await?;
|
|
2050
|
-
|
|
2051
|
-
"{}",
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
Ok(())
|
|
2056
|
-
}
|
|
5199
|
+
if args.no_download {
|
|
5200
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
5201
|
+
save_session(&session)?;
|
|
5202
|
+
return Ok(());
|
|
5203
|
+
}
|
|
2057
5204
|
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
5205
|
+
let download_path = payload
|
|
5206
|
+
.pointer("/install/downloadPath")
|
|
5207
|
+
.and_then(|value| value.as_str())
|
|
5208
|
+
.ok_or_else(|| anyhow!("install intent is missing install.downloadPath"))?;
|
|
5209
|
+
let asset_id = payload
|
|
5210
|
+
.pointer("/asset/assetId")
|
|
5211
|
+
.and_then(|value| value.as_str())
|
|
5212
|
+
.unwrap_or("unknown");
|
|
5213
|
+
let fallback_file_name = payload
|
|
5214
|
+
.pointer("/asset/fileName")
|
|
5215
|
+
.and_then(|value| value.as_str())
|
|
5216
|
+
.unwrap_or("tool-bundle.bin");
|
|
2061
5217
|
|
|
2062
|
-
let mut
|
|
2063
|
-
|
|
2064
|
-
|
|
5218
|
+
let mut output_path = args
|
|
5219
|
+
.output_path
|
|
5220
|
+
.unwrap_or_else(|| PathBuf::from(base_name_from_virtual_path(fallback_file_name)));
|
|
5221
|
+
if output_path.exists() && output_path.is_dir() {
|
|
5222
|
+
output_path = output_path.join(base_name_from_virtual_path(fallback_file_name));
|
|
2065
5223
|
}
|
|
2066
|
-
if let Some(
|
|
2067
|
-
|
|
5224
|
+
if let Some(parent) = output_path.parent() {
|
|
5225
|
+
if !parent.as_os_str().is_empty() {
|
|
5226
|
+
tokio_fs::create_dir_all(parent)
|
|
5227
|
+
.await
|
|
5228
|
+
.with_context(|| format!("Failed to create output directory {}", parent.display()))?;
|
|
5229
|
+
}
|
|
2068
5230
|
}
|
|
2069
|
-
|
|
2070
|
-
|
|
5231
|
+
|
|
5232
|
+
let mut resume_from: Option<u64> = None;
|
|
5233
|
+
if args.resume && output_path.exists() && output_path.is_file() {
|
|
5234
|
+
let existing_size = tokio_fs::metadata(&output_path)
|
|
5235
|
+
.await
|
|
5236
|
+
.with_context(|| {
|
|
5237
|
+
format!(
|
|
5238
|
+
"Failed to read output file metadata {}",
|
|
5239
|
+
output_path.display()
|
|
5240
|
+
)
|
|
5241
|
+
})?
|
|
5242
|
+
.len();
|
|
5243
|
+
if existing_size > 0 {
|
|
5244
|
+
let remote_size = payload
|
|
5245
|
+
.pointer("/asset/sizeBytes")
|
|
5246
|
+
.and_then(|value| value.as_u64())
|
|
5247
|
+
.unwrap_or(0);
|
|
5248
|
+
if remote_size > 0 && existing_size >= remote_size {
|
|
5249
|
+
println!(
|
|
5250
|
+
"{}",
|
|
5251
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
5252
|
+
"toolId": args.tool_id,
|
|
5253
|
+
"assetId": asset_id,
|
|
5254
|
+
"output": output_path.display().to_string(),
|
|
5255
|
+
"bytesWritten": 0,
|
|
5256
|
+
"resumedFrom": existing_size,
|
|
5257
|
+
"alreadyComplete": true
|
|
5258
|
+
}))?
|
|
5259
|
+
);
|
|
5260
|
+
save_session(&session)?;
|
|
5261
|
+
return Ok(());
|
|
5262
|
+
}
|
|
5263
|
+
resume_from = Some(existing_size);
|
|
5264
|
+
}
|
|
2071
5265
|
}
|
|
2072
|
-
|
|
2073
|
-
|
|
5266
|
+
|
|
5267
|
+
let mut headers = Vec::new();
|
|
5268
|
+
if let Some(offset) = resume_from {
|
|
5269
|
+
headers.push(("range".to_string(), format!("bytes={}-", offset)));
|
|
5270
|
+
}
|
|
5271
|
+
let mut download_response = authed_request_with_headers(
|
|
5272
|
+
client,
|
|
5273
|
+
&mut session,
|
|
5274
|
+
Method::GET,
|
|
5275
|
+
download_path,
|
|
5276
|
+
None,
|
|
5277
|
+
&headers,
|
|
5278
|
+
)
|
|
5279
|
+
.await?;
|
|
5280
|
+
if !(download_response.status().is_success()
|
|
5281
|
+
|| download_response.status() == StatusCode::PARTIAL_CONTENT)
|
|
5282
|
+
{
|
|
5283
|
+
let body = read_error_body(download_response).await;
|
|
5284
|
+
return Err(anyhow!("tool local install download failed: {}", body));
|
|
2074
5285
|
}
|
|
2075
5286
|
|
|
2076
|
-
let
|
|
2077
|
-
|
|
5287
|
+
let append_mode = resume_from.is_some() && download_response.status() == StatusCode::PARTIAL_CONTENT;
|
|
5288
|
+
let mut output_file = if append_mode {
|
|
5289
|
+
tokio_fs::OpenOptions::new()
|
|
5290
|
+
.append(true)
|
|
5291
|
+
.open(&output_path)
|
|
5292
|
+
.await
|
|
5293
|
+
.with_context(|| {
|
|
5294
|
+
format!(
|
|
5295
|
+
"Failed to open output file for append {}",
|
|
5296
|
+
output_path.display()
|
|
5297
|
+
)
|
|
5298
|
+
})?
|
|
2078
5299
|
} else {
|
|
2079
|
-
|
|
5300
|
+
tokio_fs::File::create(&output_path)
|
|
5301
|
+
.await
|
|
5302
|
+
.with_context(|| format!("Failed to create output file {}", output_path.display()))?
|
|
2080
5303
|
};
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
5304
|
+
|
|
5305
|
+
let mut bytes_written: u64 = 0;
|
|
5306
|
+
while let Some(chunk) = download_response.chunk().await? {
|
|
5307
|
+
output_file
|
|
5308
|
+
.write_all(&chunk)
|
|
5309
|
+
.await
|
|
5310
|
+
.with_context(|| format!("Failed to write to {}", output_path.display()))?;
|
|
5311
|
+
bytes_written = bytes_written.saturating_add(chunk.len() as u64);
|
|
2085
5312
|
}
|
|
2086
|
-
|
|
5313
|
+
output_file.flush().await?;
|
|
5314
|
+
|
|
2087
5315
|
println!(
|
|
2088
5316
|
"{}",
|
|
2089
|
-
serde_json::to_string_pretty(
|
|
5317
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
5318
|
+
"toolId": args.tool_id,
|
|
5319
|
+
"platform": platform,
|
|
5320
|
+
"arch": arch,
|
|
5321
|
+
"assetId": asset_id,
|
|
5322
|
+
"output": output_path.display().to_string(),
|
|
5323
|
+
"bytesWritten": bytes_written,
|
|
5324
|
+
"resumedFrom": resume_from,
|
|
5325
|
+
"partialContent": download_response.status() == StatusCode::PARTIAL_CONTENT
|
|
5326
|
+
}))?
|
|
2090
5327
|
);
|
|
2091
5328
|
save_session(&session)?;
|
|
2092
5329
|
Ok(())
|
|
2093
5330
|
}
|
|
2094
5331
|
|
|
2095
|
-
async fn
|
|
5332
|
+
async fn tool_local_complete_run_command(
|
|
5333
|
+
client: &reqwest::Client,
|
|
5334
|
+
args: ToolLocalCompleteRunArgs,
|
|
5335
|
+
) -> Result<()> {
|
|
2096
5336
|
let mut session = load_session()?;
|
|
2097
5337
|
apply_base_url_override(&mut session, args.base_url);
|
|
2098
5338
|
|
|
2099
|
-
let
|
|
2100
|
-
|
|
5339
|
+
let normalized_status = args.status.trim().to_lowercase();
|
|
5340
|
+
if normalized_status != "succeeded"
|
|
5341
|
+
&& normalized_status != "failed"
|
|
5342
|
+
&& normalized_status != "cancelled"
|
|
5343
|
+
{
|
|
5344
|
+
return Err(anyhow!(
|
|
5345
|
+
"status must be one of: succeeded, failed, cancelled"
|
|
5346
|
+
));
|
|
5347
|
+
}
|
|
5348
|
+
|
|
5349
|
+
if args.output_file.is_some() && args.output_json.is_some() {
|
|
5350
|
+
return Err(anyhow!(
|
|
5351
|
+
"Provide either --output-file or --output-json, not both"
|
|
5352
|
+
));
|
|
5353
|
+
}
|
|
5354
|
+
|
|
5355
|
+
let output_value = if let Some(path) = args.output_file {
|
|
5356
|
+
Some(load_jsonc_file(&path, "tool local complete output")?)
|
|
5357
|
+
} else if let Some(raw) = args.output_json {
|
|
5358
|
+
Some(parse_jsonc_str(&raw, "tool local complete output")?)
|
|
5359
|
+
} else {
|
|
5360
|
+
None
|
|
5361
|
+
};
|
|
5362
|
+
|
|
5363
|
+
let mut body = serde_json::Map::new();
|
|
5364
|
+
body.insert(
|
|
5365
|
+
"status".to_string(),
|
|
5366
|
+
serde_json::Value::String(normalized_status),
|
|
5367
|
+
);
|
|
5368
|
+
if let Some(output) = output_value {
|
|
5369
|
+
body.insert(
|
|
5370
|
+
"output".to_string(),
|
|
5371
|
+
serde_json::Value::Object(parse_object_from_value(
|
|
5372
|
+
output,
|
|
5373
|
+
"tool local complete output",
|
|
5374
|
+
)?),
|
|
5375
|
+
);
|
|
5376
|
+
}
|
|
5377
|
+
if let Some(error_message) = args.error_message {
|
|
5378
|
+
body.insert(
|
|
5379
|
+
"errorMessage".to_string(),
|
|
5380
|
+
serde_json::Value::String(error_message),
|
|
5381
|
+
);
|
|
5382
|
+
}
|
|
5383
|
+
if let Some(path) = args.metadata_file {
|
|
5384
|
+
let metadata = load_jsonc_file(&path, "tool local complete metadata")?;
|
|
5385
|
+
body.insert(
|
|
5386
|
+
"metadata".to_string(),
|
|
5387
|
+
serde_json::Value::Object(parse_object_from_value(
|
|
5388
|
+
metadata,
|
|
5389
|
+
"tool local complete metadata",
|
|
5390
|
+
)?),
|
|
5391
|
+
);
|
|
5392
|
+
}
|
|
5393
|
+
|
|
5394
|
+
let path = format!("/tools/runs/{}/complete", args.run_id);
|
|
5395
|
+
let response = authed_request(
|
|
5396
|
+
client,
|
|
5397
|
+
&mut session,
|
|
5398
|
+
Method::POST,
|
|
5399
|
+
&path,
|
|
5400
|
+
Some(serde_json::Value::Object(body)),
|
|
5401
|
+
)
|
|
5402
|
+
.await?;
|
|
2101
5403
|
if !response.status().is_success() {
|
|
2102
5404
|
let body = read_error_body(response).await;
|
|
2103
|
-
return Err(anyhow!("tool
|
|
5405
|
+
return Err(anyhow!("tool local complete-run failed: {}", body));
|
|
2104
5406
|
}
|
|
2105
5407
|
let payload: serde_json::Value = response.json().await?;
|
|
2106
5408
|
println!(
|
|
@@ -2121,23 +5423,58 @@ fn run_and_check_status(mut command: Command, context: &str) -> Result<()> {
|
|
|
2121
5423
|
Ok(())
|
|
2122
5424
|
}
|
|
2123
5425
|
|
|
2124
|
-
async fn self_update_command(
|
|
5426
|
+
async fn self_update_command(
|
|
5427
|
+
client: &reqwest::Client,
|
|
5428
|
+
args: SelfUpdateArgs,
|
|
5429
|
+
output: OutputFormat,
|
|
5430
|
+
) -> Result<()> {
|
|
2125
5431
|
let current = env!("CARGO_PKG_VERSION");
|
|
2126
5432
|
let latest = fetch_latest_cli_version(client).await;
|
|
2127
5433
|
|
|
2128
5434
|
let Some(latest_version) = latest else {
|
|
2129
|
-
|
|
5435
|
+
let payload = serde_json::json!({
|
|
5436
|
+
"ok": false,
|
|
5437
|
+
"checked": false,
|
|
5438
|
+
"currentVersion": current,
|
|
5439
|
+
"message": "Could not check latest version right now."
|
|
5440
|
+
});
|
|
5441
|
+
emit_text_or_json(output, "Could not check latest version right now.", payload)?;
|
|
2130
5442
|
return Ok(());
|
|
2131
5443
|
};
|
|
2132
5444
|
|
|
2133
5445
|
if !is_newer_version(current, &latest_version) {
|
|
2134
|
-
|
|
5446
|
+
let payload = serde_json::json!({
|
|
5447
|
+
"ok": true,
|
|
5448
|
+
"checked": true,
|
|
5449
|
+
"upToDate": true,
|
|
5450
|
+
"currentVersion": current,
|
|
5451
|
+
"latestVersion": latest_version
|
|
5452
|
+
});
|
|
5453
|
+
emit_text_or_json(
|
|
5454
|
+
output,
|
|
5455
|
+
&format!("reallink is up to date ({})", current),
|
|
5456
|
+
payload,
|
|
5457
|
+
)?;
|
|
2135
5458
|
return Ok(());
|
|
2136
5459
|
}
|
|
2137
5460
|
|
|
2138
|
-
println!("Update available: {} -> {}", current, latest_version);
|
|
2139
5461
|
if args.check {
|
|
2140
|
-
|
|
5462
|
+
let payload = serde_json::json!({
|
|
5463
|
+
"ok": true,
|
|
5464
|
+
"checked": true,
|
|
5465
|
+
"upToDate": false,
|
|
5466
|
+
"currentVersion": current,
|
|
5467
|
+
"latestVersion": latest_version,
|
|
5468
|
+
"action": "run `reallink self-update` to install"
|
|
5469
|
+
});
|
|
5470
|
+
emit_text_or_json(
|
|
5471
|
+
output,
|
|
5472
|
+
&format!(
|
|
5473
|
+
"Update available: {} -> {}. Run `reallink self-update`.",
|
|
5474
|
+
current, latest_version
|
|
5475
|
+
),
|
|
5476
|
+
payload,
|
|
5477
|
+
)?;
|
|
2141
5478
|
return Ok(());
|
|
2142
5479
|
}
|
|
2143
5480
|
|
|
@@ -2156,9 +5493,19 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
|
|
|
2156
5493
|
},
|
|
2157
5494
|
"npm self-update",
|
|
2158
5495
|
)?;
|
|
2159
|
-
|
|
2160
|
-
"
|
|
2161
|
-
|
|
5496
|
+
let payload = serde_json::json!({
|
|
5497
|
+
"ok": true,
|
|
5498
|
+
"checked": true,
|
|
5499
|
+
"updated": true,
|
|
5500
|
+
"method": "npm",
|
|
5501
|
+
"currentVersion": current,
|
|
5502
|
+
"latestVersion": latest_version
|
|
5503
|
+
});
|
|
5504
|
+
emit_text_or_json(
|
|
5505
|
+
output,
|
|
5506
|
+
"Updated via npm. Restart your shell if `reallink --version` still shows old version.",
|
|
5507
|
+
payload,
|
|
5508
|
+
)?;
|
|
2162
5509
|
return Ok(());
|
|
2163
5510
|
}
|
|
2164
5511
|
|
|
@@ -2188,33 +5535,191 @@ async fn self_update_command(client: &reqwest::Client, args: SelfUpdateArgs) ->
|
|
|
2188
5535
|
)?;
|
|
2189
5536
|
}
|
|
2190
5537
|
|
|
2191
|
-
|
|
5538
|
+
let payload = serde_json::json!({
|
|
5539
|
+
"ok": true,
|
|
5540
|
+
"checked": true,
|
|
5541
|
+
"updated": true,
|
|
5542
|
+
"method": if cfg!(windows) { "powershell-installer" } else { "shell-installer" },
|
|
5543
|
+
"currentVersion": current,
|
|
5544
|
+
"latestVersion": latest_version
|
|
5545
|
+
});
|
|
5546
|
+
emit_text_or_json(
|
|
5547
|
+
output,
|
|
5548
|
+
"Update installed. Verify with `reallink --version`.",
|
|
5549
|
+
payload,
|
|
5550
|
+
)?;
|
|
2192
5551
|
Ok(())
|
|
2193
5552
|
}
|
|
2194
5553
|
|
|
2195
|
-
#[
|
|
2196
|
-
|
|
2197
|
-
|
|
5554
|
+
#[cfg(test)]
|
|
5555
|
+
mod tests {
|
|
5556
|
+
use super::{
|
|
5557
|
+
compose_plugin_archive_url, default_login_scopes, default_login_scopes_with_tools,
|
|
5558
|
+
file_name_component, normalize_public_bucket_base, normalize_sha256_hex, parse_api_error,
|
|
5559
|
+
resolve_plugin_from_index,
|
|
5560
|
+
};
|
|
5561
|
+
use super::unreal::{PluginIndexFile, PluginIndexPlugin, PluginIndexVersion};
|
|
5562
|
+
|
|
5563
|
+
#[test]
|
|
5564
|
+
fn normalizes_public_bucket_base() {
|
|
5565
|
+
let normalized = normalize_public_bucket_base("https://real-agent.link/plugins/unreal/")
|
|
5566
|
+
.expect("base URL should normalize");
|
|
5567
|
+
assert_eq!(normalized, "https://real-agent.link/plugins/unreal");
|
|
5568
|
+
}
|
|
5569
|
+
|
|
5570
|
+
#[test]
|
|
5571
|
+
fn composes_archive_url() {
|
|
5572
|
+
let url = compose_plugin_archive_url(
|
|
5573
|
+
"https://real-agent.link/plugins/unreal/",
|
|
5574
|
+
"RealLinkUnreal",
|
|
5575
|
+
"latest",
|
|
5576
|
+
)
|
|
5577
|
+
.expect("archive URL should compose");
|
|
5578
|
+
assert_eq!(
|
|
5579
|
+
url,
|
|
5580
|
+
"https://real-agent.link/plugins/unreal/RealLinkUnreal/latest/RealLinkUnreal.zip"
|
|
5581
|
+
);
|
|
5582
|
+
}
|
|
5583
|
+
|
|
5584
|
+
#[test]
|
|
5585
|
+
fn rejects_plugin_name_with_path_separator() {
|
|
5586
|
+
let result = compose_plugin_archive_url(
|
|
5587
|
+
"https://real-agent.link/plugins/unreal",
|
|
5588
|
+
"RealLink/Unreal",
|
|
5589
|
+
"latest",
|
|
5590
|
+
);
|
|
5591
|
+
assert!(result.is_err());
|
|
5592
|
+
}
|
|
5593
|
+
|
|
5594
|
+
#[test]
|
|
5595
|
+
fn extracts_file_name_component() {
|
|
5596
|
+
let output = file_name_component("D:/Games/MyGame/MyGame.uproject");
|
|
5597
|
+
assert_eq!(output, "MyGame.uproject");
|
|
5598
|
+
}
|
|
5599
|
+
|
|
5600
|
+
#[test]
|
|
5601
|
+
fn normalizes_sha256_hex() {
|
|
5602
|
+
let value = normalize_sha256_hex(
|
|
5603
|
+
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF",
|
|
5604
|
+
)
|
|
5605
|
+
.expect("sha256 should normalize");
|
|
5606
|
+
assert_eq!(
|
|
5607
|
+
value,
|
|
5608
|
+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
5609
|
+
);
|
|
5610
|
+
}
|
|
5611
|
+
|
|
5612
|
+
#[test]
|
|
5613
|
+
fn resolves_plugin_from_index_with_latest() {
|
|
5614
|
+
let index = PluginIndexFile {
|
|
5615
|
+
schema_version: Some(1),
|
|
5616
|
+
plugins: vec![PluginIndexPlugin {
|
|
5617
|
+
name: "RealLinkUnreal".to_string(),
|
|
5618
|
+
latest: Some("0.1.2".to_string()),
|
|
5619
|
+
versions: vec![PluginIndexVersion {
|
|
5620
|
+
version: "0.1.2".to_string(),
|
|
5621
|
+
archive_url: Some(
|
|
5622
|
+
"https://cdn.example.com/RealLinkUnreal-0.1.2.zip".to_string(),
|
|
5623
|
+
),
|
|
5624
|
+
sha256: Some(
|
|
5625
|
+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
|
5626
|
+
.to_string(),
|
|
5627
|
+
),
|
|
5628
|
+
}],
|
|
5629
|
+
}],
|
|
5630
|
+
};
|
|
5631
|
+
|
|
5632
|
+
let (version, url, sha) = resolve_plugin_from_index(
|
|
5633
|
+
&index,
|
|
5634
|
+
"RealLinkUnreal",
|
|
5635
|
+
"latest",
|
|
5636
|
+
"https://real-agent.link/plugins/unreal",
|
|
5637
|
+
)
|
|
5638
|
+
.expect("index resolve should succeed");
|
|
5639
|
+
assert_eq!(version, "0.1.2");
|
|
5640
|
+
assert_eq!(url, "https://cdn.example.com/RealLinkUnreal-0.1.2.zip");
|
|
5641
|
+
assert_eq!(
|
|
5642
|
+
sha,
|
|
5643
|
+
Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string())
|
|
5644
|
+
);
|
|
5645
|
+
}
|
|
5646
|
+
|
|
5647
|
+
#[test]
|
|
5648
|
+
fn default_login_scopes_exclude_tool_scopes() {
|
|
5649
|
+
let scopes = default_login_scopes();
|
|
5650
|
+
assert!(scopes.contains(&"core:read".to_string()));
|
|
5651
|
+
assert!(scopes.contains(&"core:write".to_string()));
|
|
5652
|
+
assert!(scopes.contains(&"scheduler:read".to_string()));
|
|
5653
|
+
assert!(scopes.contains(&"scheduler:write".to_string()));
|
|
5654
|
+
assert!(!scopes.iter().any(|value| value.starts_with("tools:")));
|
|
5655
|
+
}
|
|
5656
|
+
|
|
5657
|
+
#[test]
|
|
5658
|
+
fn default_login_scopes_with_tools_include_tool_scopes() {
|
|
5659
|
+
let scopes = default_login_scopes_with_tools();
|
|
5660
|
+
assert!(scopes.contains(&"tools:read".to_string()));
|
|
5661
|
+
assert!(scopes.contains(&"tools:write".to_string()));
|
|
5662
|
+
assert!(scopes.contains(&"tools:run".to_string()));
|
|
5663
|
+
}
|
|
5664
|
+
|
|
5665
|
+
#[test]
|
|
5666
|
+
fn parses_api_error_envelope() {
|
|
5667
|
+
let parsed = parse_api_error(r#"{"code":"CLERK_ORG_NOT_LINKED","message":"Not linked"}"#)
|
|
5668
|
+
.expect("api error should parse");
|
|
5669
|
+
assert_eq!(parsed.code.as_deref(), Some("CLERK_ORG_NOT_LINKED"));
|
|
5670
|
+
assert_eq!(parsed.message.as_deref(), Some("Not linked"));
|
|
5671
|
+
}
|
|
5672
|
+
|
|
5673
|
+
#[test]
|
|
5674
|
+
fn ignores_non_json_api_error_body() {
|
|
5675
|
+
let parsed = parse_api_error("plain text error");
|
|
5676
|
+
assert!(parsed.is_none());
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5679
|
+
|
|
5680
|
+
async fn run_cli(cli: Cli) -> Result<()> {
|
|
2198
5681
|
let client = reqwest::Client::builder()
|
|
2199
5682
|
.user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
|
|
2200
5683
|
.build()?;
|
|
2201
5684
|
|
|
2202
5685
|
if !matches!(&cli.command, Commands::SelfUpdate(_)) {
|
|
2203
5686
|
let allow_update_fetch = matches!(&cli.command, Commands::Login(_));
|
|
2204
|
-
maybe_notify_update(&client, false, allow_update_fetch).await;
|
|
5687
|
+
maybe_notify_update(&client, false, allow_update_fetch, cli.output).await;
|
|
2205
5688
|
}
|
|
2206
5689
|
|
|
2207
5690
|
match cli.command {
|
|
2208
|
-
Commands::Login(args) => login_command(&client, args).await?,
|
|
5691
|
+
Commands::Login(args) => login_command(&client, args, cli.output).await?,
|
|
2209
5692
|
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?,
|
|
5693
|
+
Commands::Logout => logout_command(&client, cli.output).await?,
|
|
5694
|
+
Commands::SelfUpdate(args) => self_update_command(&client, args, cli.output).await?,
|
|
5695
|
+
Commands::Org { command } => match command {
|
|
5696
|
+
OrgCommands::List(args) => org_list_command(&client, args).await?,
|
|
5697
|
+
OrgCommands::Create(args) => org_create_command(&client, args).await?,
|
|
5698
|
+
OrgCommands::Get(args) => org_get_command(&client, args).await?,
|
|
5699
|
+
OrgCommands::Update(args) => org_update_command(&client, args).await?,
|
|
5700
|
+
OrgCommands::Delete(args) => org_delete_command(&client, args).await?,
|
|
5701
|
+
OrgCommands::Invites(args) => org_invites_command(&client, args).await?,
|
|
5702
|
+
OrgCommands::Invite(args) => org_invite_command(&client, args).await?,
|
|
5703
|
+
OrgCommands::Members(args) => org_members_command(&client, args).await?,
|
|
5704
|
+
OrgCommands::AddMember(args) => org_add_member_command(&client, args).await?,
|
|
5705
|
+
OrgCommands::UpdateMember(args) => org_update_member_command(&client, args).await?,
|
|
5706
|
+
OrgCommands::RemoveMember(args) => org_remove_member_command(&client, args).await?,
|
|
5707
|
+
},
|
|
5708
|
+
Commands::Link { command } => unreal::dispatch(&client, command).await?,
|
|
2212
5709
|
Commands::Project { command } => match command {
|
|
2213
5710
|
ProjectCommands::List(args) => project_list_command(&client, args).await?,
|
|
2214
5711
|
ProjectCommands::Create(args) => project_create_command(&client, args).await?,
|
|
2215
5712
|
ProjectCommands::Get(args) => project_get_command(&client, args).await?,
|
|
2216
5713
|
ProjectCommands::Update(args) => project_update_command(&client, args).await?,
|
|
2217
5714
|
ProjectCommands::Delete(args) => project_delete_command(&client, args).await?,
|
|
5715
|
+
ProjectCommands::Members(args) => project_members_command(&client, args).await?,
|
|
5716
|
+
ProjectCommands::AddMember(args) => project_add_member_command(&client, args).await?,
|
|
5717
|
+
ProjectCommands::UpdateMember(args) => {
|
|
5718
|
+
project_update_member_command(&client, args).await?
|
|
5719
|
+
}
|
|
5720
|
+
ProjectCommands::RemoveMember(args) => {
|
|
5721
|
+
project_remove_member_command(&client, args).await?
|
|
5722
|
+
}
|
|
2218
5723
|
},
|
|
2219
5724
|
Commands::Token { command } => match command {
|
|
2220
5725
|
TokenCommands::List(args) => token_list_command(&client, args).await?,
|
|
@@ -2223,24 +5728,89 @@ async fn main() -> Result<()> {
|
|
|
2223
5728
|
},
|
|
2224
5729
|
Commands::File { command } => match command {
|
|
2225
5730
|
FileCommands::List(args) => file_list_command(&client, args).await?,
|
|
5731
|
+
FileCommands::Tree(args) => file_tree_command(&client, args).await?,
|
|
2226
5732
|
FileCommands::Get(args) => file_get_command(&client, args).await?,
|
|
2227
5733
|
FileCommands::Stat(args) => file_stat_command(&client, args).await?,
|
|
5734
|
+
FileCommands::Thumbnail(args) => file_thumbnail_command(&client, args).await?,
|
|
2228
5735
|
FileCommands::Download(args) => file_download_command(&client, args).await?,
|
|
2229
5736
|
FileCommands::Upload(args) => file_upload_command(&client, args).await?,
|
|
2230
5737
|
FileCommands::Mkdir(args) => file_mkdir_command(&client, args).await?,
|
|
2231
5738
|
FileCommands::Move(args) => file_move_command(&client, args).await?,
|
|
5739
|
+
FileCommands::Set(args) => file_set_command(&client, args).await?,
|
|
5740
|
+
FileCommands::MoveFolder(args) => file_move_folder_command(&client, args).await?,
|
|
5741
|
+
FileCommands::Rmdir(args) => file_rmdir_command(&client, args).await?,
|
|
2232
5742
|
FileCommands::Remove(args) => file_remove_command(&client, args).await?,
|
|
2233
5743
|
},
|
|
2234
5744
|
Commands::Tool { command } => match command {
|
|
2235
5745
|
ToolCommands::List(args) => tool_list_command(&client, args).await?,
|
|
2236
5746
|
ToolCommands::Register(args) => tool_register_command(&client, args).await?,
|
|
5747
|
+
ToolCommands::Publish(args) => tool_publish_command(&client, args).await?,
|
|
2237
5748
|
ToolCommands::Enable(args) => tool_enable_command(&client, args).await?,
|
|
2238
5749
|
ToolCommands::Disable(args) => tool_disable_command(&client, args).await?,
|
|
2239
5750
|
ToolCommands::Run(args) => tool_run_command(&client, args).await?,
|
|
2240
5751
|
ToolCommands::Runs(args) => tool_runs_command(&client, args).await?,
|
|
2241
5752
|
ToolCommands::GetRun(args) => tool_get_run_command(&client, args).await?,
|
|
5753
|
+
ToolCommands::RunEvents(args) => tool_run_events_command(&client, args).await?,
|
|
5754
|
+
ToolCommands::Local { command } => match command {
|
|
5755
|
+
ToolLocalCommands::Catalog(args) => tool_local_catalog_command(&client, args).await?,
|
|
5756
|
+
ToolLocalCommands::Install(args) => tool_local_install_command(&client, args).await?,
|
|
5757
|
+
ToolLocalCommands::CompleteRun(args) => {
|
|
5758
|
+
tool_local_complete_run_command(&client, args).await?
|
|
5759
|
+
}
|
|
5760
|
+
},
|
|
5761
|
+
},
|
|
5762
|
+
Commands::Logs { command } => match command {
|
|
5763
|
+
LogsCommands::Status => logs_status_command(cli.output).await?,
|
|
5764
|
+
LogsCommands::Consent(args) => logs_consent_command(args, cli.output).await?,
|
|
5765
|
+
LogsCommands::Tail(args) => logs_tail_command(args, cli.output).await?,
|
|
5766
|
+
LogsCommands::Upload(args) => logs_upload_command(&client, args, cli.output).await?,
|
|
2242
5767
|
},
|
|
2243
5768
|
}
|
|
2244
5769
|
|
|
2245
5770
|
Ok(())
|
|
2246
5771
|
}
|
|
5772
|
+
|
|
5773
|
+
#[tokio::main]
|
|
5774
|
+
async fn main() {
|
|
5775
|
+
let cli = Cli::parse();
|
|
5776
|
+
let output = cli.output;
|
|
5777
|
+
let command_summary = command_summary_for_logs();
|
|
5778
|
+
let started_at = now_epoch_ms();
|
|
5779
|
+
|
|
5780
|
+
if let Err(error) = run_cli(cli).await {
|
|
5781
|
+
let duration_ms = now_epoch_ms().saturating_sub(started_at);
|
|
5782
|
+
append_runtime_log_event(
|
|
5783
|
+
&command_summary,
|
|
5784
|
+
&format!("command failed: {}", error),
|
|
5785
|
+
"error",
|
|
5786
|
+
Some(duration_ms),
|
|
5787
|
+
Some(1),
|
|
5788
|
+
);
|
|
5789
|
+
record_cli_crash_report(&command_summary, &error);
|
|
5790
|
+
match output {
|
|
5791
|
+
OutputFormat::Json => {
|
|
5792
|
+
let payload = serde_json::json!({
|
|
5793
|
+
"ok": false,
|
|
5794
|
+
"error": format!("{:#}", error)
|
|
5795
|
+
});
|
|
5796
|
+
if let Err(emit_error) = print_json(&payload) {
|
|
5797
|
+
eprintln!("Error: {}", error);
|
|
5798
|
+
eprintln!("Failed to emit JSON error payload: {}", emit_error);
|
|
5799
|
+
}
|
|
5800
|
+
}
|
|
5801
|
+
OutputFormat::Text => {
|
|
5802
|
+
eprintln!("Error: {:#}", error);
|
|
5803
|
+
}
|
|
5804
|
+
}
|
|
5805
|
+
std::process::exit(1);
|
|
5806
|
+
}
|
|
5807
|
+
|
|
5808
|
+
let duration_ms = now_epoch_ms().saturating_sub(started_at);
|
|
5809
|
+
append_runtime_log_event(
|
|
5810
|
+
&command_summary,
|
|
5811
|
+
"command completed",
|
|
5812
|
+
"info",
|
|
5813
|
+
Some(duration_ms),
|
|
5814
|
+
Some(0),
|
|
5815
|
+
);
|
|
5816
|
+
}
|