reallink-cli 0.1.1 → 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 +133 -1
- package/rust/Cargo.toml +2 -1
- package/rust/src/main.rs +1294 -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(),
|
|
@@ -331,9 +825,14 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
331
825
|
|
|
332
826
|
let expires_at = std::time::Instant::now() + Duration::from_secs(device_code.expires_in);
|
|
333
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)");
|
|
334
830
|
|
|
335
831
|
loop {
|
|
336
832
|
if std::time::Instant::now() >= expires_at {
|
|
833
|
+
if pending_polls > 0 {
|
|
834
|
+
println!();
|
|
835
|
+
}
|
|
337
836
|
return Err(anyhow!("Device code expired before approval"));
|
|
338
837
|
}
|
|
339
838
|
|
|
@@ -351,6 +850,9 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
351
850
|
|
|
352
851
|
if token_response.status().is_success() {
|
|
353
852
|
let tokens: DeviceTokenSuccess = token_response.json().await?;
|
|
853
|
+
if pending_polls > 0 {
|
|
854
|
+
println!();
|
|
855
|
+
}
|
|
354
856
|
let session = SessionConfig {
|
|
355
857
|
base_url: base_url.clone(),
|
|
356
858
|
access_token: tokens.access_token,
|
|
@@ -360,6 +862,7 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
360
862
|
};
|
|
361
863
|
save_session(&session)?;
|
|
362
864
|
println!("Login successful.");
|
|
865
|
+
println!("Session stored at {}", session_path_display());
|
|
363
866
|
return Ok(());
|
|
364
867
|
}
|
|
365
868
|
|
|
@@ -372,12 +875,23 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
372
875
|
});
|
|
373
876
|
|
|
374
877
|
match error_payload.error.as_str() {
|
|
375
|
-
"authorization_pending" =>
|
|
878
|
+
"authorization_pending" => {
|
|
879
|
+
pending_polls = pending_polls.saturating_add(1);
|
|
880
|
+
print!(".");
|
|
881
|
+
let _ = io::stdout().flush();
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
376
884
|
"slow_down" => {
|
|
377
885
|
poll_interval += Duration::from_secs(1);
|
|
886
|
+
pending_polls = pending_polls.saturating_add(1);
|
|
887
|
+
print!("+");
|
|
888
|
+
let _ = io::stdout().flush();
|
|
378
889
|
continue;
|
|
379
890
|
}
|
|
380
891
|
_ => {
|
|
892
|
+
if pending_polls > 0 {
|
|
893
|
+
println!();
|
|
894
|
+
}
|
|
381
895
|
return Err(anyhow!(
|
|
382
896
|
"Device login failed: {} ({})",
|
|
383
897
|
error_payload.error,
|
|
@@ -388,11 +902,56 @@ async fn login_command(client: &reqwest::Client, args: LoginArgs) -> Result<()>
|
|
|
388
902
|
}
|
|
389
903
|
}
|
|
390
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
|
+
|
|
391
952
|
async fn whoami_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
|
|
392
953
|
let mut session = load_session()?;
|
|
393
|
-
|
|
394
|
-
session.base_url = normalize_base_url(&base_url);
|
|
395
|
-
}
|
|
954
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
396
955
|
|
|
397
956
|
let response = authed_request(client, &mut session, Method::GET, "/auth/me", None).await?;
|
|
398
957
|
if !response.status().is_success() {
|
|
@@ -407,9 +966,7 @@ async fn whoami_command(client: &reqwest::Client, args: BaseArgs) -> Result<()>
|
|
|
407
966
|
|
|
408
967
|
async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<()> {
|
|
409
968
|
let mut session = load_session()?;
|
|
410
|
-
|
|
411
|
-
session.base_url = normalize_base_url(&base_url);
|
|
412
|
-
}
|
|
969
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
413
970
|
|
|
414
971
|
let response = authed_request(client, &mut session, Method::GET, "/auth/tokens", None).await?;
|
|
415
972
|
if !response.status().is_success() {
|
|
@@ -424,9 +981,7 @@ async fn token_list_command(client: &reqwest::Client, args: BaseArgs) -> Result<
|
|
|
424
981
|
|
|
425
982
|
async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -> Result<()> {
|
|
426
983
|
let mut session = load_session()?;
|
|
427
|
-
|
|
428
|
-
session.base_url = normalize_base_url(&base_url);
|
|
429
|
-
}
|
|
984
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
430
985
|
|
|
431
986
|
let scopes = if args.scope.is_empty() {
|
|
432
987
|
return Err(anyhow!("At least one --scope must be provided"));
|
|
@@ -455,9 +1010,7 @@ async fn token_create_command(client: &reqwest::Client, args: TokenCreateArgs) -
|
|
|
455
1010
|
|
|
456
1011
|
async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -> Result<()> {
|
|
457
1012
|
let mut session = load_session()?;
|
|
458
|
-
|
|
459
|
-
session.base_url = normalize_base_url(&base_url);
|
|
460
|
-
}
|
|
1013
|
+
apply_base_url_override(&mut session, args.base_url);
|
|
461
1014
|
|
|
462
1015
|
let path = format!("/auth/tokens/{}", args.token_id);
|
|
463
1016
|
let response = authed_request(client, &mut session, Method::DELETE, &path, None).await?;
|
|
@@ -471,25 +1024,741 @@ async fn token_revoke_command(client: &reqwest::Client, args: TokenRevokeArgs) -
|
|
|
471
1024
|
Ok(())
|
|
472
1025
|
}
|
|
473
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
|
+
|
|
474
1722
|
#[tokio::main]
|
|
475
1723
|
async fn main() -> Result<()> {
|
|
476
1724
|
let cli = Cli::parse();
|
|
477
1725
|
let client = reqwest::Client::builder()
|
|
478
|
-
.user_agent("reallink-cli/
|
|
1726
|
+
.user_agent(concat!("reallink-cli/", env!("CARGO_PKG_VERSION")))
|
|
479
1727
|
.build()?;
|
|
480
1728
|
|
|
481
1729
|
match cli.command {
|
|
482
1730
|
Commands::Login(args) => login_command(&client, args).await?,
|
|
483
1731
|
Commands::Whoami(args) => whoami_command(&client, args).await?,
|
|
484
|
-
Commands::Logout =>
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
+
},
|
|
488
1740
|
Commands::Token { command } => match command {
|
|
489
1741
|
TokenCommands::List(args) => token_list_command(&client, args).await?,
|
|
490
1742
|
TokenCommands::Create(args) => token_create_command(&client, args).await?,
|
|
491
1743
|
TokenCommands::Revoke(args) => token_revoke_command(&client, args).await?,
|
|
492
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
|
+
},
|
|
493
1762
|
}
|
|
494
1763
|
|
|
495
1764
|
Ok(())
|