reallink-cli 0.1.0 → 0.1.5
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 +34 -1
- package/package.json +1 -1
- package/rust/Cargo.lock +329 -1
- package/rust/Cargo.toml +3 -1
- package/rust/src/main.rs +1298 -25
package/rust/src/main.rs
CHANGED
|
@@ -3,11 +3,15 @@ use clap::{ArgAction, Args, Parser, Subcommand};
|
|
|
3
3
|
use reqwest::{Method, StatusCode};
|
|
4
4
|
use serde::{Deserialize, Serialize};
|
|
5
5
|
use std::fs;
|
|
6
|
-
use std::
|
|
6
|
+
use std::io::{self, Write};
|
|
7
|
+
use std::path::{Path, PathBuf};
|
|
7
8
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
8
9
|
use tokio::time::sleep;
|
|
9
10
|
|
|
10
11
|
const DEFAULT_BASE_URL: &str = "https://api.real-agent.link";
|
|
12
|
+
const CONFIG_DIR_ENV: &str = "REALLINK_CONFIG_DIR";
|
|
13
|
+
const SESSION_DIR_NAME: &str = "reallink";
|
|
14
|
+
const SESSION_FILE_NAME: &str = "session.json";
|
|
11
15
|
|
|
12
16
|
#[derive(Parser)]
|
|
13
17
|
#[command(name = "reallink", version, about = "Reallink CLI")]
|
|
@@ -21,10 +25,22 @@ enum Commands {
|
|
|
21
25
|
Login(LoginArgs),
|
|
22
26
|
Whoami(BaseArgs),
|
|
23
27
|
Logout,
|
|
28
|
+
Project {
|
|
29
|
+
#[command(subcommand)]
|
|
30
|
+
command: ProjectCommands,
|
|
31
|
+
},
|
|
24
32
|
Token {
|
|
25
33
|
#[command(subcommand)]
|
|
26
34
|
command: TokenCommands,
|
|
27
35
|
},
|
|
36
|
+
File {
|
|
37
|
+
#[command(subcommand)]
|
|
38
|
+
command: FileCommands,
|
|
39
|
+
},
|
|
40
|
+
Tool {
|
|
41
|
+
#[command(subcommand)]
|
|
42
|
+
command: ToolCommands,
|
|
43
|
+
},
|
|
28
44
|
}
|
|
29
45
|
|
|
30
46
|
#[derive(Args)]
|
|
@@ -41,6 +57,8 @@ struct LoginArgs {
|
|
|
41
57
|
client_id: String,
|
|
42
58
|
#[arg(long = "scope", action = ArgAction::Append)]
|
|
43
59
|
scope: Vec<String>,
|
|
60
|
+
#[arg(long)]
|
|
61
|
+
force: bool,
|
|
44
62
|
}
|
|
45
63
|
|
|
46
64
|
#[derive(Subcommand)]
|
|
@@ -50,6 +68,36 @@ enum TokenCommands {
|
|
|
50
68
|
Revoke(TokenRevokeArgs),
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
#[derive(Subcommand)]
|
|
72
|
+
enum ProjectCommands {
|
|
73
|
+
List(ProjectListArgs),
|
|
74
|
+
Create(ProjectCreateArgs),
|
|
75
|
+
Get(ProjectGetArgs),
|
|
76
|
+
Update(ProjectUpdateArgs),
|
|
77
|
+
Delete(ProjectDeleteArgs),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[derive(Subcommand)]
|
|
81
|
+
enum FileCommands {
|
|
82
|
+
List(FileListArgs),
|
|
83
|
+
Get(FileGetArgs),
|
|
84
|
+
Upload(FileUploadArgs),
|
|
85
|
+
Mkdir(FileMkdirArgs),
|
|
86
|
+
Move(FileMoveArgs),
|
|
87
|
+
Remove(FileRemoveArgs),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Subcommand)]
|
|
91
|
+
enum ToolCommands {
|
|
92
|
+
List(ToolListArgs),
|
|
93
|
+
Register(ToolRegisterArgs),
|
|
94
|
+
Enable(ToolEnableArgs),
|
|
95
|
+
Disable(ToolDisableArgs),
|
|
96
|
+
Run(ToolRunArgs),
|
|
97
|
+
Runs(ToolRunsArgs),
|
|
98
|
+
GetRun(ToolGetRunArgs),
|
|
99
|
+
}
|
|
100
|
+
|
|
53
101
|
#[derive(Args)]
|
|
54
102
|
struct TokenCreateArgs {
|
|
55
103
|
#[arg(long)]
|
|
@@ -74,6 +122,212 @@ struct TokenRevokeArgs {
|
|
|
74
122
|
base_url: Option<String>,
|
|
75
123
|
}
|
|
76
124
|
|
|
125
|
+
#[derive(Args)]
|
|
126
|
+
struct FileListArgs {
|
|
127
|
+
#[arg(long)]
|
|
128
|
+
project_id: String,
|
|
129
|
+
#[arg(long)]
|
|
130
|
+
path: Option<String>,
|
|
131
|
+
#[arg(long)]
|
|
132
|
+
base_url: Option<String>,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#[derive(Args)]
|
|
136
|
+
struct FileGetArgs {
|
|
137
|
+
#[arg(long)]
|
|
138
|
+
asset_id: String,
|
|
139
|
+
#[arg(long)]
|
|
140
|
+
base_url: Option<String>,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[derive(Args)]
|
|
144
|
+
struct FileUploadArgs {
|
|
145
|
+
#[arg(long)]
|
|
146
|
+
project_id: String,
|
|
147
|
+
#[arg(long)]
|
|
148
|
+
source: PathBuf,
|
|
149
|
+
#[arg(long)]
|
|
150
|
+
path: Option<String>,
|
|
151
|
+
#[arg(long, default_value = "other")]
|
|
152
|
+
asset_type: String,
|
|
153
|
+
#[arg(long, default_value = "private")]
|
|
154
|
+
visibility: String,
|
|
155
|
+
#[arg(long)]
|
|
156
|
+
base_url: Option<String>,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#[derive(Args)]
|
|
160
|
+
struct FileMkdirArgs {
|
|
161
|
+
#[arg(long)]
|
|
162
|
+
project_id: String,
|
|
163
|
+
#[arg(long)]
|
|
164
|
+
path: String,
|
|
165
|
+
#[arg(long)]
|
|
166
|
+
base_url: Option<String>,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#[derive(Args)]
|
|
170
|
+
struct FileMoveArgs {
|
|
171
|
+
#[arg(long)]
|
|
172
|
+
asset_id: String,
|
|
173
|
+
#[arg(long)]
|
|
174
|
+
file_name: String,
|
|
175
|
+
#[arg(long)]
|
|
176
|
+
base_url: Option<String>,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
#[derive(Args)]
|
|
180
|
+
struct FileRemoveArgs {
|
|
181
|
+
#[arg(long)]
|
|
182
|
+
asset_id: String,
|
|
183
|
+
#[arg(long)]
|
|
184
|
+
base_url: Option<String>,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[derive(Args)]
|
|
188
|
+
struct ToolListArgs {
|
|
189
|
+
#[arg(long)]
|
|
190
|
+
include_inactive: bool,
|
|
191
|
+
#[arg(long)]
|
|
192
|
+
include_disabled_channel: bool,
|
|
193
|
+
#[arg(long)]
|
|
194
|
+
base_url: Option<String>,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[derive(Args)]
|
|
198
|
+
struct ToolRegisterArgs {
|
|
199
|
+
#[arg(long, help = "Path to a JSON/JSONC tool manifest")]
|
|
200
|
+
manifest: PathBuf,
|
|
201
|
+
#[arg(long)]
|
|
202
|
+
base_url: Option<String>,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#[derive(Args)]
|
|
206
|
+
struct ToolEnableArgs {
|
|
207
|
+
#[arg(long)]
|
|
208
|
+
tool_id: String,
|
|
209
|
+
#[arg(long)]
|
|
210
|
+
org_id: Option<String>,
|
|
211
|
+
#[arg(long)]
|
|
212
|
+
project_id: Option<String>,
|
|
213
|
+
#[arg(long)]
|
|
214
|
+
user_id: Option<String>,
|
|
215
|
+
#[arg(long)]
|
|
216
|
+
expires_at: Option<String>,
|
|
217
|
+
#[arg(long, help = "Path to JSON/JSONC metadata file")]
|
|
218
|
+
metadata_file: Option<PathBuf>,
|
|
219
|
+
#[arg(long)]
|
|
220
|
+
base_url: Option<String>,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#[derive(Args)]
|
|
224
|
+
struct ToolDisableArgs {
|
|
225
|
+
#[arg(long)]
|
|
226
|
+
tool_id: String,
|
|
227
|
+
#[arg(long)]
|
|
228
|
+
org_id: Option<String>,
|
|
229
|
+
#[arg(long)]
|
|
230
|
+
project_id: Option<String>,
|
|
231
|
+
#[arg(long)]
|
|
232
|
+
user_id: Option<String>,
|
|
233
|
+
#[arg(long, help = "Path to JSON/JSONC metadata file")]
|
|
234
|
+
metadata_file: Option<PathBuf>,
|
|
235
|
+
#[arg(long)]
|
|
236
|
+
base_url: Option<String>,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[derive(Args)]
|
|
240
|
+
struct ToolRunArgs {
|
|
241
|
+
#[arg(long)]
|
|
242
|
+
tool_id: String,
|
|
243
|
+
#[arg(long)]
|
|
244
|
+
org_id: Option<String>,
|
|
245
|
+
#[arg(long)]
|
|
246
|
+
project_id: Option<String>,
|
|
247
|
+
#[arg(long, help = "Inline JSON object for tool input")]
|
|
248
|
+
input_json: Option<String>,
|
|
249
|
+
#[arg(long, help = "Path to JSON/JSONC file for tool input")]
|
|
250
|
+
input_file: Option<PathBuf>,
|
|
251
|
+
#[arg(long, help = "Path to JSON/JSONC metadata file")]
|
|
252
|
+
metadata_file: Option<PathBuf>,
|
|
253
|
+
#[arg(long, help = "Idempotency key for deduplicating retries")]
|
|
254
|
+
idempotency_key: Option<String>,
|
|
255
|
+
#[arg(long)]
|
|
256
|
+
base_url: Option<String>,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[derive(Args)]
|
|
260
|
+
struct ToolRunsArgs {
|
|
261
|
+
#[arg(long)]
|
|
262
|
+
tool_id: Option<String>,
|
|
263
|
+
#[arg(long)]
|
|
264
|
+
project_id: Option<String>,
|
|
265
|
+
#[arg(long)]
|
|
266
|
+
requested_by_user_id: Option<String>,
|
|
267
|
+
#[arg(long)]
|
|
268
|
+
status: Option<String>,
|
|
269
|
+
#[arg(long)]
|
|
270
|
+
base_url: Option<String>,
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#[derive(Args)]
|
|
274
|
+
struct ToolGetRunArgs {
|
|
275
|
+
#[arg(long)]
|
|
276
|
+
run_id: String,
|
|
277
|
+
#[arg(long)]
|
|
278
|
+
base_url: Option<String>,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
#[derive(Args)]
|
|
282
|
+
struct ProjectListArgs {
|
|
283
|
+
#[arg(long)]
|
|
284
|
+
org_id: Option<String>,
|
|
285
|
+
#[arg(long)]
|
|
286
|
+
base_url: Option<String>,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#[derive(Args)]
|
|
290
|
+
struct ProjectCreateArgs {
|
|
291
|
+
#[arg(long)]
|
|
292
|
+
org_id: String,
|
|
293
|
+
#[arg(long)]
|
|
294
|
+
name: String,
|
|
295
|
+
#[arg(long)]
|
|
296
|
+
description: Option<String>,
|
|
297
|
+
#[arg(long)]
|
|
298
|
+
base_url: Option<String>,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[derive(Args)]
|
|
302
|
+
struct ProjectGetArgs {
|
|
303
|
+
#[arg(long)]
|
|
304
|
+
project_id: String,
|
|
305
|
+
#[arg(long)]
|
|
306
|
+
base_url: Option<String>,
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#[derive(Args)]
|
|
310
|
+
struct ProjectUpdateArgs {
|
|
311
|
+
#[arg(long)]
|
|
312
|
+
project_id: String,
|
|
313
|
+
#[arg(long)]
|
|
314
|
+
name: Option<String>,
|
|
315
|
+
#[arg(long)]
|
|
316
|
+
description: Option<String>,
|
|
317
|
+
#[arg(long)]
|
|
318
|
+
clear_description: bool,
|
|
319
|
+
#[arg(long)]
|
|
320
|
+
base_url: Option<String>,
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
#[derive(Args)]
|
|
324
|
+
struct ProjectDeleteArgs {
|
|
325
|
+
#[arg(long)]
|
|
326
|
+
project_id: String,
|
|
327
|
+
#[arg(long)]
|
|
328
|
+
base_url: Option<String>,
|
|
329
|
+
}
|
|
330
|
+
|
|
77
331
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
78
332
|
struct SessionConfig {
|
|
79
333
|
base_url: String,
|
|
@@ -185,8 +439,153 @@ struct CreateApiTokenResponse {
|
|
|
185
439
|
expires_at: Option<String>,
|
|
186
440
|
}
|
|
187
441
|
|
|
442
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
443
|
+
#[serde(rename_all = "camelCase")]
|
|
444
|
+
struct ProjectRecord {
|
|
445
|
+
id: String,
|
|
446
|
+
#[serde(alias = "orgId")]
|
|
447
|
+
org_id: String,
|
|
448
|
+
name: String,
|
|
449
|
+
description: Option<String>,
|
|
450
|
+
#[serde(alias = "createdAt")]
|
|
451
|
+
created_at: String,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
455
|
+
struct ListProjectsResponse {
|
|
456
|
+
projects: Vec<ProjectRecord>,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
460
|
+
#[serde(rename_all = "camelCase")]
|
|
461
|
+
struct ProjectResponse {
|
|
462
|
+
project: ProjectRecord,
|
|
463
|
+
#[serde(alias = "accessLevel")]
|
|
464
|
+
access_level: Option<String>,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
468
|
+
#[serde(rename_all = "camelCase")]
|
|
469
|
+
struct AssetRecord {
|
|
470
|
+
id: String,
|
|
471
|
+
project_id: String,
|
|
472
|
+
file_name: String,
|
|
473
|
+
content_type: String,
|
|
474
|
+
size_bytes: i64,
|
|
475
|
+
asset_type: String,
|
|
476
|
+
visibility: String,
|
|
477
|
+
status: String,
|
|
478
|
+
object_key: String,
|
|
479
|
+
etag: Option<String>,
|
|
480
|
+
created_at: String,
|
|
481
|
+
updated_at: String,
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
485
|
+
struct ListAssetsResponse {
|
|
486
|
+
assets: Vec<AssetRecord>,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
490
|
+
struct AssetResponse {
|
|
491
|
+
asset: AssetRecord,
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
#[derive(Debug, Serialize)]
|
|
495
|
+
#[serde(rename_all = "camelCase")]
|
|
496
|
+
struct UploadIntentRequest {
|
|
497
|
+
project_id: String,
|
|
498
|
+
file_name: String,
|
|
499
|
+
content_type: String,
|
|
500
|
+
size_bytes: i64,
|
|
501
|
+
asset_type: String,
|
|
502
|
+
visibility: String,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#[derive(Debug, Serialize, Deserialize)]
|
|
506
|
+
#[serde(rename_all = "camelCase")]
|
|
507
|
+
struct UploadIntentResponse {
|
|
508
|
+
asset_id: String,
|
|
509
|
+
#[serde(default = "default_upload_strategy")]
|
|
510
|
+
strategy: String,
|
|
511
|
+
upload_url: Option<String>,
|
|
512
|
+
method: Option<String>,
|
|
513
|
+
required_headers: Option<std::collections::HashMap<String, String>>,
|
|
514
|
+
session_id: Option<String>,
|
|
515
|
+
part_size_bytes: Option<i64>,
|
|
516
|
+
part_count: Option<i64>,
|
|
517
|
+
parts: Option<Vec<UploadPartIntent>>,
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
fn default_upload_strategy() -> String {
|
|
521
|
+
"single".to_string()
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
525
|
+
#[serde(rename_all = "camelCase")]
|
|
526
|
+
struct UploadPartIntent {
|
|
527
|
+
part_number: i64,
|
|
528
|
+
upload_url: String,
|
|
529
|
+
method: String,
|
|
530
|
+
required_headers: std::collections::HashMap<String, String>,
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
#[derive(Debug, Serialize)]
|
|
534
|
+
#[serde(rename_all = "camelCase")]
|
|
535
|
+
struct UploadCompletedPart {
|
|
536
|
+
part_number: i64,
|
|
537
|
+
etag: String,
|
|
538
|
+
size_bytes: i64,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
#[derive(Debug, Serialize)]
|
|
542
|
+
#[serde(rename_all = "camelCase")]
|
|
543
|
+
struct UploadCompleteRequest {
|
|
544
|
+
asset_id: String,
|
|
545
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
546
|
+
session_id: Option<String>,
|
|
547
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
548
|
+
etag: Option<String>,
|
|
549
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
550
|
+
size_bytes: Option<i64>,
|
|
551
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
552
|
+
completed_parts: Option<Vec<UploadCompletedPart>>,
|
|
553
|
+
}
|
|
554
|
+
|
|
188
555
|
fn normalize_base_url(input: &str) -> String {
|
|
189
|
-
input.trim().trim_end_matches('/')
|
|
556
|
+
let trimmed = input.trim().trim_end_matches('/');
|
|
557
|
+
match reqwest::Url::parse(trimmed) {
|
|
558
|
+
Ok(url) => {
|
|
559
|
+
let Some(host) = url.host_str() else {
|
|
560
|
+
return trimmed.to_string();
|
|
561
|
+
};
|
|
562
|
+
let mut normalized = format!("{}://{}", url.scheme(), host);
|
|
563
|
+
if let Some(port) = url.port() {
|
|
564
|
+
normalized.push_str(&format!(":{}", port));
|
|
565
|
+
}
|
|
566
|
+
normalized
|
|
567
|
+
}
|
|
568
|
+
Err(_) => trimmed.to_string(),
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
fn parse_jsonc_str(raw: &str, context: &str) -> Result<serde_json::Value> {
|
|
573
|
+
let value: serde_json::Value =
|
|
574
|
+
json5::from_str(raw).with_context(|| format!("Failed to parse {}", context))?;
|
|
575
|
+
Ok(value)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
fn load_jsonc_file(path: &Path, label: &str) -> Result<serde_json::Value> {
|
|
579
|
+
let raw = fs::read_to_string(path)
|
|
580
|
+
.with_context(|| format!("Failed to read {} file {}", label, path.display()))?;
|
|
581
|
+
parse_jsonc_str(&raw, &format!("{} file {}", label, path.display()))
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
fn parse_object_from_value(value: serde_json::Value, context: &str) -> Result<serde_json::Map<String, serde_json::Value>> {
|
|
585
|
+
match value {
|
|
586
|
+
serde_json::Value::Object(map) => Ok(map),
|
|
587
|
+
_ => Err(anyhow!("{} must be a JSON object", context)),
|
|
588
|
+
}
|
|
190
589
|
}
|
|
191
590
|
|
|
192
591
|
fn now_epoch_ms() -> u128 {
|
|
@@ -196,9 +595,43 @@ fn now_epoch_ms() -> u128 {
|
|
|
196
595
|
.as_millis()
|
|
197
596
|
}
|
|
198
597
|
|
|
598
|
+
fn resolve_config_root() -> Result<PathBuf> {
|
|
599
|
+
if let Ok(custom_root) = std::env::var(CONFIG_DIR_ENV) {
|
|
600
|
+
let trimmed = custom_root.trim();
|
|
601
|
+
if !trimmed.is_empty() {
|
|
602
|
+
return Ok(PathBuf::from(trimmed));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
dirs::config_dir().ok_or_else(|| anyhow!("Could not resolve config directory"))
|
|
607
|
+
}
|
|
608
|
+
|
|
199
609
|
fn config_path() -> Result<PathBuf> {
|
|
200
|
-
let base =
|
|
201
|
-
Ok(base.join(
|
|
610
|
+
let base = resolve_config_root()?;
|
|
611
|
+
Ok(base.join(SESSION_DIR_NAME).join(SESSION_FILE_NAME))
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
fn session_path_display() -> String {
|
|
615
|
+
config_path()
|
|
616
|
+
.map(|path| path.display().to_string())
|
|
617
|
+
.unwrap_or_else(|_| format!("<{}>/{}", SESSION_DIR_NAME, SESSION_FILE_NAME))
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
fn write_atomic(path: &Path, payload: &[u8]) -> Result<()> {
|
|
621
|
+
let temp_path = path.with_extension(format!("tmp.{}", now_epoch_ms()));
|
|
622
|
+
fs::write(&temp_path, payload)
|
|
623
|
+
.with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
|
|
624
|
+
if path.exists() {
|
|
625
|
+
fs::remove_file(path).with_context(|| format!("Failed to replace {}", path.display()))?;
|
|
626
|
+
}
|
|
627
|
+
fs::rename(&temp_path, path).with_context(|| {
|
|
628
|
+
format!(
|
|
629
|
+
"Failed to move temporary file {} to {}",
|
|
630
|
+
temp_path.display(),
|
|
631
|
+
path.display()
|
|
632
|
+
)
|
|
633
|
+
})?;
|
|
634
|
+
Ok(())
|
|
202
635
|
}
|
|
203
636
|
|
|
204
637
|
fn save_session(session: &SessionConfig) -> Result<()> {
|
|
@@ -207,7 +640,7 @@ fn save_session(session: &SessionConfig) -> Result<()> {
|
|
|
207
640
|
fs::create_dir_all(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
|
|
208
641
|
}
|
|
209
642
|
let payload = serde_json::to_vec_pretty(session)?;
|
|
210
|
-
|
|
643
|
+
write_atomic(&path, &payload)?;
|
|
211
644
|
Ok(())
|
|
212
645
|
}
|
|
213
646
|
|
|
@@ -224,12 +657,13 @@ fn load_session() -> Result<SessionConfig> {
|
|
|
224
657
|
Ok(session)
|
|
225
658
|
}
|
|
226
659
|
|
|
227
|
-
fn clear_session() -> Result<
|
|
660
|
+
fn clear_session() -> Result<bool> {
|
|
228
661
|
let path = config_path()?;
|
|
229
662
|
if path.exists() {
|
|
230
663
|
fs::remove_file(&path).with_context(|| format!("Failed to remove {}", path.display()))?;
|
|
664
|
+
return Ok(true);
|
|
231
665
|
}
|
|
232
|
-
Ok(
|
|
666
|
+
Ok(false)
|
|
233
667
|
}
|
|
234
668
|
|
|
235
669
|
async fn read_error_body(response: reqwest::Response) -> String {
|
|
@@ -239,6 +673,33 @@ async fn read_error_body(response: reqwest::Response) -> String {
|
|
|
239
673
|
}
|
|
240
674
|
}
|
|
241
675
|
|
|
676
|
+
fn clean_virtual_path(value: &str) -> String {
|
|
677
|
+
value
|
|
678
|
+
.split('/')
|
|
679
|
+
.map(|segment| segment.trim())
|
|
680
|
+
.filter(|segment| !segment.is_empty() && *segment != "." && *segment != "..")
|
|
681
|
+
.collect::<Vec<_>>()
|
|
682
|
+
.join("/")
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
fn apply_base_url_override(session: &mut SessionConfig, base_url: Option<String>) {
|
|
686
|
+
if let Some(base_url) = base_url {
|
|
687
|
+
session.base_url = normalize_base_url(&base_url);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
fn join_remote_path(prefix: Option<&str>, file_name: &str) -> String {
|
|
692
|
+
let file_clean = clean_virtual_path(file_name);
|
|
693
|
+
let prefix_clean = prefix.map(clean_virtual_path).unwrap_or_default();
|
|
694
|
+
if prefix_clean.is_empty() {
|
|
695
|
+
return file_clean;
|
|
696
|
+
}
|
|
697
|
+
if file_clean.is_empty() {
|
|
698
|
+
return prefix_clean;
|
|
699
|
+
}
|
|
700
|
+
format!("{}/{}", prefix_clean, file_clean)
|
|
701
|
+
}
|
|
702
|
+
|
|
242
703
|
async fn authed_request(
|
|
243
704
|
client: &reqwest::Client,
|
|
244
705
|
session: &mut SessionConfig,
|
|
@@ -289,8 +750,41 @@ async fn refresh_session(client: &reqwest::Client, session: &mut SessionConfig)
|
|
|
289
750
|
Ok(())
|
|
290
751
|
}
|
|
291
752
|
|
|
753
|
+
async fn existing_session_identity_for_base_url(
|
|
754
|
+
client: &reqwest::Client,
|
|
755
|
+
base_url: &str,
|
|
756
|
+
) -> Option<String> {
|
|
757
|
+
let mut session = load_session().ok()?;
|
|
758
|
+
if normalize_base_url(&session.base_url) != base_url {
|
|
759
|
+
return None;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
let response = authed_request(client, &mut session, Method::GET, "/auth/me", None)
|
|
763
|
+
.await
|
|
764
|
+
.ok()?;
|
|
765
|
+
if !response.status().is_success() {
|
|
766
|
+
return None;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
let payload: serde_json::Value = response.json().await.ok()?;
|
|
770
|
+
let _ = save_session(&session);
|
|
771
|
+
payload
|
|
772
|
+
.get("user")
|
|
773
|
+
.and_then(|user| user.get("email"))
|
|
774
|
+
.and_then(|value| value.as_str())
|
|
775
|
+
.map(|email| email.to_string())
|
|
776
|
+
}
|
|
777
|
+
|
|
292
778
|
async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()> {
|
|
293
779
|
let base_url = normalize_base_url(&args.base_url);
|
|
780
|
+
if !args.force {
|
|
781
|
+
if let Some(email) = existing_session_identity_for_base_url(client, &base_url).await {
|
|
782
|
+
println!("Already logged in as {} on {}.", email, base_url);
|
|
783
|
+
println!("Use `reallink logout` to sign out or `reallink login --force` to replace this session.");
|
|
784
|
+
return Ok(());
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
294
788
|
let scope = if args.scope.is_empty() {
|
|
295
789
|
vec![
|
|
296
790
|
"core:read".to_string(),
|
|
@@ -324,12 +818,21 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
324
818
|
println!("Open this URL in your browser and approve the login:");
|
|
325
819
|
println!("{}", device_code.verification_uri_complete);
|
|
326
820
|
println!("User code: {}", device_code.user_code);
|
|
821
|
+
match webbrowser::open(&device_code.verification_uri_complete) {
|
|
822
|
+
Ok(_) => println!("Browser opened for device approval."),
|
|
823
|
+
Err(_) => println!("Could not open browser automatically. Open the URL manually."),
|
|
824
|
+
}
|
|
327
825
|
|
|
328
826
|
let expires_at = std::time::Instant::now() + Duration::from_secs(device_code.expires_in);
|
|
329
827
|
let mut poll_interval = Duration::from_secs(device_code.interval.max(1));
|
|
828
|
+
let mut pending_polls = 0u32;
|
|
829
|
+
println!("Waiting for approval (press Ctrl+C to cancel)");
|
|
330
830
|
|
|
331
831
|
loop {
|
|
332
832
|
if std::time::Instant::now() >= expires_at {
|
|
833
|
+
if pending_polls > 0 {
|
|
834
|
+
println!();
|
|
835
|
+
}
|
|
333
836
|
return Err(anyhow!("Device code expired before approval"));
|
|
334
837
|
}
|
|
335
838
|
|
|
@@ -347,6 +850,9 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
347
850
|
|
|
348
851
|
if token_response.status().is_success() {
|
|
349
852
|
let tokens: DeviceTokenSuccess = token_response.json().await?;
|
|
853
|
+
if pending_polls > 0 {
|
|
854
|
+
println!();
|
|
855
|
+
}
|
|
350
856
|
let session = SessionConfig {
|
|
351
857
|
base_url: base_url.clone(),
|
|
352
858
|
access_token: tokens.access_token,
|
|
@@ -356,6 +862,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
356
862
|
};
|
|
357
863
|
save_session(&session)?;
|
|
358
864
|
println!("Login successful.");
|
|
865
|
+
println!("Session stored at {}", session_path_display());
|
|
359
866
|
return Ok(());
|
|
360
867
|
}
|
|
361
868
|
|
|
@@ -368,12 +875,23 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
368
875
|
});
|
|
369
876
|
|
|
370
877
|
match error_payload.error.as_str() {
|
|
371
|
-
"authorization_pending" =>
|
|
878
|
+
"authorization_pending" => {
|
|
879
|
+
pending_polls = pending_polls.saturating_add(1);
|
|
880
|
+
print!(".");
|
|
881
|
+
let _ = io::stdout().flush();
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
372
884
|
"slow_down" => {
|
|
373
885
|
poll_interval += Duration::from_secs(1);
|
|
886
|
+
pending_polls = pending_polls.saturating_add(1);
|
|
887
|
+
print!("+");
|
|
888
|
+
let _ = io::stdout().flush();
|
|
374
889
|
continue;
|
|
375
890
|
}
|
|
376
891
|
_ => {
|
|
892
|
+
if pending_polls > 0 {
|
|
893
|
+
println!();
|
|
894
|
+
}
|
|
377
895
|
return Err(anyhow!(
|
|
378
896
|
"Device login failed: {} ({})",
|
|
379
897
|
error_payload.error,
|
|
@@ -384,11 +902,56 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
384
902
|
}
|
|
385
903
|
}
|
|
386
904
|
|
|
905
|
+
async fn logout_command(client: &reqwest::Client) -> Result<()> {
|
|
906
|
+
let path_display = session_path_display();
|
|
907
|
+
let mut session = match load_session() {
|
|
908
|
+
Ok(session) => session,
|
|
909
|
+
Err(_) => {
|
|
910
|
+
println!("No local session found at {}.", path_display);
|
|
911
|
+
println!("You are already logged out.");
|
|
912
|
+
return Ok(());
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
let mut remote_revoked = false;
|
|
917
|
+
let mut remote_unavailable = false;
|
|
918
|
+
match authed_request(client, &mut session, Method::POST, "/auth/logout", None).await {
|
|
919
|
+
Ok(response) if response.status().is_success() => {
|
|
920
|
+
remote_revoked = true;
|
|
921
|
+
}
|
|
922
|
+
Ok(response) if response.status() == StatusCode::NOT_FOUND => {
|
|
923
|
+
remote_unavailable = true;
|
|
924
|
+
}
|
|
925
|
+
Ok(response) => {
|
|
926
|
+
let body = read_error_body(response).await;
|
|
927
|
+
eprintln!("Warning: remote logout request failed: {}", body);
|
|
928
|
+
}
|
|
929
|
+
Err(error) => {
|
|
930
|
+
eprintln!("Warning: remote logout request failed: {}", error);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
let removed = clear_session()?;
|
|
935
|
+
if !removed {
|
|
936
|
+
println!("Local session was already cleared.");
|
|
937
|
+
return Ok(());
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if remote_revoked {
|
|
941
|
+
println!("Logged out. Server session revoked and local session removed from {}.", path_display);
|
|
942
|
+
} else if remote_unavailable {
|
|
943
|
+
println!("Logged out locally. Removed session from {}.", path_display);
|
|
944
|
+
println!("Server logout endpoint is not available on this API deployment yet.");
|
|
945
|
+
} else {
|
|
946
|
+
println!("Logged out locally. Removed session from {}.", path_display);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
Ok(())
|
|
950
|
+
}
|
|
951
|
+
|
|
387
952
|
async fn whoami_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
|
|
388
953
|
let mut session = load_session()?;
|
|
389
|
-
|
|
390
|
-
session.base_url = normalize_base_url(&base_url);
|
|
391
|
-
}
|
|
954
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
392
955
|
|
|
393
956
|
let response = authed_request(client, &mut session, Method::GET, "/auth/me", None).await?;
|
|
394
957
|
if !response.status().is_success() {
|
|
@@ -403,9 +966,7 @@ async fn whoami_command(client: &reqwest::Client, args: BaseArgs) -> Result<()>
|
|
|
403
966
|
|
|
404
967
|
async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
|
|
405
968
|
let mut session = load_session()?;
|
|
406
|
-
|
|
407
|
-
session.base_url = normalize_base_url(&base_url);
|
|
408
|
-
}
|
|
969
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
409
970
|
|
|
410
971
|
let response = authed_request(client, &mut session, Method::GET, "/auth/tokens", None).await?;
|
|
411
972
|
if !response.status().is_success() {
|
|
@@ -420,9 +981,7 @@ async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<
|
|
|
420
981
|
|
|
421
982
|
async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
|
|
422
983
|
let mut session = load_session()?;
|
|
423
|
-
|
|
424
|
-
session.base_url = normalize_base_url(&base_url);
|
|
425
|
-
}
|
|
984
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
426
985
|
|
|
427
986
|
let scopes = if args.scope.is_empty() {
|
|
428
987
|
return Err(anyhow!("At least one --scope must be provided"));
|
|
@@ -451,9 +1010,7 @@ async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -
|
|
|
451
1010
|
|
|
452
1011
|
async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -> Result<()> {
|
|
453
1012
|
let mut session = load_session()?;
|
|
454
|
-
|
|
455
|
-
session.base_url = normalize_base_url(&base_url);
|
|
456
|
-
}
|
|
1013
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
457
1014
|
|
|
458
1015
|
let path = format!("/auth/tokens/{}", args.token_id);
|
|
459
1016
|
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
@@ -467,25 +1024,741 @@ async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -
|
|
|
467
1024
|
Ok(())
|
|
468
1025
|
}
|
|
469
1026
|
|
|
1027
|
+
async fn project_list_command(client: &reqwest::Client, args: ProjectListArgs) -> Result<()> {
|
|
1028
|
+
let mut session = load_session()?;
|
|
1029
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1030
|
+
|
|
1031
|
+
let path = match args.org_id {
|
|
1032
|
+
Some(org_id) if !org_id.trim().is_empty() => format!("/core/projects?orgId={}", org_id.trim()),
|
|
1033
|
+
_ => "/core/projects".to_string(),
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1037
|
+
if !response.status().is_success() {
|
|
1038
|
+
let body = read_error_body(response).await;
|
|
1039
|
+
return Err(anyhow!("project list failed: {}", body));
|
|
1040
|
+
}
|
|
1041
|
+
let payload: ListProjectsResponse = response.json().await?;
|
|
1042
|
+
println!("{}", serde_json::to_string_pretty(&payload.projects)?);
|
|
1043
|
+
save_session(&session)?;
|
|
1044
|
+
Ok(())
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async fn project_create_command(client: &reqwest::Client, args: ProjectCreateArgs) -> Result<()> {
|
|
1048
|
+
let mut session = load_session()?;
|
|
1049
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1050
|
+
|
|
1051
|
+
let response = authed_request(
|
|
1052
|
+
client,
|
|
1053
|
+
&mut session,
|
|
1054
|
+
Method::POST,
|
|
1055
|
+
"/core/projects",
|
|
1056
|
+
Some(serde_json::json!({
|
|
1057
|
+
"orgId": args.org_id,
|
|
1058
|
+
"name": args.name,
|
|
1059
|
+
"description": args.description
|
|
1060
|
+
})),
|
|
1061
|
+
)
|
|
1062
|
+
.await?;
|
|
1063
|
+
if !response.status().is_success() {
|
|
1064
|
+
let body = read_error_body(response).await;
|
|
1065
|
+
return Err(anyhow!("project create failed: {}", body));
|
|
1066
|
+
}
|
|
1067
|
+
let payload: ProjectResponse = response.json().await?;
|
|
1068
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
1069
|
+
save_session(&session)?;
|
|
1070
|
+
Ok(())
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async fn project_get_command(client: &reqwest::Client, args: ProjectGetArgs) -> Result<()> {
|
|
1074
|
+
let mut session = load_session()?;
|
|
1075
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1076
|
+
|
|
1077
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
1078
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1079
|
+
if !response.status().is_success() {
|
|
1080
|
+
let body = read_error_body(response).await;
|
|
1081
|
+
return Err(anyhow!("project get failed: {}", body));
|
|
1082
|
+
}
|
|
1083
|
+
let payload: ProjectResponse = response.json().await?;
|
|
1084
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
1085
|
+
save_session(&session)?;
|
|
1086
|
+
Ok(())
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
async fn project_update_command(client: &reqwest::Client, args: ProjectUpdateArgs) -> Result<()> {
|
|
1090
|
+
let mut session = load_session()?;
|
|
1091
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1092
|
+
|
|
1093
|
+
let mut body = serde_json::Map::new();
|
|
1094
|
+
if let Some(name) = args.name {
|
|
1095
|
+
body.insert("name".to_string(), serde_json::Value::String(name));
|
|
1096
|
+
}
|
|
1097
|
+
if args.clear_description {
|
|
1098
|
+
body.insert("description".to_string(), serde_json::Value::Null);
|
|
1099
|
+
} else if let Some(description) = args.description {
|
|
1100
|
+
body.insert(
|
|
1101
|
+
"description".to_string(),
|
|
1102
|
+
serde_json::Value::String(description),
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if body.is_empty() {
|
|
1107
|
+
return Err(anyhow!(
|
|
1108
|
+
"project update requires at least one field (--name, --description, or --clear-description)"
|
|
1109
|
+
));
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
1113
|
+
let response = authed_request(
|
|
1114
|
+
client,
|
|
1115
|
+
&mut session,
|
|
1116
|
+
Method::PATCH,
|
|
1117
|
+
&path,
|
|
1118
|
+
Some(serde_json::Value::Object(body)),
|
|
1119
|
+
)
|
|
1120
|
+
.await?;
|
|
1121
|
+
if !response.status().is_success() {
|
|
1122
|
+
let body = read_error_body(response).await;
|
|
1123
|
+
return Err(anyhow!("project update failed: {}", body));
|
|
1124
|
+
}
|
|
1125
|
+
let payload: ProjectResponse = response.json().await?;
|
|
1126
|
+
println!("{}", serde_json::to_string_pretty(&payload.project)?);
|
|
1127
|
+
save_session(&session)?;
|
|
1128
|
+
Ok(())
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
async fn project_delete_command(client: &reqwest::Client, args: ProjectDeleteArgs) -> Result<()> {
|
|
1132
|
+
let mut session = load_session()?;
|
|
1133
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1134
|
+
|
|
1135
|
+
let path = format!("/core/projects/{}", args.project_id);
|
|
1136
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
1137
|
+
if !response.status().is_success() {
|
|
1138
|
+
let body = read_error_body(response).await;
|
|
1139
|
+
return Err(anyhow!("project delete failed: {}", body));
|
|
1140
|
+
}
|
|
1141
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1142
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1143
|
+
save_session(&session)?;
|
|
1144
|
+
Ok(())
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async fn upload_asset_via_intent(
|
|
1148
|
+
client: &reqwest::Client,
|
|
1149
|
+
session: &mut SessionConfig,
|
|
1150
|
+
project_id: &str,
|
|
1151
|
+
remote_file_name: &str,
|
|
1152
|
+
bytes: Vec<u8>,
|
|
1153
|
+
content_type: &str,
|
|
1154
|
+
asset_type: &str,
|
|
1155
|
+
visibility: &str,
|
|
1156
|
+
) -> Result<AssetRecord> {
|
|
1157
|
+
let size_bytes = bytes.len() as i64;
|
|
1158
|
+
let intent_body = serde_json::to_value(UploadIntentRequest {
|
|
1159
|
+
project_id: project_id.to_string(),
|
|
1160
|
+
file_name: remote_file_name.to_string(),
|
|
1161
|
+
content_type: content_type.to_string(),
|
|
1162
|
+
size_bytes,
|
|
1163
|
+
asset_type: asset_type.to_string(),
|
|
1164
|
+
visibility: visibility.to_string(),
|
|
1165
|
+
})?;
|
|
1166
|
+
|
|
1167
|
+
let intent_response = authed_request(
|
|
1168
|
+
client,
|
|
1169
|
+
session,
|
|
1170
|
+
Method::POST,
|
|
1171
|
+
"/assets/upload-intent",
|
|
1172
|
+
Some(intent_body),
|
|
1173
|
+
)
|
|
1174
|
+
.await?;
|
|
1175
|
+
if !intent_response.status().is_success() {
|
|
1176
|
+
let body = read_error_body(intent_response).await;
|
|
1177
|
+
return Err(anyhow!("upload-intent failed: {}", body));
|
|
1178
|
+
}
|
|
1179
|
+
let intent: UploadIntentResponse = intent_response.json().await?;
|
|
1180
|
+
let strategy = intent.strategy.trim().to_ascii_lowercase();
|
|
1181
|
+
let complete_payload = if strategy == "multipart" {
|
|
1182
|
+
let part_size = intent
|
|
1183
|
+
.part_size_bytes
|
|
1184
|
+
.and_then(|value| if value > 0 { Some(value as usize) } else { None })
|
|
1185
|
+
.ok_or_else(|| anyhow!("multipart upload intent is missing partSizeBytes"))?;
|
|
1186
|
+
let part_count = intent
|
|
1187
|
+
.part_count
|
|
1188
|
+
.and_then(|value| if value > 0 { Some(value as usize) } else { None })
|
|
1189
|
+
.ok_or_else(|| anyhow!("multipart upload intent is missing partCount"))?;
|
|
1190
|
+
let session_id = intent
|
|
1191
|
+
.session_id
|
|
1192
|
+
.clone()
|
|
1193
|
+
.ok_or_else(|| anyhow!("multipart upload intent is missing sessionId"))?;
|
|
1194
|
+
let mut parts = intent.parts.clone().unwrap_or_default();
|
|
1195
|
+
if parts.is_empty() {
|
|
1196
|
+
return Err(anyhow!("multipart upload intent is missing parts"));
|
|
1197
|
+
}
|
|
1198
|
+
parts.sort_by_key(|part| part.part_number);
|
|
1199
|
+
if parts.len() != part_count {
|
|
1200
|
+
return Err(anyhow!(
|
|
1201
|
+
"multipart upload intent has mismatched part count (expected {}, got {})",
|
|
1202
|
+
part_count,
|
|
1203
|
+
parts.len()
|
|
1204
|
+
));
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
let mut completed_parts = Vec::with_capacity(parts.len());
|
|
1208
|
+
for part in parts {
|
|
1209
|
+
let part_number = part.part_number;
|
|
1210
|
+
if part_number <= 0 {
|
|
1211
|
+
return Err(anyhow!("multipart part has invalid part number"));
|
|
1212
|
+
}
|
|
1213
|
+
let offset = (part_number as usize - 1) * part_size;
|
|
1214
|
+
let end = std::cmp::min(offset + part_size, bytes.len());
|
|
1215
|
+
if offset >= end {
|
|
1216
|
+
return Err(anyhow!("multipart part range is invalid"));
|
|
1217
|
+
}
|
|
1218
|
+
let chunk = bytes[offset..end].to_vec();
|
|
1219
|
+
|
|
1220
|
+
let upload_method = Method::from_bytes(part.method.as_bytes()).unwrap_or(Method::PUT);
|
|
1221
|
+
let etag = format!("etag_{}_{}", now_epoch_ms(), part_number);
|
|
1222
|
+
let mut upload_request = client.request(upload_method, &part.upload_url);
|
|
1223
|
+
for (key, value) in part.required_headers.iter() {
|
|
1224
|
+
upload_request = upload_request.header(key, value);
|
|
1225
|
+
}
|
|
1226
|
+
upload_request = upload_request.header("x-mock-etag", &etag).body(chunk);
|
|
1227
|
+
let upload_response = upload_request.send().await?;
|
|
1228
|
+
if !upload_response.status().is_success() {
|
|
1229
|
+
let body = read_error_body(upload_response).await;
|
|
1230
|
+
return Err(anyhow!(
|
|
1231
|
+
"multipart upload failed on part {}: {}",
|
|
1232
|
+
part_number,
|
|
1233
|
+
body
|
|
1234
|
+
));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
completed_parts.push(UploadCompletedPart {
|
|
1238
|
+
part_number,
|
|
1239
|
+
etag,
|
|
1240
|
+
size_bytes: (end - offset) as i64,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
serde_json::to_value(UploadCompleteRequest {
|
|
1245
|
+
asset_id: intent.asset_id,
|
|
1246
|
+
session_id: Some(session_id),
|
|
1247
|
+
etag: None,
|
|
1248
|
+
size_bytes: Some(size_bytes),
|
|
1249
|
+
completed_parts: Some(completed_parts),
|
|
1250
|
+
})?
|
|
1251
|
+
} else {
|
|
1252
|
+
let upload_url = intent
|
|
1253
|
+
.upload_url
|
|
1254
|
+
.clone()
|
|
1255
|
+
.ok_or_else(|| anyhow!("single upload intent is missing uploadUrl"))?;
|
|
1256
|
+
let method = intent.method.clone().unwrap_or_else(|| "PUT".to_string());
|
|
1257
|
+
let required_headers = intent.required_headers.clone().unwrap_or_default();
|
|
1258
|
+
|
|
1259
|
+
let upload_method = Method::from_bytes(method.as_bytes()).unwrap_or(Method::PUT);
|
|
1260
|
+
let etag = format!("etag_{}", now_epoch_ms());
|
|
1261
|
+
let mut upload_request = client.request(upload_method, &upload_url);
|
|
1262
|
+
for (key, value) in required_headers.iter() {
|
|
1263
|
+
upload_request = upload_request.header(key, value);
|
|
1264
|
+
}
|
|
1265
|
+
upload_request = upload_request.header("x-mock-etag", &etag).body(bytes);
|
|
1266
|
+
let upload_response = upload_request.send().await?;
|
|
1267
|
+
if !upload_response.status().is_success() {
|
|
1268
|
+
let body = read_error_body(upload_response).await;
|
|
1269
|
+
return Err(anyhow!("upload PUT failed: {}", body));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
serde_json::to_value(UploadCompleteRequest {
|
|
1273
|
+
asset_id: intent.asset_id,
|
|
1274
|
+
session_id: intent.session_id.clone(),
|
|
1275
|
+
etag: Some(etag),
|
|
1276
|
+
size_bytes: Some(size_bytes),
|
|
1277
|
+
completed_parts: None,
|
|
1278
|
+
})?
|
|
1279
|
+
};
|
|
1280
|
+
|
|
1281
|
+
let complete_response = authed_request(
|
|
1282
|
+
client,
|
|
1283
|
+
session,
|
|
1284
|
+
Method::POST,
|
|
1285
|
+
"/assets/upload-complete",
|
|
1286
|
+
Some(complete_payload),
|
|
1287
|
+
)
|
|
1288
|
+
.await?;
|
|
1289
|
+
if !complete_response.status().is_success() {
|
|
1290
|
+
let body = read_error_body(complete_response).await;
|
|
1291
|
+
return Err(anyhow!("upload-complete failed: {}", body));
|
|
1292
|
+
}
|
|
1293
|
+
let payload: AssetResponse = complete_response.json().await?;
|
|
1294
|
+
Ok(payload.asset)
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
async fn file_list_command(client: &reqwest::Client, args: FileListArgs) -> Result<()> {
|
|
1298
|
+
let mut session = load_session()?;
|
|
1299
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1300
|
+
|
|
1301
|
+
let path = format!("/assets?projectId={}", args.project_id);
|
|
1302
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1303
|
+
if !response.status().is_success() {
|
|
1304
|
+
let body = read_error_body(response).await;
|
|
1305
|
+
return Err(anyhow!("file list failed: {}", body));
|
|
1306
|
+
}
|
|
1307
|
+
let mut payload: ListAssetsResponse = response.json().await?;
|
|
1308
|
+
if let Some(prefix) = args.path {
|
|
1309
|
+
let cleaned = clean_virtual_path(&prefix);
|
|
1310
|
+
if !cleaned.is_empty() {
|
|
1311
|
+
let strict = format!("{}/", cleaned);
|
|
1312
|
+
payload.assets = payload
|
|
1313
|
+
.assets
|
|
1314
|
+
.into_iter()
|
|
1315
|
+
.filter(|asset| asset.file_name == cleaned || asset.file_name.starts_with(&strict))
|
|
1316
|
+
.collect();
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
println!("{}", serde_json::to_string_pretty(&payload.assets)?);
|
|
1320
|
+
save_session(&session)?;
|
|
1321
|
+
Ok(())
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async fn file_get_command(client: &reqwest::Client, args: FileGetArgs) -> Result<()> {
|
|
1325
|
+
let mut session = load_session()?;
|
|
1326
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1327
|
+
|
|
1328
|
+
let path = format!("/assets/{}", args.asset_id);
|
|
1329
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1330
|
+
if !response.status().is_success() {
|
|
1331
|
+
let body = read_error_body(response).await;
|
|
1332
|
+
return Err(anyhow!("file get failed: {}", body));
|
|
1333
|
+
}
|
|
1334
|
+
let payload: AssetResponse = response.json().await?;
|
|
1335
|
+
println!("{}", serde_json::to_string_pretty(&payload.asset)?);
|
|
1336
|
+
save_session(&session)?;
|
|
1337
|
+
Ok(())
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
async fn file_upload_command(client: &reqwest::Client, args: FileUploadArgs) -> Result<()> {
|
|
1341
|
+
let mut session = load_session()?;
|
|
1342
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1343
|
+
|
|
1344
|
+
let bytes = fs::read(&args.source)
|
|
1345
|
+
.with_context(|| format!("Failed to read source file {}", args.source.display()))?;
|
|
1346
|
+
let source_name = args
|
|
1347
|
+
.source
|
|
1348
|
+
.file_name()
|
|
1349
|
+
.and_then(|name| name.to_str())
|
|
1350
|
+
.ok_or_else(|| anyhow!("source file name is invalid"))?;
|
|
1351
|
+
let remote_name = join_remote_path(args.path.as_deref(), source_name);
|
|
1352
|
+
if remote_name.is_empty() {
|
|
1353
|
+
return Err(anyhow!("resolved remote file name is empty"));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
let asset = upload_asset_via_intent(
|
|
1357
|
+
client,
|
|
1358
|
+
&mut session,
|
|
1359
|
+
&args.project_id,
|
|
1360
|
+
&remote_name,
|
|
1361
|
+
bytes,
|
|
1362
|
+
"application/octet-stream",
|
|
1363
|
+
&args.asset_type,
|
|
1364
|
+
&args.visibility,
|
|
1365
|
+
)
|
|
1366
|
+
.await?;
|
|
1367
|
+
println!("{}", serde_json::to_string_pretty(&asset)?);
|
|
1368
|
+
save_session(&session)?;
|
|
1369
|
+
Ok(())
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async fn file_mkdir_command(client: &reqwest::Client, args: FileMkdirArgs) -> Result<()> {
|
|
1373
|
+
let mut session = load_session()?;
|
|
1374
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1375
|
+
|
|
1376
|
+
let folder = clean_virtual_path(&args.path);
|
|
1377
|
+
if folder.is_empty() {
|
|
1378
|
+
return Err(anyhow!("folder path is empty"));
|
|
1379
|
+
}
|
|
1380
|
+
let marker_file = join_remote_path(Some(&folder), ".reallink.keep");
|
|
1381
|
+
let marker_bytes = format!("folder marker {}\n", now_epoch_ms()).into_bytes();
|
|
1382
|
+
let asset = upload_asset_via_intent(
|
|
1383
|
+
client,
|
|
1384
|
+
&mut session,
|
|
1385
|
+
&args.project_id,
|
|
1386
|
+
&marker_file,
|
|
1387
|
+
marker_bytes,
|
|
1388
|
+
"text/plain",
|
|
1389
|
+
"other",
|
|
1390
|
+
"private",
|
|
1391
|
+
)
|
|
1392
|
+
.await?;
|
|
1393
|
+
println!("{}", serde_json::to_string_pretty(&asset)?);
|
|
1394
|
+
save_session(&session)?;
|
|
1395
|
+
Ok(())
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
async fn file_move_command(client: &reqwest::Client, args: FileMoveArgs) -> Result<()> {
|
|
1399
|
+
let mut session = load_session()?;
|
|
1400
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1401
|
+
|
|
1402
|
+
let file_name = clean_virtual_path(&args.file_name);
|
|
1403
|
+
if file_name.is_empty() {
|
|
1404
|
+
return Err(anyhow!("file_name is empty"));
|
|
1405
|
+
}
|
|
1406
|
+
let path = format!("/assets/{}", args.asset_id);
|
|
1407
|
+
let response = authed_request(
|
|
1408
|
+
client,
|
|
1409
|
+
&mut session,
|
|
1410
|
+
Method::PATCH,
|
|
1411
|
+
&path,
|
|
1412
|
+
Some(serde_json::json!({
|
|
1413
|
+
"fileName": file_name
|
|
1414
|
+
})),
|
|
1415
|
+
)
|
|
1416
|
+
.await?;
|
|
1417
|
+
if !response.status().is_success() {
|
|
1418
|
+
let body = read_error_body(response).await;
|
|
1419
|
+
return Err(anyhow!("file move failed: {}", body));
|
|
1420
|
+
}
|
|
1421
|
+
let payload: AssetResponse = response.json().await?;
|
|
1422
|
+
println!("{}", serde_json::to_string_pretty(&payload.asset)?);
|
|
1423
|
+
save_session(&session)?;
|
|
1424
|
+
Ok(())
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
async fn file_remove_command(client: &reqwest::Client, args: FileRemoveArgs) -> Result<()> {
|
|
1428
|
+
let mut session = load_session()?;
|
|
1429
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1430
|
+
|
|
1431
|
+
let path = format!("/assets/{}", args.asset_id);
|
|
1432
|
+
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
1433
|
+
if !response.status().is_success() {
|
|
1434
|
+
let body = read_error_body(response).await;
|
|
1435
|
+
return Err(anyhow!("file remove failed: {}", body));
|
|
1436
|
+
}
|
|
1437
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1438
|
+
println!("{}", serde_json::to_string_pretty(&payload)?);
|
|
1439
|
+
save_session(&session)?;
|
|
1440
|
+
Ok(())
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async fn tool_list_command(client: &reqwest::Client, args: ToolListArgs) -> Result<()> {
|
|
1444
|
+
let mut session = load_session()?;
|
|
1445
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1446
|
+
|
|
1447
|
+
let path = format!(
|
|
1448
|
+
"/tools/definitions?includeInactive={}&includeDisabledChannel={}",
|
|
1449
|
+
args.include_inactive, args.include_disabled_channel
|
|
1450
|
+
);
|
|
1451
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1452
|
+
if !response.status().is_success() {
|
|
1453
|
+
let body = read_error_body(response).await;
|
|
1454
|
+
return Err(anyhow!("tool list failed: {}", body));
|
|
1455
|
+
}
|
|
1456
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1457
|
+
println!(
|
|
1458
|
+
"{}",
|
|
1459
|
+
serde_json::to_string_pretty(payload.get("definitions").unwrap_or(&payload))?
|
|
1460
|
+
);
|
|
1461
|
+
save_session(&session)?;
|
|
1462
|
+
Ok(())
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
async fn tool_register_command(client: &reqwest::Client, args: ToolRegisterArgs) -> Result<()> {
|
|
1466
|
+
let mut session = load_session()?;
|
|
1467
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1468
|
+
|
|
1469
|
+
let manifest = load_jsonc_file(&args.manifest, "tool manifest")?;
|
|
1470
|
+
let body = serde_json::Value::Object(parse_object_from_value(
|
|
1471
|
+
manifest,
|
|
1472
|
+
"tool manifest payload",
|
|
1473
|
+
)?);
|
|
1474
|
+
let response =
|
|
1475
|
+
authed_request(client, &mut session, Method::POST, "/tools/definitions", Some(body)).await?;
|
|
1476
|
+
if !response.status().is_success() {
|
|
1477
|
+
let body = read_error_body(response).await;
|
|
1478
|
+
return Err(anyhow!("tool register failed: {}", body));
|
|
1479
|
+
}
|
|
1480
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1481
|
+
println!(
|
|
1482
|
+
"{}",
|
|
1483
|
+
serde_json::to_string_pretty(payload.get("definition").unwrap_or(&payload))?
|
|
1484
|
+
);
|
|
1485
|
+
save_session(&session)?;
|
|
1486
|
+
Ok(())
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
async fn tool_set_entitlement_command(
|
|
1490
|
+
client: &reqwest::Client,
|
|
1491
|
+
mut session: SessionConfig,
|
|
1492
|
+
tool_id: String,
|
|
1493
|
+
org_id: Option<String>,
|
|
1494
|
+
project_id: Option<String>,
|
|
1495
|
+
user_id: Option<String>,
|
|
1496
|
+
status: &str,
|
|
1497
|
+
expires_at: Option<String>,
|
|
1498
|
+
metadata_file: Option<PathBuf>,
|
|
1499
|
+
) -> Result<()> {
|
|
1500
|
+
let scoped_count =
|
|
1501
|
+
(org_id.is_some() as u8) + (project_id.is_some() as u8) + (user_id.is_some() as u8);
|
|
1502
|
+
if scoped_count > 1 {
|
|
1503
|
+
return Err(anyhow!(
|
|
1504
|
+
"Only one of --org-id, --project-id, or --user-id can be set"
|
|
1505
|
+
));
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
let mut body = serde_json::Map::new();
|
|
1509
|
+
body.insert("toolId".to_string(), serde_json::Value::String(tool_id));
|
|
1510
|
+
body.insert(
|
|
1511
|
+
"status".to_string(),
|
|
1512
|
+
serde_json::Value::String(status.to_string()),
|
|
1513
|
+
);
|
|
1514
|
+
if let Some(org_id) = org_id {
|
|
1515
|
+
body.insert("orgId".to_string(), serde_json::Value::String(org_id));
|
|
1516
|
+
}
|
|
1517
|
+
if let Some(project_id) = project_id {
|
|
1518
|
+
body.insert("projectId".to_string(), serde_json::Value::String(project_id));
|
|
1519
|
+
}
|
|
1520
|
+
if let Some(user_id) = user_id {
|
|
1521
|
+
body.insert("userId".to_string(), serde_json::Value::String(user_id));
|
|
1522
|
+
}
|
|
1523
|
+
if let Some(expires_at) = expires_at {
|
|
1524
|
+
body.insert("expiresAt".to_string(), serde_json::Value::String(expires_at));
|
|
1525
|
+
}
|
|
1526
|
+
if let Some(path) = metadata_file {
|
|
1527
|
+
let metadata = load_jsonc_file(&path, "tool entitlement metadata")?;
|
|
1528
|
+
body.insert(
|
|
1529
|
+
"metadata".to_string(),
|
|
1530
|
+
serde_json::Value::Object(parse_object_from_value(
|
|
1531
|
+
metadata,
|
|
1532
|
+
"tool entitlement metadata",
|
|
1533
|
+
)?),
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
let response = authed_request(
|
|
1538
|
+
client,
|
|
1539
|
+
&mut session,
|
|
1540
|
+
Method::PUT,
|
|
1541
|
+
"/tools/entitlements",
|
|
1542
|
+
Some(serde_json::Value::Object(body)),
|
|
1543
|
+
)
|
|
1544
|
+
.await?;
|
|
1545
|
+
if !response.status().is_success() {
|
|
1546
|
+
let body = read_error_body(response).await;
|
|
1547
|
+
return Err(anyhow!("tool entitlement update failed: {}", body));
|
|
1548
|
+
}
|
|
1549
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1550
|
+
println!(
|
|
1551
|
+
"{}",
|
|
1552
|
+
serde_json::to_string_pretty(payload.get("entitlement").unwrap_or(&payload))?
|
|
1553
|
+
);
|
|
1554
|
+
save_session(&session)?;
|
|
1555
|
+
Ok(())
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
async fn tool_enable_command(client: &reqwest::Client, args: ToolEnableArgs) -> Result<()> {
|
|
1559
|
+
let mut session = load_session()?;
|
|
1560
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1561
|
+
|
|
1562
|
+
tool_set_entitlement_command(
|
|
1563
|
+
client,
|
|
1564
|
+
session,
|
|
1565
|
+
args.tool_id,
|
|
1566
|
+
args.org_id,
|
|
1567
|
+
args.project_id,
|
|
1568
|
+
args.user_id,
|
|
1569
|
+
"enabled",
|
|
1570
|
+
args.expires_at,
|
|
1571
|
+
args.metadata_file,
|
|
1572
|
+
)
|
|
1573
|
+
.await
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
async fn tool_disable_command(client: &reqwest::Client, args: ToolDisableArgs) -> Result<()> {
|
|
1577
|
+
let mut session = load_session()?;
|
|
1578
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1579
|
+
|
|
1580
|
+
tool_set_entitlement_command(
|
|
1581
|
+
client,
|
|
1582
|
+
session,
|
|
1583
|
+
args.tool_id,
|
|
1584
|
+
args.org_id,
|
|
1585
|
+
args.project_id,
|
|
1586
|
+
args.user_id,
|
|
1587
|
+
"disabled",
|
|
1588
|
+
None,
|
|
1589
|
+
args.metadata_file,
|
|
1590
|
+
)
|
|
1591
|
+
.await
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async fn tool_run_command(client: &reqwest::Client, args: ToolRunArgs) -> Result<()> {
|
|
1595
|
+
let mut session = load_session()?;
|
|
1596
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1597
|
+
|
|
1598
|
+
if args.input_json.is_some() && args.input_file.is_some() {
|
|
1599
|
+
return Err(anyhow!(
|
|
1600
|
+
"Provide either --input-json or --input-file, not both"
|
|
1601
|
+
));
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
let input_value = if let Some(path) = args.input_file {
|
|
1605
|
+
load_jsonc_file(&path, "tool run input")?
|
|
1606
|
+
} else if let Some(input_json) = args.input_json {
|
|
1607
|
+
parse_jsonc_str(&input_json, "tool run input")?
|
|
1608
|
+
} else {
|
|
1609
|
+
serde_json::Value::Object(serde_json::Map::new())
|
|
1610
|
+
};
|
|
1611
|
+
|
|
1612
|
+
let input_object = serde_json::Value::Object(parse_object_from_value(
|
|
1613
|
+
input_value,
|
|
1614
|
+
"tool run input",
|
|
1615
|
+
)?);
|
|
1616
|
+
|
|
1617
|
+
let mut body = serde_json::Map::new();
|
|
1618
|
+
body.insert("toolId".to_string(), serde_json::Value::String(args.tool_id));
|
|
1619
|
+
body.insert("input".to_string(), input_object);
|
|
1620
|
+
if let Some(org_id) = args.org_id {
|
|
1621
|
+
body.insert("orgId".to_string(), serde_json::Value::String(org_id));
|
|
1622
|
+
}
|
|
1623
|
+
if let Some(project_id) = args.project_id {
|
|
1624
|
+
body.insert("projectId".to_string(), serde_json::Value::String(project_id));
|
|
1625
|
+
}
|
|
1626
|
+
let mut metadata_map = if let Some(path) = args.metadata_file {
|
|
1627
|
+
let metadata = load_jsonc_file(&path, "tool run metadata")?;
|
|
1628
|
+
parse_object_from_value(metadata, "tool run metadata")?
|
|
1629
|
+
} else {
|
|
1630
|
+
serde_json::Map::new()
|
|
1631
|
+
};
|
|
1632
|
+
if let Some(idempotency_key) = args.idempotency_key {
|
|
1633
|
+
let normalized = idempotency_key.trim();
|
|
1634
|
+
if !normalized.is_empty() {
|
|
1635
|
+
metadata_map.insert(
|
|
1636
|
+
"idempotencyKey".to_string(),
|
|
1637
|
+
serde_json::Value::String(normalized.to_string()),
|
|
1638
|
+
);
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
if !metadata_map.is_empty() {
|
|
1642
|
+
body.insert("metadata".to_string(), serde_json::Value::Object(metadata_map));
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
let response = authed_request(
|
|
1646
|
+
client,
|
|
1647
|
+
&mut session,
|
|
1648
|
+
Method::POST,
|
|
1649
|
+
"/tools/runs",
|
|
1650
|
+
Some(serde_json::Value::Object(body)),
|
|
1651
|
+
)
|
|
1652
|
+
.await?;
|
|
1653
|
+
if !response.status().is_success() {
|
|
1654
|
+
let body = read_error_body(response).await;
|
|
1655
|
+
return Err(anyhow!("tool run failed: {}", body));
|
|
1656
|
+
}
|
|
1657
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1658
|
+
println!(
|
|
1659
|
+
"{}",
|
|
1660
|
+
serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
|
|
1661
|
+
);
|
|
1662
|
+
save_session(&session)?;
|
|
1663
|
+
Ok(())
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
async fn tool_runs_command(client: &reqwest::Client, args: ToolRunsArgs) -> Result<()> {
|
|
1667
|
+
let mut session = load_session()?;
|
|
1668
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1669
|
+
|
|
1670
|
+
let mut query_parts: Vec<String> = Vec::new();
|
|
1671
|
+
if let Some(tool_id) = args.tool_id {
|
|
1672
|
+
query_parts.push(format!("toolId={}", tool_id));
|
|
1673
|
+
}
|
|
1674
|
+
if let Some(project_id) = args.project_id {
|
|
1675
|
+
query_parts.push(format!("projectId={}", project_id));
|
|
1676
|
+
}
|
|
1677
|
+
if let Some(requested_by_user_id) = args.requested_by_user_id {
|
|
1678
|
+
query_parts.push(format!("requestedByUserId={}", requested_by_user_id));
|
|
1679
|
+
}
|
|
1680
|
+
if let Some(status) = args.status {
|
|
1681
|
+
query_parts.push(format!("status={}", status));
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
let path = if query_parts.is_empty() {
|
|
1685
|
+
"/tools/runs".to_string()
|
|
1686
|
+
} else {
|
|
1687
|
+
format!("/tools/runs?{}", query_parts.join("&"))
|
|
1688
|
+
};
|
|
1689
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1690
|
+
if !response.status().is_success() {
|
|
1691
|
+
let body = read_error_body(response).await;
|
|
1692
|
+
return Err(anyhow!("tool runs failed: {}", body));
|
|
1693
|
+
}
|
|
1694
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1695
|
+
println!(
|
|
1696
|
+
"{}",
|
|
1697
|
+
serde_json::to_string_pretty(payload.get("runs").unwrap_or(&payload))?
|
|
1698
|
+
);
|
|
1699
|
+
save_session(&session)?;
|
|
1700
|
+
Ok(())
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
async fn tool_get_run_command(client: &reqwest::Client, args: ToolGetRunArgs) -> Result<()> {
|
|
1704
|
+
let mut session = load_session()?;
|
|
1705
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
1706
|
+
|
|
1707
|
+
let path = format!("/tools/runs/{}", args.run_id);
|
|
1708
|
+
let response = authed_request(client, &mut session, Method::GET, &path, None).await?;
|
|
1709
|
+
if !response.status().is_success() {
|
|
1710
|
+
let body = read_error_body(response).await;
|
|
1711
|
+
return Err(anyhow!("tool get-run failed: {}", body));
|
|
1712
|
+
}
|
|
1713
|
+
let payload: serde_json::Value = response.json().await?;
|
|
1714
|
+
println!(
|
|
1715
|
+
"{}",
|
|
1716
|
+
serde_json::to_string_pretty(payload.get("run").unwrap_or(&payload))?
|
|
1717
|
+
);
|
|
1718
|
+
save_session(&session)?;
|
|
1719
|
+
Ok(())
|
|
1720
|
+
}
|
|
1721
|
+
|
|
470
1722
|
#[tokio::main]
|
|
471
1723
|
async fn main() -> Result<()> {
|
|
472
1724
|
let cli = Cli::parse();
|
|
473
1725
|
let client = reqwest::Client::builder()
|
|
474
|
-
.user_agent("reallink-cli/
|
|
1726
|
+
.user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
|
|
475
1727
|
.build()?;
|
|
476
1728
|
|
|
477
1729
|
match cli.command {
|
|
478
1730
|
Commands::Login(args) => login_command(&client, args).await?,
|
|
479
1731
|
Commands::Whoami(args) => whoami_command(&client, args).await?,
|
|
480
|
-
Commands::Logout =>
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
1732
|
+
Commands::Logout => logout_command(&client).await?,
|
|
1733
|
+
Commands::Project { command } => match command {
|
|
1734
|
+
ProjectCommands::List(args) => project_list_command(&client, args).await?,
|
|
1735
|
+
ProjectCommands::Create(args) => project_create_command(&client, args).await?,
|
|
1736
|
+
ProjectCommands::Get(args) => project_get_command(&client, args).await?,
|
|
1737
|
+
ProjectCommands::Update(args) => project_update_command(&client, args).await?,
|
|
1738
|
+
ProjectCommands::Delete(args) => project_delete_command(&client, args).await?,
|
|
1739
|
+
},
|
|
484
1740
|
Commands::Token { command } => match command {
|
|
485
1741
|
TokenCommands::List(args) => token_list_command(&client, args).await?,
|
|
486
1742
|
TokenCommands::Create(args) => token_create_command(&client, args).await?,
|
|
487
1743
|
TokenCommands::Revoke(args) => token_revoke_command(&client, args).await?,
|
|
488
1744
|
},
|
|
1745
|
+
Commands::File { command } => match command {
|
|
1746
|
+
FileCommands::List(args) => file_list_command(&client, args).await?,
|
|
1747
|
+
FileCommands::Get(args) => file_get_command(&client, args).await?,
|
|
1748
|
+
FileCommands::Upload(args) => file_upload_command(&client, args).await?,
|
|
1749
|
+
FileCommands::Mkdir(args) => file_mkdir_command(&client, args).await?,
|
|
1750
|
+
FileCommands::Move(args) => file_move_command(&client, args).await?,
|
|
1751
|
+
FileCommands::Remove(args) => file_remove_command(&client, args).await?,
|
|
1752
|
+
},
|
|
1753
|
+
Commands::Tool { command } => match command {
|
|
1754
|
+
ToolCommands::List(args) => tool_list_command(&client, args).await?,
|
|
1755
|
+
ToolCommands::Register(args) => tool_register_command(&client, args).await?,
|
|
1756
|
+
ToolCommands::Enable(args) => tool_enable_command(&client, args).await?,
|
|
1757
|
+
ToolCommands::Disable(args) => tool_disable_command(&client, args).await?,
|
|
1758
|
+
ToolCommands::Run(args) => tool_run_command(&client, args).await?,
|
|
1759
|
+
ToolCommands::Runs(args) => tool_runs_command(&client, args).await?,
|
|
1760
|
+
ToolCommands::GetRun(args) => tool_get_run_command(&client, args).await?,
|
|
1761
|
+
},
|
|
489
1762
|
}
|
|
490
1763
|
|
|
491
1764
|
Ok(())
|