reallink-cli 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/package.json +1 -1
- package/prebuilt/win32-x64/reallink-cli.exe +0 -0
- package/rust/Cargo.lock +1 -1
- package/rust/Cargo.toml +1 -1
- package/rust/src/generated/contract.rs +1 -1
- package/rust/src/logs.rs +277 -0
- package/rust/src/main.rs +1312 -104
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ reallink token create --name "ci-token" --scope trace:read --scope trace:write
|
|
|
53
53
|
reallink token revoke --token-id tok_xxx
|
|
54
54
|
|
|
55
55
|
reallink file list --project-id prj_xxx
|
|
56
|
+
reallink file tree --project-id prj_xxx
|
|
56
57
|
reallink file get --asset-id ast_xxx
|
|
57
58
|
reallink file stat --asset-id ast_xxx
|
|
58
59
|
reallink file download --asset-id ast_xxx --output ./downloads/report.json
|
|
@@ -60,15 +61,25 @@ reallink file download --asset-id ast_xxx --output ./downloads/report.json --res
|
|
|
60
61
|
reallink file upload --project-id prj_xxx --source ./local/report.json --path reports/daily
|
|
61
62
|
reallink file mkdir --project-id prj_xxx --path reports/archive
|
|
62
63
|
reallink file move --asset-id ast_xxx --file-name reports/archive/report.json
|
|
64
|
+
reallink file move-folder --project-id prj_xxx --source-path reports/archive --target-path reports/2026
|
|
63
65
|
reallink file remove --asset-id ast_xxx
|
|
64
66
|
|
|
67
|
+
reallink logs status
|
|
68
|
+
reallink logs consent --enable
|
|
69
|
+
reallink logs tail --lines 120
|
|
70
|
+
reallink logs upload --project-id prj_xxx --dry-run
|
|
71
|
+
reallink logs upload --project-id prj_xxx --clear-on-success
|
|
72
|
+
|
|
65
73
|
reallink tool list
|
|
66
74
|
reallink tool register --manifest ./spec/tools/trace-placeholder.tool.jsonc
|
|
75
|
+
reallink tool publish --tool-id trace.placeholder --channel ga --visibility public
|
|
67
76
|
reallink tool enable --tool-id trace.placeholder --project-id prj_xxx
|
|
68
77
|
reallink tool enable --tool-id trace.placeholder --user-id usr_xxx
|
|
69
78
|
reallink tool run --tool-id trace.placeholder --project-id prj_xxx --input-file ./run-input.jsonc --idempotency-key run-001
|
|
70
79
|
reallink tool runs --project-id prj_xxx
|
|
71
80
|
reallink tool get-run --run-id trun_xxx
|
|
81
|
+
reallink tool run-events --run-id trun_xxx --limit 200
|
|
82
|
+
reallink tool run-events --run-id trun_xxx --stage-prefix workflow. --status succeeded
|
|
72
83
|
|
|
73
84
|
reallink link unreal --project-id prj_xxx --uproject "D:/Games/MyGame/MyGame.uproject" --engine-root "D:/Epic/UE_5.4" --set-default
|
|
74
85
|
reallink link list
|
|
@@ -100,9 +111,24 @@ reallink link plugin install --project-id prj_xxx --name RealLinkUnreal --url ht
|
|
|
100
111
|
- Login stores a local session at:
|
|
101
112
|
- Windows: `%APPDATA%\\reallink\\session.json`
|
|
102
113
|
- Linux/macOS: `${XDG_CONFIG_HOME:-~/.config}/reallink/session.json`
|
|
114
|
+
- Access token TTL: 15 minutes (auto-refreshed by CLI on 401).
|
|
115
|
+
- Refresh/session TTL: 30 days.
|
|
103
116
|
- You only need to log in again when the saved session is invalid/expired or you explicitly run `reallink logout`.
|
|
104
117
|
- `reallink logout` revokes the current server session (`/auth/logout`) and clears the local session file.
|
|
105
118
|
|
|
119
|
+
## CLI Logs & Crash Reports
|
|
120
|
+
|
|
121
|
+
- Local logs are written under:
|
|
122
|
+
- Windows: `%APPDATA%\\reallink\\logs\\`
|
|
123
|
+
- Linux/macOS: `${XDG_CONFIG_HOME:-~/.config}/reallink/logs/`
|
|
124
|
+
- Runtime log file: `runtime.jsonl`
|
|
125
|
+
- Crash reports: `crash/crash-<epoch>.json`
|
|
126
|
+
- Uploading logs requires explicit consent:
|
|
127
|
+
- `reallink logs consent --enable`
|
|
128
|
+
- Upload destination path in project storage:
|
|
129
|
+
- `.reallink/logs/cli/runtime/*`
|
|
130
|
+
- `.reallink/logs/cli/crash/*`
|
|
131
|
+
|
|
106
132
|
## Unreal Link Architecture
|
|
107
133
|
|
|
108
134
|
`reallink link ...` provides a Vercel-style "link local project to remote project" workflow for local Unreal development.
|
package/package.json
CHANGED
|
Binary file
|
package/rust/Cargo.lock
CHANGED
package/rust/Cargo.toml
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Do not edit by hand. Update packages/shared/src/api-contract.ts and re-run codegen.
|
|
3
3
|
#![allow(dead_code)]
|
|
4
4
|
|
|
5
|
-
pub const API_CONTRACT_VERSION: &str = "2026.03.
|
|
5
|
+
pub const API_CONTRACT_VERSION: &str = "2026.03.1";
|
|
6
6
|
pub const API_TOKEN_SCOPES: &[&str] = &[
|
|
7
7
|
"core:read",
|
|
8
8
|
"core:write",
|
package/rust/src/logs.rs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use serde::{Deserialize, Serialize};
|
|
3
|
+
use std::collections::VecDeque;
|
|
4
|
+
use std::fs::{self, OpenOptions};
|
|
5
|
+
use std::io::Write;
|
|
6
|
+
use std::path::{Path, PathBuf};
|
|
7
|
+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
8
|
+
|
|
9
|
+
const LOGS_DIR: &str = "logs";
|
|
10
|
+
const CRASH_DIR: &str = "crash";
|
|
11
|
+
const CONSENT_FILE: &str = "consent.json";
|
|
12
|
+
const RUNTIME_LOG_FILE: &str = "runtime.jsonl";
|
|
13
|
+
|
|
14
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
15
|
+
#[serde(rename_all = "camelCase")]
|
|
16
|
+
pub struct LogConsentConfig {
|
|
17
|
+
pub upload_enabled: bool,
|
|
18
|
+
pub asked_at_epoch_ms: Option<u128>,
|
|
19
|
+
pub updated_at_epoch_ms: u128,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl Default for LogConsentConfig {
|
|
23
|
+
fn default() -> Self {
|
|
24
|
+
Self {
|
|
25
|
+
upload_enabled: false,
|
|
26
|
+
asked_at_epoch_ms: None,
|
|
27
|
+
updated_at_epoch_ms: now_epoch_ms(),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
33
|
+
#[serde(rename_all = "camelCase")]
|
|
34
|
+
pub struct RuntimeLogEvent {
|
|
35
|
+
pub ts_epoch_ms: u128,
|
|
36
|
+
pub level: String,
|
|
37
|
+
pub command: String,
|
|
38
|
+
pub message: String,
|
|
39
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
40
|
+
pub duration_ms: Option<u128>,
|
|
41
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
42
|
+
pub exit_code: Option<i32>,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
46
|
+
#[serde(rename_all = "camelCase")]
|
|
47
|
+
pub struct CrashReport {
|
|
48
|
+
pub ts_epoch_ms: u128,
|
|
49
|
+
pub command: String,
|
|
50
|
+
pub message: String,
|
|
51
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
52
|
+
pub detail: Option<String>,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[derive(Debug, Clone, Serialize)]
|
|
56
|
+
#[serde(rename_all = "camelCase")]
|
|
57
|
+
pub struct LogUploadCandidate {
|
|
58
|
+
pub local_path: PathBuf,
|
|
59
|
+
pub remote_path: String,
|
|
60
|
+
pub content_type: String,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#[derive(Debug, Clone, Serialize)]
|
|
64
|
+
#[serde(rename_all = "camelCase")]
|
|
65
|
+
pub struct LogStatus {
|
|
66
|
+
pub logs_root: String,
|
|
67
|
+
pub runtime_log_path: String,
|
|
68
|
+
pub crash_dir: String,
|
|
69
|
+
pub consent_file_path: String,
|
|
70
|
+
pub consent: LogConsentConfig,
|
|
71
|
+
pub runtime_exists: bool,
|
|
72
|
+
pub crash_report_count: usize,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
fn now_epoch_ms() -> u128 {
|
|
76
|
+
SystemTime::now()
|
|
77
|
+
.duration_since(UNIX_EPOCH)
|
|
78
|
+
.unwrap_or_else(|_| Duration::from_secs(0))
|
|
79
|
+
.as_millis()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
fn write_atomic(path: &Path, payload: &[u8]) -> Result<()> {
|
|
83
|
+
let temp_path = path.with_extension(format!("tmp.{}", now_epoch_ms()));
|
|
84
|
+
fs::write(&temp_path, payload)
|
|
85
|
+
.with_context(|| format!("Failed to write temporary file {}", temp_path.display()))?;
|
|
86
|
+
if path.exists() {
|
|
87
|
+
fs::remove_file(path).with_context(|| format!("Failed to replace {}", path.display()))?;
|
|
88
|
+
}
|
|
89
|
+
fs::rename(&temp_path, path).with_context(|| {
|
|
90
|
+
format!(
|
|
91
|
+
"Failed to move temporary file {} to {}",
|
|
92
|
+
temp_path.display(),
|
|
93
|
+
path.display()
|
|
94
|
+
)
|
|
95
|
+
})?;
|
|
96
|
+
Ok(())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fn logs_root(state_root: &Path) -> PathBuf {
|
|
100
|
+
state_root.join(LOGS_DIR)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn crash_dir(state_root: &Path) -> PathBuf {
|
|
104
|
+
logs_root(state_root).join(CRASH_DIR)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fn consent_path(state_root: &Path) -> PathBuf {
|
|
108
|
+
logs_root(state_root).join(CONSENT_FILE)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fn runtime_log_path(state_root: &Path) -> PathBuf {
|
|
112
|
+
logs_root(state_root).join(RUNTIME_LOG_FILE)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn ensure_logs_dirs(state_root: &Path) -> Result<()> {
|
|
116
|
+
fs::create_dir_all(logs_root(state_root))
|
|
117
|
+
.with_context(|| format!("Failed to create {}", logs_root(state_root).display()))?;
|
|
118
|
+
fs::create_dir_all(crash_dir(state_root))
|
|
119
|
+
.with_context(|| format!("Failed to create {}", crash_dir(state_root).display()))?;
|
|
120
|
+
Ok(())
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pub fn load_consent(state_root: &Path) -> Result<LogConsentConfig> {
|
|
124
|
+
let path = consent_path(state_root);
|
|
125
|
+
if !path.exists() {
|
|
126
|
+
return Ok(LogConsentConfig::default());
|
|
127
|
+
}
|
|
128
|
+
let raw =
|
|
129
|
+
fs::read(&path).with_context(|| format!("Failed to read consent file {}", path.display()))?;
|
|
130
|
+
let parsed: LogConsentConfig = serde_json::from_slice(&raw)
|
|
131
|
+
.with_context(|| format!("Invalid consent file {}", path.display()))?;
|
|
132
|
+
Ok(parsed)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
pub fn set_upload_consent(state_root: &Path, enabled: bool) -> Result<LogConsentConfig> {
|
|
136
|
+
ensure_logs_dirs(state_root)?;
|
|
137
|
+
let mut current = load_consent(state_root)?;
|
|
138
|
+
let now = now_epoch_ms();
|
|
139
|
+
current.upload_enabled = enabled;
|
|
140
|
+
current.updated_at_epoch_ms = now;
|
|
141
|
+
if current.asked_at_epoch_ms.is_none() {
|
|
142
|
+
current.asked_at_epoch_ms = Some(now);
|
|
143
|
+
}
|
|
144
|
+
save_consent(state_root, ¤t)?;
|
|
145
|
+
Ok(current)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
pub fn save_consent(state_root: &Path, config: &LogConsentConfig) -> Result<()> {
|
|
149
|
+
ensure_logs_dirs(state_root)?;
|
|
150
|
+
let path = consent_path(state_root);
|
|
151
|
+
let payload = serde_json::to_vec_pretty(config)?;
|
|
152
|
+
write_atomic(&path, &payload)?;
|
|
153
|
+
Ok(())
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pub fn append_runtime_event(state_root: &Path, event: &RuntimeLogEvent) -> Result<()> {
|
|
157
|
+
ensure_logs_dirs(state_root)?;
|
|
158
|
+
let path = runtime_log_path(state_root);
|
|
159
|
+
let mut writer = OpenOptions::new()
|
|
160
|
+
.create(true)
|
|
161
|
+
.append(true)
|
|
162
|
+
.open(&path)
|
|
163
|
+
.with_context(|| format!("Failed to open runtime log {}", path.display()))?;
|
|
164
|
+
let line = serde_json::to_string(event)?;
|
|
165
|
+
writer
|
|
166
|
+
.write_all(format!("{}\n", line).as_bytes())
|
|
167
|
+
.with_context(|| format!("Failed to write runtime log {}", path.display()))?;
|
|
168
|
+
Ok(())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
pub fn write_crash_report(state_root: &Path, report: &CrashReport) -> Result<PathBuf> {
|
|
172
|
+
ensure_logs_dirs(state_root)?;
|
|
173
|
+
let file_name = format!("crash-{}.json", report.ts_epoch_ms);
|
|
174
|
+
let path = crash_dir(state_root).join(file_name);
|
|
175
|
+
let payload = serde_json::to_vec_pretty(report)?;
|
|
176
|
+
write_atomic(&path, &payload)?;
|
|
177
|
+
Ok(path)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
fn file_name_string(path: &Path) -> Option<String> {
|
|
181
|
+
path.file_name()
|
|
182
|
+
.and_then(|value| value.to_str())
|
|
183
|
+
.map(|value| value.to_string())
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn content_type_for_path(path: &Path) -> String {
|
|
187
|
+
match path.extension().and_then(|value| value.to_str()) {
|
|
188
|
+
Some("json") => "application/json".to_string(),
|
|
189
|
+
Some("jsonl") => "application/x-ndjson".to_string(),
|
|
190
|
+
Some("log") | Some("txt") => "text/plain".to_string(),
|
|
191
|
+
_ => "application/octet-stream".to_string(),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
pub fn list_upload_candidates(
|
|
196
|
+
state_root: &Path,
|
|
197
|
+
include_runtime: bool,
|
|
198
|
+
include_crash: bool,
|
|
199
|
+
) -> Result<Vec<LogUploadCandidate>> {
|
|
200
|
+
ensure_logs_dirs(state_root)?;
|
|
201
|
+
let mut output = Vec::new();
|
|
202
|
+
|
|
203
|
+
if include_runtime {
|
|
204
|
+
let runtime_path = runtime_log_path(state_root);
|
|
205
|
+
if runtime_path.exists() && runtime_path.is_file() {
|
|
206
|
+
if let Some(file_name) = file_name_string(&runtime_path) {
|
|
207
|
+
output.push(LogUploadCandidate {
|
|
208
|
+
local_path: runtime_path.clone(),
|
|
209
|
+
remote_path: format!(".reallink/logs/cli/runtime/{}", file_name),
|
|
210
|
+
content_type: content_type_for_path(&runtime_path),
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if include_crash {
|
|
217
|
+
let mut crash_files: Vec<PathBuf> = fs::read_dir(crash_dir(state_root))
|
|
218
|
+
.with_context(|| format!("Failed to read {}", crash_dir(state_root).display()))?
|
|
219
|
+
.filter_map(|entry| entry.ok().map(|item| item.path()))
|
|
220
|
+
.filter(|path| path.is_file())
|
|
221
|
+
.collect();
|
|
222
|
+
crash_files.sort();
|
|
223
|
+
for path in crash_files {
|
|
224
|
+
if let Some(file_name) = file_name_string(&path) {
|
|
225
|
+
output.push(LogUploadCandidate {
|
|
226
|
+
local_path: path.clone(),
|
|
227
|
+
remote_path: format!(".reallink/logs/cli/crash/{}", file_name),
|
|
228
|
+
content_type: content_type_for_path(&path),
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
Ok(output)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
pub fn read_runtime_tail(state_root: &Path, lines: usize) -> Result<Vec<String>> {
|
|
238
|
+
let path = runtime_log_path(state_root);
|
|
239
|
+
if !path.exists() {
|
|
240
|
+
return Ok(Vec::new());
|
|
241
|
+
}
|
|
242
|
+
let content = fs::read_to_string(&path)
|
|
243
|
+
.with_context(|| format!("Failed to read runtime log {}", path.display()))?;
|
|
244
|
+
if lines == 0 {
|
|
245
|
+
return Ok(Vec::new());
|
|
246
|
+
}
|
|
247
|
+
let mut deque = VecDeque::with_capacity(lines);
|
|
248
|
+
for line in content.lines() {
|
|
249
|
+
if deque.len() == lines {
|
|
250
|
+
deque.pop_front();
|
|
251
|
+
}
|
|
252
|
+
deque.push_back(line.to_string());
|
|
253
|
+
}
|
|
254
|
+
Ok(deque.into_iter().collect())
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
pub fn status(state_root: &Path) -> Result<LogStatus> {
|
|
258
|
+
ensure_logs_dirs(state_root)?;
|
|
259
|
+
let runtime_path = runtime_log_path(state_root);
|
|
260
|
+
let crash_directory = crash_dir(state_root);
|
|
261
|
+
let crash_count = fs::read_dir(&crash_directory)
|
|
262
|
+
.with_context(|| format!("Failed to read crash directory {}", crash_directory.display()))?
|
|
263
|
+
.filter_map(|entry| entry.ok())
|
|
264
|
+
.filter(|entry| entry.path().is_file())
|
|
265
|
+
.count();
|
|
266
|
+
let consent = load_consent(state_root)?;
|
|
267
|
+
|
|
268
|
+
Ok(LogStatus {
|
|
269
|
+
logs_root: logs_root(state_root).display().to_string(),
|
|
270
|
+
runtime_log_path: runtime_path.display().to_string(),
|
|
271
|
+
crash_dir: crash_directory.display().to_string(),
|
|
272
|
+
consent_file_path: consent_path(state_root).display().to_string(),
|
|
273
|
+
consent,
|
|
274
|
+
runtime_exists: runtime_path.exists(),
|
|
275
|
+
crash_report_count: crash_count,
|
|
276
|
+
})
|
|
277
|
+
}
|