railwayapp 4.56.1

Interact with Railway via CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
use std::cmp::Ordering;

use anyhow::{Context, bail};
use dirs::home_dir;

use super::compare_semver::compare_semver;

/// Best-effort write — logs a warning on failure but does not propagate.
/// Used by cache mutation methods where a write failure is non-fatal.
fn try_write(update: &UpdateCheck) {
    if let Err(e) = update.write() {
        eprintln!("warning: failed to write update cache: {e}");
    }
}

#[derive(serde::Serialize, serde::Deserialize, Default)]
pub struct UpdateCheck {
    pub last_update_check: Option<chrono::DateTime<chrono::Utc>>,
    pub latest_version: Option<String>,
    /// Number of consecutive download failures for the cached version.
    /// After 3 failures the version is cleared to force a fresh API check.
    #[serde(default)]
    pub download_failures: u32,
    /// Version the user rolled back from.  Auto-update skips this version
    /// and resumes normally once a newer release is published.
    #[serde(default)]
    pub skipped_version: Option<String>,
    /// Timestamp of the last package-manager spawn.  We only re-spawn if
    /// this is older than 1 hour, preventing rapid-fire retries when
    /// multiple CLI invocations happen before the update finishes.
    #[serde(default)]
    pub last_package_manager_spawn: Option<chrono::DateTime<chrono::Utc>>,
}
impl UpdateCheck {
    fn has_stale_latest_version(&self) -> bool {
        self.latest_version
            .as_deref()
            .map(|latest| {
                !matches!(
                    compare_semver(env!("CARGO_PKG_VERSION"), latest),
                    Ordering::Less
                )
            })
            .unwrap_or(false)
    }

    fn clear_latest_fields(&mut self) {
        self.latest_version = None;
        self.download_failures = 0;
        self.last_package_manager_spawn = None;
        self.last_update_check = None;
    }

    pub fn write(&self) -> anyhow::Result<()> {
        let home = home_dir().context("Failed to get home directory")?;
        let path = home.join(".railway/version.json");
        let contents = serde_json::to_string_pretty(&self)?;
        super::write_atomic(&path, &contents)
    }

    /// Read-modify-write helper: reads cached state (or default), applies
    /// the mutation, and writes back.
    fn mutate(f: impl FnOnce(&mut Self)) {
        let mut update = Self::read().unwrap_or_default();
        f(&mut update);
        try_write(&update);
    }

    /// Update the check timestamp, optionally preserving (or clearing) the
    /// cached pending version.  Resets the failure counter.
    pub fn persist_latest(version: Option<&str>) {
        Self::mutate(|u| {
            u.last_update_check = Some(chrono::Utc::now());
            // Reset package-manager spawn gate when the target version changes
            // so the new version gets an immediate attempt.
            if u.latest_version.as_deref() != version {
                u.last_package_manager_spawn = None;
            }
            u.latest_version = version.map(String::from);
            u.download_failures = 0;
        });
    }

    /// Read the cached update state and clear any pending version that is no
    /// longer ahead of the currently running binary.
    pub fn read_normalized() -> Self {
        let mut update = Self::read().unwrap_or_default();
        if update.has_stale_latest_version() {
            update.clear_latest_fields();
            try_write(&update);
        }
        update
    }

    /// Record a version to skip during auto-update (set after rollback).
    /// Clears `last_update_check` so the next invocation re-checks immediately.
    pub fn skip_version(version: &str) {
        Self::mutate(|u| {
            u.skipped_version = Some(version.to_string());
            u.last_package_manager_spawn = None;
            u.last_update_check = None;
        });
    }

    /// Reset cached update state after a successful upgrade or auto-apply.
    pub fn clear_after_update() {
        Self::mutate(|u| {
            u.last_update_check = Some(chrono::Utc::now());
            u.latest_version = None;
            u.download_failures = 0;
            u.last_package_manager_spawn = None;
            u.skipped_version = None;
        });
    }

    /// Max consecutive download failures before clearing the cached version.
    const MAX_DOWNLOAD_FAILURES: u32 = 3;

    /// Record a failed download attempt.  After [`Self::MAX_DOWNLOAD_FAILURES`]
    /// consecutive failures the cached pending version is cleared so the next
    /// invocation re-checks the GitHub API instead of retrying a stale version.
    pub fn record_download_failure() {
        Self::mutate(|u| {
            u.download_failures += 1;
            if u.download_failures >= Self::MAX_DOWNLOAD_FAILURES {
                u.latest_version = None;
                u.last_update_check = None;
                u.download_failures = 0;
            }
        });
    }

    /// Record that a package-manager update was just spawned.
    pub fn record_package_manager_spawn() {
        Self::mutate(|u| {
            u.last_package_manager_spawn = Some(chrono::Utc::now());
        });
    }

    /// Returns `true` if enough time has passed since the last package-manager
    /// spawn to allow another attempt (or if no spawn has been recorded).
    pub fn should_spawn_package_manager() -> bool {
        Self::read()
            .map(|u| match u.last_package_manager_spawn {
                Some(t) => (chrono::Utc::now() - t) >= chrono::Duration::hours(1),
                None => true,
            })
            .unwrap_or(true)
    }

    pub fn read() -> anyhow::Result<Self> {
        let home = home_dir().context("Failed to get home directory")?;
        let path = home.join(".railway/version.json");
        let contents =
            std::fs::read_to_string(&path).context("Failed to read update check file")?;
        serde_json::from_str::<Self>(&contents).context("Failed to parse update check file")
    }
}
#[derive(serde::Deserialize)]
struct GithubApiRelease {
    tag_name: String,
}

const GITHUB_API_RELEASE_URL: &str = "https://api.github.com/repos/railwayapp/cli/releases/latest";
pub async fn check_update(force: bool) -> anyhow::Result<Option<String>> {
    let update = UpdateCheck::read().unwrap_or_default();

    if let Some(last_update_check) = update.last_update_check {
        // 12-hour gate: avoid hitting the GitHub API on every invocation.
        if (chrono::Utc::now() - last_update_check) < chrono::Duration::hours(12) && !force {
            return Ok(None);
        }
    }

    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()?;
    let response = client
        .get(GITHUB_API_RELEASE_URL)
        .header("User-Agent", "railwayapp")
        .send()
        .await?;
    let response = response.json::<GithubApiRelease>().await?;
    let latest_version = response.tag_name.trim_start_matches('v');

    match compare_semver(env!("CARGO_PKG_VERSION"), latest_version) {
        Ordering::Less => {
            // Re-read state from disk so we don't overwrite fields that
            // were changed while the network request was in flight (e.g.
            // `skipped_version` set by a concurrent rollback).
            let mut fresh = UpdateCheck::read().unwrap_or_default();
            // Don't arm the daily gate when the latest release is the version
            // the user rolled back from — keep checking so a fix release
            // published shortly after is discovered promptly.
            if fresh.skipped_version.as_deref() != Some(latest_version) {
                fresh.last_update_check = Some(chrono::Utc::now());
            }
            // Reset package-manager spawn gate when a genuinely new version
            // appears so it gets an immediate attempt.
            if fresh.latest_version.as_deref() != Some(latest_version) {
                fresh.last_package_manager_spawn = None;
            }
            fresh.latest_version = Some(latest_version.to_owned());
            fresh.download_failures = 0;
            fresh.write()?;
            Ok(Some(latest_version.to_string()))
        }
        _ => {
            // Record the check time so we don't re-check on every invocation.
            UpdateCheck::persist_latest(None);
            Ok(None)
        }
    }
}

/// Spawns a fully detached package manager process to update the CLI.
/// Used for npm, Bun, and Scoop installs where the package manager is fast.
/// The child process runs independently — if the update succeeds, the next
/// CLI invocation will be the new version and the "new version available"
/// notification will stop appearing.
pub fn spawn_package_manager_update(
    method: super::install_method::InstallMethod,
) -> anyhow::Result<()> {
    let (program, args) = method
        .package_manager_command()
        .context("No package manager command for this install method")?;

    if which::which(program).is_err() {
        bail!("Package manager '{program}' not found in PATH");
    }

    // Acquire a file lock to serialize the PID-check-spawn-write sequence,
    // preventing two concurrent invocations from both launching an updater.
    use fs2::FileExt;

    let lock_path = super::self_update::package_update_lock_path()?;
    if let Some(parent) = lock_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let lock_file =
        std::fs::File::create(&lock_path).context("Failed to create package-update lock file")?;
    lock_file
        .try_lock_exclusive()
        .map_err(|_| anyhow::anyhow!("Another update process is starting. Please try again."))?;

    // Re-check after acquiring the lock: the user may have run
    // `railway autoupdate disable` while we were waiting.
    if crate::telemetry::is_auto_update_disabled() {
        bail!("Auto-updates were disabled while waiting for lock");
    }

    // Only spawn once per hour to avoid rapid-fire retries when multiple
    // CLI invocations happen before the update finishes.
    if !UpdateCheck::should_spawn_package_manager() {
        bail!("Package-manager update was spawned recently; waiting before retrying");
    }

    // Guard against an already-running updater.
    let pid_path = super::self_update::package_update_pid_path()?;
    if let Some(pid) = is_background_update_running(&pid_path) {
        bail!("Another update process (pid {pid}) is already running");
    }

    let log_path = super::self_update::auto_update_log_path()?;

    let mut cmd = std::process::Command::new(program);
    cmd.args(&args);

    let child = super::spawn_detached(&mut cmd, &log_path)?;
    let child_pid = child.id();
    // Intentionally leak the Child handle — we never wait on the detached
    // process.  On Unix this is harmless; on Windows it leaks a HANDLE,
    // which is acceptable for a single short-lived spawn per invocation.
    std::mem::forget(child);

    // Record the child PID + timestamp so future invocations can detect an
    // in-flight update and expire stale entries.
    let now = chrono::Utc::now().timestamp();
    let _ = std::fs::write(&pid_path, format!("{child_pid} {now}"));

    // Record spawn time so we don't re-spawn within the next hour.
    UpdateCheck::record_package_manager_spawn();

    // Lock is released on drop after the PID file is written.

    Ok(())
}

/// Maximum age in seconds for a PID file entry before it's considered stale.
const PID_STALENESS_TTL_SECS: i64 = 600;

/// Parse a PID file containing `"{pid} {timestamp}"`.
pub fn parse_pid_file(contents: &str) -> Option<(u32, i64)> {
    let mut parts = contents.split_whitespace();
    let pid = parts.next()?.parse().ok()?;
    let ts = parts.next()?.parse().ok()?;
    Some((pid, ts))
}

/// Returns `true` if a background package-manager update is currently running,
/// based on the PID file at the given path.
pub fn is_background_update_running(pid_path: &std::path::Path) -> Option<u32> {
    let contents = std::fs::read_to_string(pid_path).ok()?;
    let (pid, ts) = parse_pid_file(&contents)?;
    let age_secs = chrono::Utc::now().timestamp().saturating_sub(ts);
    if age_secs < PID_STALENESS_TTL_SECS && is_pid_alive(pid) {
        Some(pid)
    } else {
        None
    }
}

/// Check whether a process with the given PID is still running.
pub fn is_pid_alive(pid: u32) -> bool {
    #[cfg(unix)]
    {
        use nix::sys::signal::kill;
        use nix::unistd::Pid;
        // Signal 0 checks existence without delivering a signal.
        // EPERM means the process exists but we lack permission to signal it.
        matches!(
            kill(Pid::from_raw(pid as i32), None),
            Ok(()) | Err(nix::errno::Errno::EPERM)
        )
    }
    #[cfg(windows)]
    {
        use winapi::um::handleapi::CloseHandle;
        use winapi::um::processthreadsapi::{GetExitCodeProcess, OpenProcess};
        use winapi::um::winnt::PROCESS_QUERY_INFORMATION;
        // GetExitCodeProcess returns STILL_ACTIVE (259) while the process runs.
        const STILL_ACTIVE: u32 = 259;
        unsafe {
            let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid);
            if handle.is_null() {
                // Process doesn't exist or we have no permission to query it.
                return false;
            }
            let mut exit_code: u32 = 0;
            let ok = GetExitCodeProcess(handle, &mut exit_code as *mut u32 as *mut _) != 0;
            CloseHandle(handle);
            ok && exit_code == STILL_ACTIVE
        }
    }
    #[cfg(not(any(unix, windows)))]
    {
        // Conservative fallback for other platforms (e.g. FreeBSD): assume
        // alive and let the 10-minute staleness TTL expire the entry.
        let _ = pid;
        true
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn next_version(version: &str) -> String {
        let mut parts = version
            .split('-')
            .next()
            .unwrap_or(version)
            .split('.')
            .map(|part| part.parse::<u8>().unwrap_or(0))
            .collect::<Vec<_>>();
        parts.resize(3, 0);

        for idx in (0..parts.len()).rev() {
            if parts[idx] < u8::MAX {
                parts[idx] += 1;
                for part in parts.iter_mut().skip(idx + 1) {
                    *part = 0;
                }
                return format!("{}.{}.{}", parts[0], parts[1], parts[2]);
            }
        }

        "255.255.255-rc.1".to_string()
    }

    #[test]
    fn stale_latest_version_is_detected_and_cleared() {
        let mut update = UpdateCheck {
            last_update_check: Some(chrono::Utc::now()),
            latest_version: Some(env!("CARGO_PKG_VERSION").to_string()),
            download_failures: 2,
            skipped_version: Some("0.1.0".to_string()),
            last_package_manager_spawn: Some(chrono::Utc::now()),
        };

        assert!(update.has_stale_latest_version());

        update.clear_latest_fields();

        assert!(update.latest_version.is_none());
        assert_eq!(update.download_failures, 0);
        assert!(update.last_package_manager_spawn.is_none());
        assert!(update.last_update_check.is_none());
        assert_eq!(update.skipped_version.as_deref(), Some("0.1.0"));
    }

    #[test]
    fn newer_latest_version_is_not_stale() {
        let update = UpdateCheck {
            latest_version: Some(next_version(env!("CARGO_PKG_VERSION"))),
            ..Default::default()
        };

        assert!(!update.has_stale_latest_version());
    }
}