reallink-cli 0.1.15 → 0.1.17
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 +39 -0
- package/bin/reallink.cjs +7 -2
- package/package.json +14 -4
- package/prebuilt/linux-x64/reallink-cli +0 -0
- package/rust/Cargo.lock +40 -1
- package/rust/Cargo.toml +2 -1
- package/rust/src/logs.rs +282 -277
- package/rust/src/main.rs +8459 -7157
- package/rust/src/unreal.rs +489 -316
- package/scripts/postinstall.cjs +1 -1
- package/prebuilt/win32-x64/reallink-cli.exe +0 -0
package/rust/src/logs.rs
CHANGED
|
@@ -1,277 +1,282 @@
|
|
|
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
|
-
|
|
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(||
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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 = fs::read(&path)
|
|
129
|
+
.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(|| {
|
|
263
|
+
format!(
|
|
264
|
+
"Failed to read crash directory {}",
|
|
265
|
+
crash_directory.display()
|
|
266
|
+
)
|
|
267
|
+
})?
|
|
268
|
+
.filter_map(|entry| entry.ok())
|
|
269
|
+
.filter(|entry| entry.path().is_file())
|
|
270
|
+
.count();
|
|
271
|
+
let consent = load_consent(state_root)?;
|
|
272
|
+
|
|
273
|
+
Ok(LogStatus {
|
|
274
|
+
logs_root: logs_root(state_root).display().to_string(),
|
|
275
|
+
runtime_log_path: runtime_path.display().to_string(),
|
|
276
|
+
crash_dir: crash_directory.display().to_string(),
|
|
277
|
+
consent_file_path: consent_path(state_root).display().to_string(),
|
|
278
|
+
consent,
|
|
279
|
+
runtime_exists: runtime_path.exists(),
|
|
280
|
+
crash_report_count: crash_count,
|
|
281
|
+
})
|
|
282
|
+
}
|