mirror of
https://github.com/railwayapp/cli
synced 2026-04-21 14:07:23 +00:00
feat: add auto-update mechanism for CLI (#825)
* feat: add auto-update mechanism for CLI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent auto-update from blocking CLI exit and fix permission/lock issues
- Don't await download_and_stage on exit; spawn it as fire-and-forget so
commands like --help return instantly even when an update is available
- Add writability check before attempting self-update download, skipping
the download entirely for root-owned paths like /usr/local/bin
- Replace acquire-then-drop file lock with PID file guard for package
manager updates to prevent duplicate concurrent spawns
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: cap update task with timeout and add staleness to PID guard
- Move download_and_stage back into the awaited task so the tokio
runtime doesn't cancel it on exit, but cap handle_update_task with
a 5-second timeout so short commands like --help aren't blocked
- Write "PID TIMESTAMP" to the lock file and treat entries older than
10 minutes as stale, fixing the permanent block on Windows where
is_pid_alive always returned true
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: cache version check timestamp, separate lock files, and clean up control flow
- Write last_update_check even when CLI is up-to-date, preventing a
GitHub API call on every invocation
- Use separate file paths for self-update flock (update.lock) and
package-manager PID guard (package-update.pid)
- Return Ok(None) instead of bail! for "already checked today" since
it's normal control flow, not an error
- Make Preferences::write atomic via temp file + rename
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: improve robustness, Windows support, and rollback UX for auto-update
- Bypass same-day gate for known-pending versions so timed-out downloads
retry on the next invocation
- Skip spawning update task when running `autoupdate` subcommand to avoid
racing with preference changes
- Use nix crate for Unix PID liveness check; add Windows implementation
via winapi instead of conservative fallback
- Detach child update process from console Ctrl+C on Windows
- Add Windows CREATE_NEW_PROCESS_GROUP flag to package manager spawning
- Use scopeguard for write-probe cleanup in install method detection
- Warn when checksums.txt is missing rather than silently skipping
- Improve rollback: back up current binary first, support multi-candidate
selection via inquire prompt
- Remove freebsd target triple (no release asset published)
- Use nanosecond-precision tmp file names to reduce collision risk
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
* Updates
* fix: prevent autoupdate disable from applying staged update, handle non-writable shell installs, and preserve retry signal
- Skip try_apply_staged() for both `upgrade` and `autoupdate` subcommands
so `railway autoupdate disable` doesn't swap the binary before disabling
- Add can_write_binary() check in `railway upgrade` for shell installs in
non-writable locations (e.g. /usr/local/bin with sudo), guiding users to
use sudo or reinstall instead of failing with a permission error
- Move clear_latest() from eager call in main to post-success in the
background task, so a timed-out download preserves the retry signal for
the next invocation instead of losing it behind the same-day gate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: clear stale version cache for notification-only installs, lock download_and_stage, and add FreeBSD target
- For installs that can't self-update or auto-run a package manager
(Homebrew, Cargo, Unknown, non-writable Shell), clear latest_version
after the notification so the next day's check_update() can discover
newer releases instead of freezing on the first cached version
- Wrap download_and_stage() with an exclusive file lock (update.lock)
using double-checked locking to prevent concurrent CLI processes from
racing on the staged-update directory
- Add FreeBSD x86_64 to detect_target_triple() to match install.sh
support, preventing shell-installed FreeBSD users from hitting an
"Unsupported platform" error on upgrade
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only clear version cache when update actually starts, Windows-safe rename, custom bin dir detection, and rollback target tracking
- download_and_stage returns Result<bool> so callers distinguish "lock
held" (no work done) from a real staging; failed package-manager
spawns no longer fall through to the notification-only branch that
clears the cache
- Add rename_replacing() helper that removes the destination on Windows
before renaming, fixing silent write failures for preferences.json
and update.json after the first successful write
- Shell install detection now falls back to any parent directory named
"bin", covering custom --bin-dir installs (~/bin, /opt/bin, etc.)
- Backup filenames include the target triple; rollback filters
candidates by current architecture to prevent cross-arch restoration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: Windows version cache write, rollback guards, CI status reporting, preference persistence, and FreeBSD self-update
- Use rename_replacing() in UpdateCheck::write() so Windows cache clears work
- Add interact_or! and can_write_binary() guards to rollback path
- Report CI-disabled state in autoupdate status
- Create ~/.railway dir in Preferences::write() for clean HOME directories
- Remove FreeBSD from self-update targets until release pipeline publishes assets
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: tighten install detection, ensure ~/.railway exists, gate FreeBSD self-update, and preserve notifications when auto-updates disabled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve update retry signals, propagate preference write errors, and use atomic Windows rename
- Notification-only installs (Homebrew, Cargo, Unknown) now preserve
latest_version in the cache so the "new version available" notice
actually shows on the next invocation instead of being cleared before
the user sees it.
- Detached package-manager updates no longer clear the retry signal on
spawn — the cached version persists until the user is actually on the
new version.
- Failed staged-update applies preserve the staged payload for retry
instead of deleting it; the 7-day staleness TTL handles permanent
failures.
- `railway upgrade` now clears the version cache after a successful
update so the next invocation doesn't redundantly re-download.
- Preferences::write() returns Result so `railway autoupdate disable`
and `railway telemetry disable` report failures instead of silently
succeeding.
- Standardize timestamp_nanos_opt() on unwrap_or_default() everywhere.
- Windows rename_replacing uses MoveFileExW(MOVEFILE_REPLACE_EXISTING)
for a single-syscall atomic replace instead of remove+rename.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: deduplicate update-check persistence with persist_latest helper
Consolidate repeated UpdateCheck write blocks in spawn_update_task into
a single persist_latest() method, and skip redundant writes when
check_update() already persisted the timestamp.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sort backups by version instead of mtime to fix Windows CI
list_backups relied on filesystem modification times, which required
write access to set in tests and is fragile across platforms. Sort by
the semver version embedded in the backup filename instead.
Also removes low-value tests that only assert trivial string formatting
or compiler-guaranteed match exhaustiveness.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: detach shell downloads, expire stale versions, reduce exit delay, and clarify update message
- Spawn background downloads in a detached child process so they survive
beyond the parent's exit timeout instead of restarting from zero
- Add download_failures counter to version.json; after 3 consecutive
failures, clear the cached version to force a fresh API re-check
(fixes infinite retry loop on yanked/stale releases)
- Reduce exit timeout from 5s to 2s since it now only gates the fast
API version check, not the download
- Append "(active on next run)" to the auto-update message since the
current process still executes the old binary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract env var constant, name failure threshold, skip redundant spawns
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: stale comment, probe cleanup, non-TTY gating, Windows .old.exe, rollback prune timing, and check_update timeout
- Correct "5 s" → "2 s" in download_and_stage comment to match handle_update_task
- Simplify can_write_binary probe to unconditional create-then-remove
- Skip spawn_update_task in non-TTY when auto-update is disabled
- Clean up leftover .old.exe on Windows at the start of try_apply_staged
- Defer backup pruning until after rollback succeeds so candidates aren't removed before the picker
- Add 30 s timeout to check_update reqwest client to prevent indefinite hangs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: send update notifications to stderr to avoid corrupting command output
println! in the auto-update and version-banner paths wrote to stdout,
which breaks JSON parsing when the CLI is invoked programmatically
(e.g. by Claude Code piping `railway status --json`).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: clear stale update cache when CLI catches up to cached version
Once the installed binary reaches or surpasses the cached latest_version,
clear the cache so spawn_update_task falls through to a fresh check_update().
This prevents repeated package-manager spawns on every invocation after an
update lands and allows discovery of newer releases on manual install paths.
Also null out stdin on the detached package-manager update process to match
spawn_background_download and fully detach from the terminal.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: always attempt fresh API check so cached version does not become permanent
Previously spawn_update_task skipped check_update() entirely when a
cached version existed, and re-persisted the timestamp on every
invocation. This prevented the same-day gate from ever expiring, so the
CLI would advertise a stale cached version forever and never discover
newer releases.
Now we always call check_update() first (gated to once per UTC day),
falling back to the cached version only within the same day for download
retries.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: apply cargo fmt and lint-fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: keep cached pending version until staged update is actually applied
background_stage_update() was clearing the cached latest_version
immediately after staging succeeded, but the binary isn't replaced until
try_apply_staged() runs on a later invocation. If that apply step fails,
the user loses both the upgrade banner and future download retries until
the 7-day stale TTL expires. The cache is already cleared on successful
apply in try_apply_staged(), so the staging path should leave it alone.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: apply cargo fmt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: hold update lock across apply in interactive upgrade and rollback
self_update_interactive() dropped the update lock after staging but
before applying, letting a concurrent try_apply_staged() race the binary
replacement. Now the lock is held across both staging and apply.
rollback() was mutating the binary and cleaning staged state without
acquiring update.lock at all, which could interleave with a background
auto-updater. Now it acquires the same lock before replacing the binary.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: correct autoupdate status for non-writable shell installs and close stage-apply race
update_strategy() now checks can_self_update() and can_write_binary()
so `autoupdate status` no longer claims "Background download + auto-swap"
when the binary directory is not writable or the platform is unsupported.
self_update_interactive() now holds a single lock across both
download_and_stage_inner() and apply_staged_update(), eliminating the
window where a concurrent try_apply_staged() could consume the staged
binary between staging and applying.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: drop lock file handle instead of deleting to prevent concurrent lock race
Removing the lock file while the handle is still held unlinks the inode
on Unix, allowing a concurrent process to create a new file at the same
path and acquire its own "exclusive" lock. Replacing remove_file with
drop releases the lock via the OS and leaves the file as an inert
sentinel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract spawn_detached helper and simplify update task logic
- Extract shared spawn_detached() into util/mod.rs, deduplicating the
detached-process setup from spawn_background_download and
spawn_package_manager_update
- Remove redundant staged-update check in spawn_background_download
(child process already checks in download_and_stage)
- Consolidate scattered from_cache/persist_latest branches into a
single needs_persist flag
- Cache is_auto_update_disabled() result in main() instead of calling
it twice
- Use persist_latest(None) in check_update's up-to-date branch
- Trim narrating comments
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: skip rolled-back version in auto-update instead of pausing entirely
After rollback, record the rolled-back-from version as skipped in
version.json. Auto-update skips only that version and resumes normally
once a newer release is published. This avoids silent staleness from
a full pause while still respecting the user's rollback intent.
- Add skipped_version field to UpdateCheck (serde-default for compat)
- Record skip in rollback(), guard try_apply_staged() against the race
where a pre-rollback detached download stages the skipped version
- Clear skip on successful update (clear_after_update) and autoupdate enable
- Suppress "new version available" notification for skipped version
- Show skipped version in autoupdate status
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: close concurrency races in rollback, package-manager guard, and failure reset
- Acquire update.lock before backup/picker in rollback() so a concurrent
try_apply_staged() cannot swap the binary during interactive selection
- Serialize the PID-check-spawn-write sequence in spawn_package_manager_update()
with a file lock to prevent two CLI invocations from both launching updaters
- Clear last_update_check alongside latest_version after 3 download failures
so the same-day gate does not suppress the fresh API re-check
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add checksum generation, respect disable preference, and lock staged cleanup
- Add generate-checksums job to release workflow that produces checksums.txt
with SHA-256 hashes for all release assets, making checksum verification
functional instead of always falling back to the skip path
- Gate update polling and "New version available" banner on auto_update_enabled
so disabled users no longer hit the GitHub API or see nagging banners
- Acquire update.lock in autoupdate disable before cleaning staged updates so
an in-flight background download cannot re-stage after cleanup returns
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* revert: remove checksums.txt workflow change (pre-existing, out of scope)
The missing checksums.txt in the release pipeline predates this PR.
Reverting the workflow change to keep this PR focused on auto-update fixes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Revert "revert: remove checksums.txt workflow change (pre-existing, out of scope)"
This reverts commit e1e76a1ea2.
* fix: harden auto-update safety for non-TTY, install detection, and permissions
- Gate background updates and staged-apply on is_tty so cron jobs,
scripts, and piped invocations never silently self-mutate
- Resolve symlinks in InstallMethod::detect() to prevent Intel Homebrew
misclassification when current_exe() returns the symlink path
- Replace catch-all bin/ Shell detection with explicit allowlist of
known shell-installer directories to avoid overwriting version-manager
binaries
- Probe binary directory writability in can_auto_run_package_manager()
to prevent repeated doomed npm/Bun/Scoop updates on sudo installs
- Acquire package-update lock in autoupdate disable to wait for
in-flight package manager updates before returning
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: wait for detached updater by PID, restore custom bin-dir support, fix status accuracy
- autoupdate disable now polls the actual child PID instead of
acquiring a lock the detached process never holds, so it truly
waits for in-flight npm/Bun/Scoop updates (30s timeout with warning)
- Replace install-method allowlist with version-manager exclusion list
(asdf, mise, rtx, proto, volta, fnm, nodenv, rbenv, pyenv) and
restore the */bin/ catch-all so custom --bin-dir installs from
install.sh are correctly classified as Shell again
- update_strategy() now reflects the writability check for
npm/Bun/Scoop, showing "Notification only" when the binary
directory is not writable instead of overpromising
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve rollback guard on enable, recover from missing staged binary, quiesce detached child on disable
- `railway autoupdate enable` no longer clears skipped_version; the
rollback guard persists until a newer release is applied
- Staged-update fast paths now verify the binary exists before
short-circuiting; missing binary cleans stale metadata immediately
- Detached background child re-checks auto-update preferences before
downloading and again after acquiring the update lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: exclude check_updates from auto-update side effects
check_updates is a read-only command but was not in the
update-management guard, so it could apply staged binaries and
spawn background downloads.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: reformat matches! macro (cargo fmt)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: close pkg-manager race on disable, unblock same-day discovery after rollback, fix Windows upgrade instructions
- spawn_package_manager_update re-checks is_auto_update_disabled()
after acquiring its lock, preventing a concurrent invocation from
spawning an updater after the user ran autoupdate disable
- skip_version() now clears last_update_check so the next invocation
performs a fresh API check and can discover a newer release published
the same day as the rollback
- Upgrade/rollback non-writable instructions now show Windows-appropriate
guidance (run as Administrator) instead of sudo/bash commands
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: keep checking for releases while skipped version is latest, acquire package-update lock in disable
- check_update() no longer arms the daily gate when the discovered
version matches skipped_version, so a fix release published later
the same day is discovered on the next invocation
- autoupdate disable now acquires package_update_lock (blocking) before
reading the PID file, closing the race where spawn_package_manager_update
writes the PID after disable already looked
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve package-update PID file when child outlives the 30s timeout
disable was unconditionally removing the PID file even when the
detached updater was still alive. This left no in-flight marker,
so re-enabling auto-updates could launch a duplicate updater.
Now the PID file is only removed once the child has actually exited.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: ensure ~/.railway directory exists before creating rollback lock file
On a fresh install where ~/.railway has never been written,
rollback would fail with "Failed to create update lock file"
instead of reporting no backups available.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: detect custom CARGO_INSTALL_ROOT installs via .crates.toml marker
The */bin catch-all could misclassify a custom Cargo install root
(e.g. CARGO_INSTALL_ROOT=~/tools) as a shell install, allowing the
auto-updater to self-replace a Cargo-managed binary. Now checks for
Cargo's .crates.toml marker in the parent of bin/ before falling
through to Shell.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: re-read version state after network call to prevent rollback skip loss, check Cargo marker before shell-path heuristic
check_update() loaded version.json, did a network call (up to 30s), then
wrote the stale snapshot back — silently overwriting a skipped_version set
by a concurrent rollback. Now re-reads from disk after the network call.
InstallMethod::detect() matched /usr/local/bin before the .crates.toml
marker check, so CARGO_INSTALL_ROOT=/usr/local installs were misclassified
as Shell. Moved the marker check above the shell-path heuristic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: don't stamp daily gate when clearing stale version, exclude pnpm from npm detection
clear_latest() called persist_latest(None) which set last_update_check=now,
preventing the background task from discovering a hotfix published later the
same day after a manual upgrade. Now clears the cached version without
stamping the gate.
pnpm global paths contain "npm" as a substring, causing misclassification
as InstallMethod::Npm and driving updates through the wrong package manager.
Added an early pnpm check that falls back to Unknown.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: serialize interactive upgrade with background package-manager updates
run_upgrade_command() now acquires the same file lock and checks the PID
file used by spawn_package_manager_update(), preventing concurrent
package-manager processes against the same global install when a user
runs `railway upgrade` while a background auto-update is in flight.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent update side effects on help/error paths and retain cached version on API failure
Use raw args to detect update-management subcommands so that clap
DisplayHelp/DisplayVersion error paths (e.g. `railway upgrade --help`)
no longer trigger try_apply_staged() as a side effect.
Match on the Result from check_update() instead of using `?` so that
API errors fall back to the cached known_version for retry rather than
short-circuiting the entire update task.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip auto-update pipeline entirely on help/version/parse-error paths
--help, --version, and invalid-input paths now bypass both
try_apply_staged() and the background update spawn, ensuring they are
truly read-only with zero added latency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract parse_pid_file helper and remove dead .crates.toml check
- Extract `parse_pid_file()` in check_update.rs to deduplicate PID file
parsing across autoupdate, upgrade, and check_update modules
- Remove unreachable `.crates.toml` guard in install_method.rs catch-all
(the same check already fires earlier in detect())
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: don't stamp daily gate for skipped version, make checksum upload idempotent
- After rollback, an API failure would fall back to the cached (skipped)
version and call persist_latest(), arming the daily gate. This prevented
re-checking for a newer release until the next day. Now skipped versions
skip persist_latest so the API is re-checked on every invocation.
- Add --clobber to gh release upload so re-running the checksums job
doesn't fail on an existing asset.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip update side effects on bare `railway`, use compile-time target triple
- Bare `railway` (no subcommand) now skips try_apply_staged() and the
background updater, matching the read-only behavior of --help/--version.
Previously a first-time user typing `railway` to explore would trigger
update side effects before seeing help.
- detect_target_triple() now uses the compile-time BUILD_TARGET from
build.rs instead of runtime OS/arch guessing. This ensures the
self-updater fetches the correct ABI variant (e.g. a binary built for
x86_64-unknown-linux-gnu will not be replaced with a musl build).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: treat `railway help` as read-only, fix i686-pc-windows-gnu asset format
- Add "help" to the read-only invocation guard so `railway help` skips
try_apply_staged() and the background updater, matching the behavior
of --help and bare `railway`.
- i686-pc-windows-gnu is cross-compiled on Linux and only ships as
.tar.gz (no .zip). The asset name logic now accounts for this, and
extraction derives the format from the asset name rather than
re-checking the target.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: clear daily gate on version catch-up, restore shell upgrade path, eager download spawn
- clear_latest() now resets last_update_check so that same-day hotfixes
are discovered after the user catches up to a cached version.
- Shell installs on unsupported self-update platforms (e.g. FreeBSD) now
get a dedicated match arm showing the reinstall command instead of
falling through to the vague catch-all message.
- spawn_update_task now starts the background download from the cached
version before the API call, so the 2s exit timeout cannot strand the
spawn on slow networks. The API check still runs to refresh the cache.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only eagerly spawn download when same-day gate is armed
The eager download spawn could race with a newer version discovered by
the API: the cached-version child holds the update lock, so the
newer-version child exits immediately, and try_apply_staged would apply
the stale cached release on the next run.
Fix: only eagerly spawn when the same-day gate is armed (last_update_check
is today). In that case check_update(false) returns Ok(None) instantly
without a network request, so the API cannot discover a newer version that
would conflict. When the gate is NOT armed, the API call goes over the
network and may return a newer release — defer the spawn to after the
check completes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: ensure parent directory exists before creating detached-process log file
spawn_detached() creates the log file without ensuring the parent
directory exists. On a fresh install where ~/.railway doesn't exist yet,
this fails silently (the spawn result is ignored), preventing the
background download from starting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: show update banner when auto-update disabled, apply staged offline, add PID TTL to disable
- Version discovery and the "New version available" banner now run
regardless of auto-update preference. Disabling auto-update stops
automatic installation, not release awareness.
- `railway upgrade` now falls back to an already-staged update when the
network check fails, so offline users can apply a previously
downloaded binary.
- `autoupdate disable` now applies the 10-minute PID file TTL before
trusting the stored PID, consistent with upgrade and
spawn_package_manager_update. Prevents blocking 30s on a recycled PID.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: validate staged update before offline apply in interactive upgrade
The fallback path in self_update_interactive now applies the same guards
as try_apply_staged: rejects stale entries, wrong-platform binaries,
versions <= current, and versions the user rolled back from. Prevents
offline `railway upgrade` from downgrading or re-applying a skipped
version.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: simplify auto-update design after review
- Switch version check gate from calendar-day UTC to fixed 12h window
- Remove checksums.txt machinery (code, tests, workflow job) — co-located
checksums don't add security; TLS handles integrity in transit
- Reduce exit-delay timeout from 2s to 1s for version check
- Simplify `autoupdate disable` — set flag and clean staged, no PID polling
- Run version check in non-TTY to keep cache fresh for script-heavy users
- Suppress update banner when disabled via env var or CI, keep for preference
- Extract `validate_staged()` to deduplicate safety checks
- Enrich `autoupdate status` with pipeline state (latest version, staged,
last check time, in-flight PID)
- Add `autoupdate skip` subcommand for package-manager installs
- Split download timeout: 30s background, 120s interactive
- Replace count-based package-manager retry (5 attempts) with time gate (1/hr)
- Add telemetry event for silent auto-update apply
- Clean up orphaned generate-checksums workflow job
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: simplify PID liveness check, remove unnecessary duplication
- Extract `is_package_update_running()` helper to replace 3 identical
PID-file-parse + age-check + liveness-check blocks
- Extract `PID_STALENESS_TTL_SECS` constant (was magic number 600)
- Remove unnecessary clone in `autoupdate skip`
- Remove TOCTOU `.exists()` check before `hard_link` in backup
- Remove unnecessary comment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address review findings — TOCTOU, handle leak, error visibility
- Move validate_staged() inside the exclusive lock in try_apply_staged()
to close TOCTOU window where another process could delete the staged
binary between validation and apply
- Use std::mem::forget on detached Child handles to avoid leaking OS
resources on Windows (both package-manager and background-download paths)
- Use rename_replacing() in replace_binary() on Unix for consistency
- Replace silent `let _ = write()` in cache mutation methods with
try_write() that logs warnings on failure
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: collapse clippy warning, document intentional handle leaks, defer Windows cleanup
- Collapse nested `if` in spawn_update_task to fix clippy::collapsible_if
- Add comment in download_and_stage_inner explaining the duplicate
staged-version check is the authoritative post-lock re-check
- Document std::mem::forget(child) on both detached-process call sites
- Move Windows clean_old_binary() to only run after successful apply
instead of on every try_apply_staged entry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip API check in disabled non-TTY sessions, validate staged binary, add --locked to cargo install
- Non-TTY sessions with auto-update disabled no longer make a GitHub API
call or pay the exit-time budget (CI/scripts).
- validate_staged() now checks the staged binary exists on disk before
reporting the update as ready to apply.
- Cargo upgrade command includes --locked to match documented install path
and prevent resolver drift.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: simplify auto-update code — deduplicate dispatch, extract helpers, reduce boilerplate
- Extract `write_atomic()` in util/mod.rs, replacing 3 duplicated
temp-file + rename write implementations
- Extract `try_dispatch_update()` helper and `UpdateContext` struct,
eliminating duplicated dispatch logic and double InstallMethod::detect()
- Add `UpdateCheck::mutate()` to absorb read-modify-write boilerplate
from 5 static methods
- Collapse 3 near-identical disabled-state branches in autoupdate status
into one computed `disabled_reason`
- Trim WHAT-narration comments to concise WHY reasoning
No behavior changes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
56bbd07c74
commit
44e09c26c0
14 changed files with 2450 additions and 181 deletions
59
Cargo.lock
generated
59
Cargo.lock
generated
|
|
@ -87,6 +87,15 @@ version = "1.0.95"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
|
|
@ -203,9 +212,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.16.0"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
|
|
@ -741,6 +750,17 @@ dependencies = [
|
|||
"syn 2.0.93",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.93",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
|
|
@ -1911,9 +1931,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.22"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
|
|
@ -2391,6 +2411,7 @@ dependencies = [
|
|||
"derive-new",
|
||||
"derive_more",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"fs2",
|
||||
"futures 0.3.31",
|
||||
"futures-util",
|
||||
|
|
@ -2436,6 +2457,7 @@ dependencies = [
|
|||
"url",
|
||||
"which",
|
||||
"winapi",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4281,12 +4303,41 @@ dependencies = [
|
|||
"syn 2.0.93",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.9",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ arboard = "3"
|
|||
clap = { version = "4.5.23", features = ["derive", "suggestions", "cargo"] }
|
||||
colored = "2.2.0"
|
||||
dirs = "5.0.1"
|
||||
flate2 = "1"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
serde = { version = "1.0.217", features = ["derive"] }
|
||||
serde_json = "1.0.134"
|
||||
serde_yaml = "0.9"
|
||||
|
|
@ -76,6 +78,8 @@ winapi = { version = "0.3.9", features = [
|
|||
"processthreadsapi",
|
||||
"handleapi",
|
||||
"winerror",
|
||||
"winbase",
|
||||
"winnt",
|
||||
] }
|
||||
strum = { version = "0.26.3", features = ["derive"] }
|
||||
structstruck = "0.4.1"
|
||||
|
|
|
|||
5
build.rs
5
build.rs
|
|
@ -4,4 +4,9 @@ fn main() {
|
|||
println!("cargo:rerun-if-changed=src/gql/mutations/strings");
|
||||
println!("cargo:rerun-if-changed=src/gql/subscriptions/strings");
|
||||
println!("cargo:rerun-if-changed=src/gql/schema.json");
|
||||
|
||||
// Expose the compile-time target triple so the self-updater fetches the
|
||||
// correct release asset (respects ABI: gnu vs musl, msvc vs gnu, etc.).
|
||||
let target = std::env::var("TARGET").unwrap();
|
||||
println!("cargo:rustc-env=BUILD_TARGET={target}");
|
||||
}
|
||||
|
|
|
|||
307
src/commands/autoupdate.rs
Normal file
307
src/commands/autoupdate.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
use super::*;
|
||||
use crate::config::Configs;
|
||||
use crate::telemetry::{Preferences, is_auto_update_disabled_by_env};
|
||||
use crate::util::check_update::UpdateCheck;
|
||||
use crate::util::install_method::InstallMethod;
|
||||
|
||||
/// Manage auto-update preferences
|
||||
#[derive(Parser)]
|
||||
pub struct Args {
|
||||
#[clap(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
enum Commands {
|
||||
/// Enable automatic updates
|
||||
Enable,
|
||||
/// Disable automatic updates
|
||||
Disable,
|
||||
/// Show current auto-update status
|
||||
Status,
|
||||
/// Skip the current pending version (useful if a release is broken)
|
||||
Skip,
|
||||
}
|
||||
|
||||
fn pending_version(update: &UpdateCheck, staged_version: Option<String>) -> Option<String> {
|
||||
update.latest_version.clone().or(staged_version)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BackgroundUpdateKind {
|
||||
Download,
|
||||
PackageManager,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct BackgroundUpdate {
|
||||
pid: u32,
|
||||
kind: BackgroundUpdateKind,
|
||||
}
|
||||
|
||||
fn running_background_update() -> Option<BackgroundUpdate> {
|
||||
let download_pid_path = crate::util::self_update::download_update_pid_path().ok()?;
|
||||
if let Some(pid) = crate::util::check_update::is_background_update_running(&download_pid_path) {
|
||||
return Some(BackgroundUpdate {
|
||||
pid,
|
||||
kind: BackgroundUpdateKind::Download,
|
||||
});
|
||||
}
|
||||
|
||||
let package_pid_path = crate::util::self_update::package_update_pid_path().ok()?;
|
||||
crate::util::check_update::is_background_update_running(&package_pid_path).map(|pid| {
|
||||
BackgroundUpdate {
|
||||
pid,
|
||||
kind: BackgroundUpdateKind::PackageManager,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn disable_in_flight_message(update: BackgroundUpdate) -> String {
|
||||
match update.kind {
|
||||
BackgroundUpdateKind::Download => format!(
|
||||
"Note: background download (PID {}) is already running and may still finish staging an update. \
|
||||
Disabling auto-updates prevents future automatic updates and automatic apply.",
|
||||
update.pid
|
||||
),
|
||||
BackgroundUpdateKind::PackageManager => format!(
|
||||
"Note: background package-manager update (PID {}) is already running and may still finish. \
|
||||
Disabling auto-updates only prevents future automatic updates.",
|
||||
update.pid
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_in_flight_message(update: BackgroundUpdate, version: &str) -> Option<String> {
|
||||
match update.kind {
|
||||
BackgroundUpdateKind::Download => None,
|
||||
BackgroundUpdateKind::PackageManager => Some(format!(
|
||||
"Note: background package-manager update (PID {}) is already running and may still finish installing v{}. \
|
||||
Future auto-updates will skip this version.",
|
||||
update.pid, version
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn background_update_status_message(update: BackgroundUpdate, auto_update_enabled: bool) -> String {
|
||||
match (update.kind, auto_update_enabled) {
|
||||
(BackgroundUpdateKind::Download, true) => {
|
||||
format!(
|
||||
"Background update: downloading and staging (PID {})",
|
||||
update.pid
|
||||
)
|
||||
}
|
||||
(BackgroundUpdateKind::Download, false) => format!(
|
||||
"Background update: downloading and staging (PID {}; started before auto-updates were disabled and may still finish)",
|
||||
update.pid
|
||||
),
|
||||
(BackgroundUpdateKind::PackageManager, true) => {
|
||||
format!(
|
||||
"Background update: package manager running (PID {})",
|
||||
update.pid
|
||||
)
|
||||
}
|
||||
(BackgroundUpdateKind::PackageManager, false) => format!(
|
||||
"Background update: package manager running (PID {}; started before auto-updates were disabled and may still finish)",
|
||||
update.pid
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_status_message(env_disabled: bool, ci: bool) -> (&'static str, bool) {
|
||||
if env_disabled {
|
||||
(
|
||||
"Auto-update preference enabled, but updates remain disabled by RAILWAY_NO_AUTO_UPDATE.",
|
||||
false,
|
||||
)
|
||||
} else if ci {
|
||||
(
|
||||
"Auto-update preference enabled, but updates remain disabled in this CI environment.",
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
("Auto-updates enabled.", true)
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_upgrade_hint() -> &'static str {
|
||||
"Manual upgrade is still available via `railway upgrade --yes`."
|
||||
}
|
||||
|
||||
fn should_show_manual_upgrade_hint(method: InstallMethod) -> bool {
|
||||
method.can_self_update() || method.can_auto_upgrade()
|
||||
}
|
||||
|
||||
pub async fn command(args: Args) -> Result<()> {
|
||||
match args.command {
|
||||
Commands::Enable => {
|
||||
let mut prefs = Preferences::read();
|
||||
prefs.auto_update_disabled = false;
|
||||
prefs.write().context("Failed to save preferences")?;
|
||||
let env_disabled = is_auto_update_disabled_by_env();
|
||||
let ci = Configs::env_is_ci();
|
||||
let (message, effective_enabled) = enable_status_message(env_disabled, ci);
|
||||
if effective_enabled {
|
||||
println!("{}", message.green());
|
||||
} else {
|
||||
println!("{}", message.yellow());
|
||||
}
|
||||
let update = UpdateCheck::read_normalized();
|
||||
if let Some(ref skipped) = update.skipped_version {
|
||||
println!(
|
||||
"Note: v{skipped} is still skipped from rollback; auto-update resumes on next release."
|
||||
);
|
||||
}
|
||||
}
|
||||
Commands::Disable => {
|
||||
let mut prefs = Preferences::read();
|
||||
prefs.auto_update_disabled = true;
|
||||
prefs.write().context("Failed to save preferences")?;
|
||||
// Clean any staged binary so it isn't applied on next launch.
|
||||
// Best-effort: if a background download holds the lock, the staged
|
||||
// dir will be left behind but try_apply_staged() checks the
|
||||
// preference and won't apply it.
|
||||
let _ = crate::util::self_update::clean_staged();
|
||||
println!("{}", "Auto-updates disabled.".yellow());
|
||||
if let Some(update) = running_background_update() {
|
||||
println!("{}", disable_in_flight_message(update));
|
||||
}
|
||||
}
|
||||
Commands::Skip => {
|
||||
let update = UpdateCheck::read_normalized();
|
||||
let staged_version = crate::util::self_update::validated_staged_version();
|
||||
if let Some(version) = pending_version(&update, staged_version) {
|
||||
UpdateCheck::skip_version(&version);
|
||||
let _ = crate::util::self_update::clean_staged();
|
||||
println!(
|
||||
"Skipping v{version}. Auto-update will resume when a newer version is released.",
|
||||
);
|
||||
if let Some(update) = running_background_update() {
|
||||
if let Some(message) = skip_in_flight_message(update, &version) {
|
||||
println!("{message}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("No pending update to skip.");
|
||||
}
|
||||
}
|
||||
Commands::Status => {
|
||||
let prefs = Preferences::read();
|
||||
let env_disabled = is_auto_update_disabled_by_env();
|
||||
let method = InstallMethod::detect();
|
||||
|
||||
let ci = Configs::env_is_ci();
|
||||
let auto_update_enabled = !env_disabled && !ci && !prefs.auto_update_disabled;
|
||||
|
||||
let disabled_reason: Option<String> = if env_disabled {
|
||||
Some("disabled by RAILWAY_NO_AUTO_UPDATE".into())
|
||||
} else if ci {
|
||||
Some("disabled in CI environment".into())
|
||||
} else if prefs.auto_update_disabled {
|
||||
Some(format!(
|
||||
"disabled via {}",
|
||||
"railway autoupdate disable".bold()
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(reason) = &disabled_reason {
|
||||
println!("Auto-updates: {} ({reason})", "disabled".yellow());
|
||||
if should_show_manual_upgrade_hint(method) {
|
||||
println!("{}", manual_upgrade_hint());
|
||||
}
|
||||
} else {
|
||||
println!("Auto-updates: {}", "enabled".green());
|
||||
}
|
||||
|
||||
println!("Install method: {}", method.name().bold());
|
||||
println!("Update strategy: {}", method.update_strategy());
|
||||
|
||||
let update = UpdateCheck::read_normalized();
|
||||
|
||||
if let Some(ref version) = update.latest_version {
|
||||
println!("Latest known version: {}", format!("v{version}").cyan());
|
||||
}
|
||||
|
||||
if let Some(ref staged) = crate::util::self_update::validated_staged_version() {
|
||||
if auto_update_enabled {
|
||||
println!(
|
||||
"Staged update: {} (will apply on next run)",
|
||||
format!("v{staged}").green()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Staged update: {} (ready, but auto-updates are currently disabled)",
|
||||
format!("v{staged}").yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref skipped) = update.skipped_version {
|
||||
println!(
|
||||
"Skipped version: {} (rolled back; auto-update resumes on next release)",
|
||||
format!("v{skipped}").yellow()
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(last_check) = update.last_update_check {
|
||||
let ago = chrono::Utc::now() - last_check;
|
||||
let label = if ago.num_hours() < 1 {
|
||||
format!("{}m ago", ago.num_minutes())
|
||||
} else if ago.num_hours() < 24 {
|
||||
format!("{}h ago", ago.num_hours())
|
||||
} else {
|
||||
format!("{}d ago", ago.num_days())
|
||||
};
|
||||
println!("Last check: {}", label);
|
||||
}
|
||||
|
||||
if let Some(update) = running_background_update() {
|
||||
println!(
|
||||
"{}",
|
||||
background_update_status_message(update, auto_update_enabled)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pending_version_prefers_cached_latest() {
|
||||
let update = UpdateCheck {
|
||||
latest_version: Some("1.2.3".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
pending_version(&update, Some("1.2.2".to_string())).as_deref(),
|
||||
Some("1.2.3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_version_falls_back_to_staged_update() {
|
||||
let update = UpdateCheck::default();
|
||||
|
||||
assert_eq!(
|
||||
pending_version(&update, Some("1.2.3".to_string())).as_deref(),
|
||||
Some("1.2.3")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_upgrade_hint_is_hidden_for_unknown_install_method() {
|
||||
assert!(!should_show_manual_upgrade_hint(InstallMethod::Unknown));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_upgrade_hint_is_shown_for_auto_upgrade_methods() {
|
||||
assert!(should_show_manual_upgrade_hint(InstallMethod::Npm));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ pub fn get_dynamic_args(cmd: clap::Command) -> clap::Command {
|
|||
}
|
||||
|
||||
pub mod add;
|
||||
pub mod autoupdate;
|
||||
pub mod bucket;
|
||||
pub mod completion;
|
||||
pub mod connect;
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
Commands::Enable => {
|
||||
let mut prefs = Preferences::read();
|
||||
prefs.telemetry_disabled = false;
|
||||
prefs.write();
|
||||
prefs.write().context("Failed to save preferences")?;
|
||||
println!("{}", "Telemetry enabled.".green());
|
||||
}
|
||||
Commands::Disable => {
|
||||
let mut prefs = Preferences::read();
|
||||
prefs.telemetry_disabled = true;
|
||||
prefs.write();
|
||||
prefs.write().context("Failed to save preferences")?;
|
||||
println!("{}", "Telemetry disabled.".yellow());
|
||||
}
|
||||
Commands::Status => {
|
||||
|
|
|
|||
|
|
@ -1,128 +1,94 @@
|
|||
use std::process::Command;
|
||||
|
||||
use crate::{consts::NON_INTERACTIVE_FAILURE, interact_or};
|
||||
use is_terminal::IsTerminal;
|
||||
|
||||
use crate::util::install_method::InstallMethod;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Upgrade the Railway CLI to the latest version
|
||||
/// Upgrade the Railway CLI to the latest version.
|
||||
/// Use `--yes` for non-interactive agent/script usage.
|
||||
#[derive(Parser)]
|
||||
pub struct Args {
|
||||
/// Check install method without upgrading
|
||||
#[clap(long)]
|
||||
#[clap(long, conflicts_with = "rollback")]
|
||||
check: bool,
|
||||
|
||||
/// Rollback to the previous version
|
||||
#[clap(long, conflicts_with = "check")]
|
||||
rollback: bool,
|
||||
|
||||
/// Run without interactive prompts (useful for agents/scripts)
|
||||
#[clap(short = 'y', long = "yes")]
|
||||
yes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum InstallMethod {
|
||||
Homebrew,
|
||||
Npm,
|
||||
Bun,
|
||||
Cargo,
|
||||
Shell,
|
||||
Scoop,
|
||||
Unknown,
|
||||
fn validate_interaction(yes: bool, is_tty: bool) -> Result<()> {
|
||||
if !yes && !is_tty {
|
||||
bail!(
|
||||
"Cannot run `railway upgrade` in non-interactive mode. Use `--yes` to continue without prompts."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl InstallMethod {
|
||||
fn detect() -> Self {
|
||||
let exe_path = match std::env::current_exe() {
|
||||
Ok(path) => path,
|
||||
Err(_) => return InstallMethod::Unknown,
|
||||
};
|
||||
|
||||
let path_str = exe_path.to_string_lossy().to_lowercase();
|
||||
|
||||
// Check for Homebrew (macOS/Linux)
|
||||
if path_str.contains("homebrew")
|
||||
|| path_str.contains("cellar")
|
||||
|| path_str.contains("linuxbrew")
|
||||
{
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
|
||||
// Check for Bun global install (must be before npm since bun uses node_modules internally)
|
||||
if path_str.contains(".bun") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
// Check for npm global install
|
||||
if path_str.contains("node_modules")
|
||||
|| path_str.contains("npm")
|
||||
|| path_str.contains(".npm")
|
||||
{
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
|
||||
// Check for Cargo install
|
||||
if path_str.contains(".cargo") && path_str.contains("bin") {
|
||||
return InstallMethod::Cargo;
|
||||
}
|
||||
|
||||
// Check for Scoop (Windows)
|
||||
if path_str.contains("scoop") {
|
||||
return InstallMethod::Scoop;
|
||||
}
|
||||
|
||||
// Check for shell script install (typically in /usr/local/bin or ~/.local/bin)
|
||||
if path_str.contains("/usr/local/bin") || path_str.contains("/.local/bin") {
|
||||
return InstallMethod::Shell;
|
||||
}
|
||||
|
||||
// Check for Windows Program Files (shell install)
|
||||
if path_str.contains("program files") || path_str.contains("programfiles") {
|
||||
return InstallMethod::Shell;
|
||||
}
|
||||
|
||||
InstallMethod::Unknown
|
||||
fn fail_if_non_interactive_requested(yes: bool, message: &str) -> Result<()> {
|
||||
if yes {
|
||||
bail!(message.to_string());
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
match self {
|
||||
InstallMethod::Homebrew => "Homebrew",
|
||||
InstallMethod::Npm => "npm",
|
||||
InstallMethod::Bun => "Bun",
|
||||
InstallMethod::Cargo => "Cargo",
|
||||
InstallMethod::Shell => "Shell script",
|
||||
InstallMethod::Scoop => "Scoop",
|
||||
InstallMethod::Unknown => "Unknown",
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn retry_command(rollback: bool, yes: bool, elevated: bool) -> String {
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if elevated {
|
||||
parts.push("sudo");
|
||||
}
|
||||
|
||||
fn upgrade_command(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
InstallMethod::Homebrew => Some("brew upgrade railway"),
|
||||
InstallMethod::Npm => Some("npm update -g @railway/cli"),
|
||||
InstallMethod::Bun => Some("bun update -g @railway/cli"),
|
||||
InstallMethod::Cargo => Some("cargo install railwayapp"),
|
||||
InstallMethod::Scoop => Some("scoop update railway"),
|
||||
InstallMethod::Shell => Some("bash <(curl -fsSL cli.new)"),
|
||||
InstallMethod::Unknown => None,
|
||||
}
|
||||
parts.push("railway");
|
||||
parts.push("upgrade");
|
||||
|
||||
if rollback {
|
||||
parts.push("--rollback");
|
||||
}
|
||||
|
||||
fn can_auto_upgrade(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InstallMethod::Homebrew
|
||||
| InstallMethod::Npm
|
||||
| InstallMethod::Bun
|
||||
| InstallMethod::Cargo
|
||||
| InstallMethod::Scoop
|
||||
)
|
||||
if yes {
|
||||
parts.push("--yes");
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
fn run_upgrade_command(method: InstallMethod) -> Result<()> {
|
||||
let (program, args): (&str, Vec<&str>) = match method {
|
||||
InstallMethod::Homebrew => ("brew", vec!["upgrade", "railway"]),
|
||||
InstallMethod::Npm => ("npm", vec!["update", "-g", "@railway/cli"]),
|
||||
InstallMethod::Bun => ("bun", vec!["update", "-g", "@railway/cli"]),
|
||||
InstallMethod::Cargo => ("cargo", vec!["install", "railwayapp"]),
|
||||
InstallMethod::Scoop => ("scoop", vec!["update", "railway"]),
|
||||
InstallMethod::Shell | InstallMethod::Unknown => {
|
||||
bail!("Cannot auto-upgrade for this install method");
|
||||
}
|
||||
};
|
||||
let (program, args) = method
|
||||
.package_manager_command()
|
||||
.context("Cannot auto-upgrade for this install method")?;
|
||||
|
||||
// Coordinate with background auto-updates: acquire the same lock and
|
||||
// check the PID file used by spawn_package_manager_update() so we
|
||||
// don't run two package-manager processes against the same global install.
|
||||
use fs2::FileExt;
|
||||
|
||||
let lock_path = crate::util::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!("A background update is already in progress. Please try again shortly.")
|
||||
})?;
|
||||
|
||||
let pid_path = crate::util::self_update::package_update_pid_path()?;
|
||||
if let Some(pid) = crate::util::check_update::is_background_update_running(&pid_path) {
|
||||
bail!(
|
||||
"A background update (pid {pid}) is already running. \
|
||||
Please wait for it to finish or try again shortly."
|
||||
);
|
||||
}
|
||||
|
||||
println!("{} {} {}", "Running:".bold(), program, args.join(" "));
|
||||
println!();
|
||||
|
|
@ -132,6 +98,9 @@ fn run_upgrade_command(method: InstallMethod) -> Result<()> {
|
|||
.status()
|
||||
.context(format!("Failed to execute {}", program))?;
|
||||
|
||||
// Clean up stale PID file from a previous background updater.
|
||||
let _ = std::fs::remove_file(&pid_path);
|
||||
|
||||
if !status.success() {
|
||||
bail!(
|
||||
"Upgrade command failed with exit code: {}",
|
||||
|
|
@ -160,7 +129,39 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
interact_or!(NON_INTERACTIVE_FAILURE);
|
||||
if args.rollback {
|
||||
if !method.can_self_update() {
|
||||
bail!(
|
||||
"Rollback is only supported for shell-script installs.\n\
|
||||
Detected install method: {}. Use your package manager to \
|
||||
install a specific version instead.",
|
||||
method.name()
|
||||
);
|
||||
}
|
||||
validate_interaction(args.yes, std::io::stdout().is_terminal())?;
|
||||
if !method.can_write_binary() {
|
||||
println!(
|
||||
"{}",
|
||||
"Cannot rollback: the CLI binary is not writable by the current user.".yellow()
|
||||
);
|
||||
println!();
|
||||
if cfg!(windows) {
|
||||
println!("To rollback, run the terminal as Administrator and retry:");
|
||||
println!(" {}", retry_command(true, args.yes, false).bold());
|
||||
} else {
|
||||
println!("To rollback, re-run with elevated permissions:");
|
||||
println!(" {}", retry_command(true, args.yes, true).bold());
|
||||
}
|
||||
fail_if_non_interactive_requested(
|
||||
args.yes,
|
||||
"Rollback could not be completed because the CLI binary is not writable by the current user.",
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
return crate::util::self_update::rollback(args.yes);
|
||||
}
|
||||
|
||||
validate_interaction(args.yes, std::io::stdout().is_terminal())?;
|
||||
|
||||
println!(
|
||||
"{} {} ({})",
|
||||
|
|
@ -169,7 +170,35 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
method.name()
|
||||
);
|
||||
|
||||
// Order matters: check self-update first, then unknown, then package manager.
|
||||
match method {
|
||||
method if method.can_self_update() && method.can_write_binary() => {
|
||||
println!();
|
||||
crate::util::self_update::self_update_interactive().await?;
|
||||
}
|
||||
method if method.can_self_update() => {
|
||||
// Shell install but binary location not writable by current user
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
"Cannot upgrade: the CLI binary is not writable by the current user.".yellow()
|
||||
);
|
||||
println!();
|
||||
if cfg!(windows) {
|
||||
println!("To upgrade, run the terminal as Administrator and retry:");
|
||||
println!(" {}", retry_command(false, args.yes, false).bold());
|
||||
} else {
|
||||
println!("To upgrade, either:");
|
||||
println!(" 1. Re-run with elevated permissions:");
|
||||
println!(" {}", retry_command(false, args.yes, true).bold());
|
||||
println!(" 2. Reinstall using the install script:");
|
||||
println!(" {}", "bash <(curl -fsSL cli.new)".bold());
|
||||
}
|
||||
fail_if_non_interactive_requested(
|
||||
args.yes,
|
||||
"Upgrade could not be completed because the CLI binary is not writable by the current user.",
|
||||
)?;
|
||||
}
|
||||
InstallMethod::Unknown => {
|
||||
println!();
|
||||
println!(
|
||||
|
|
@ -199,27 +228,77 @@ pub async fn command(args: Args) -> Result<()> {
|
|||
"For more information, visit: {}",
|
||||
"https://docs.railway.com/guides/cli".purple()
|
||||
);
|
||||
}
|
||||
InstallMethod::Shell => {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
"Detected shell script installation. To upgrade, run:".yellow()
|
||||
);
|
||||
println!();
|
||||
println!(" {}", "bash <(curl -fsSL cli.new)".cyan());
|
||||
println!();
|
||||
println!(
|
||||
"For more information, visit: {}",
|
||||
"https://docs.railway.com/guides/cli".purple()
|
||||
);
|
||||
fail_if_non_interactive_requested(
|
||||
args.yes,
|
||||
"Automatic upgrade could not be completed because the install method could not be detected.",
|
||||
)?;
|
||||
}
|
||||
method if method.can_auto_upgrade() => {
|
||||
println!();
|
||||
run_upgrade_command(method)?;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
InstallMethod::Shell => {
|
||||
// Shell install on a platform where self-update is unsupported
|
||||
// (e.g. FreeBSD). Show the reinstall command.
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
"Self-update is not available on this platform. To upgrade, re-run the install script:".yellow()
|
||||
);
|
||||
println!();
|
||||
println!(" {}", "bash <(curl -fsSL cli.new)".cyan());
|
||||
fail_if_non_interactive_requested(
|
||||
args.yes,
|
||||
"Automatic upgrade could not be completed because self-update is not available on this platform.",
|
||||
)?;
|
||||
}
|
||||
_ => {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
"Could not determine an upgrade strategy for this install method.".yellow()
|
||||
);
|
||||
println!(
|
||||
"Please upgrade manually. For more information, visit: {}",
|
||||
"https://docs.railway.com/guides/cli".purple()
|
||||
);
|
||||
fail_if_non_interactive_requested(
|
||||
args.yes,
|
||||
"Automatic upgrade could not be completed for this install method.",
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Args, validate_interaction};
|
||||
use clap::Parser;
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_check_and_rollback_together() {
|
||||
let result = Args::try_parse_from(["railway", "--check", "--rollback"]);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_accepts_yes_with_rollback() {
|
||||
let result = Args::try_parse_from(["railway", "--yes", "--rollback"]);
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_upgrade_does_not_require_yes() {
|
||||
assert!(validate_interaction(false, true).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_interactive_upgrade_requires_yes() {
|
||||
assert!(validate_interaction(false, false).is_err());
|
||||
assert!(validate_interaction(true, false).is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub const RAILWAY_API_TOKEN_ENV: &str = "RAILWAY_API_TOKEN";
|
|||
pub const RAILWAY_PROJECT_ID_ENV: &str = "RAILWAY_PROJECT_ID";
|
||||
pub const RAILWAY_ENVIRONMENT_ID_ENV: &str = "RAILWAY_ENVIRONMENT_ID";
|
||||
pub const RAILWAY_SERVICE_ID_ENV: &str = "RAILWAY_SERVICE_ID";
|
||||
pub const RAILWAY_STAGE_UPDATE_ENV: &str = "_RAILWAY_STAGE_UPDATE";
|
||||
|
||||
pub const TICK_STRING: &str = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ ";
|
||||
pub const NON_INTERACTIVE_FAILURE: &str = "This command is only available in interactive mode";
|
||||
|
|
|
|||
231
src/main.rs
231
src/main.rs
|
|
@ -29,6 +29,7 @@ mod telemetry;
|
|||
// Specify the modules you want to include in the commands_enum! macro
|
||||
commands!(
|
||||
add,
|
||||
autoupdate,
|
||||
bucket,
|
||||
completion,
|
||||
connect,
|
||||
|
|
@ -69,68 +70,211 @@ commands!(
|
|||
functions(function, func, fn, funcs, fns)
|
||||
);
|
||||
|
||||
fn spawn_update_task() -> tokio::task::JoinHandle<anyhow::Result<Option<String>>> {
|
||||
/// Groups the state needed to decide whether and how to check for / dispatch
|
||||
/// a background update.
|
||||
struct UpdateContext {
|
||||
known_version: Option<String>,
|
||||
auto_update_enabled: bool,
|
||||
skipped_version: Option<String>,
|
||||
check_gate_armed: bool,
|
||||
}
|
||||
|
||||
/// Routes a pending version to the appropriate background updater.
|
||||
fn try_dispatch_update(
|
||||
version: &str,
|
||||
skipped_version: Option<&str>,
|
||||
method: &util::install_method::InstallMethod,
|
||||
) {
|
||||
if skipped_version == Some(version) {
|
||||
return;
|
||||
}
|
||||
if method.can_self_update() && method.can_write_binary() {
|
||||
let _ = util::self_update::spawn_background_download(version);
|
||||
} else if method.can_auto_run_package_manager() {
|
||||
let _ = util::check_update::spawn_package_manager_update(*method);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_update_task(
|
||||
ctx: UpdateContext,
|
||||
) -> tokio::task::JoinHandle<anyhow::Result<Option<String>>> {
|
||||
tokio::spawn(async move {
|
||||
// outputting would break json output on CI
|
||||
if !std::io::stdout().is_terminal() {
|
||||
anyhow::bail!("Stdout is not a terminal");
|
||||
let method = util::install_method::InstallMethod::detect();
|
||||
|
||||
// Safe to eagerly dispatch from cache: the gate means no API call
|
||||
// will race with a newer version during this invocation.
|
||||
if ctx.auto_update_enabled && ctx.check_gate_armed {
|
||||
if let Some(ref version) = ctx.known_version {
|
||||
try_dispatch_update(version, ctx.skipped_version.as_deref(), &method);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip the network check entirely when auto-update is disabled
|
||||
// and there is no TTY to show a banner on (e.g. CI / scripts).
|
||||
let (from_cache, latest_version) =
|
||||
if !ctx.auto_update_enabled && !std::io::stdout().is_terminal() {
|
||||
(ctx.known_version.is_some(), ctx.known_version)
|
||||
} else {
|
||||
match util::check_update::check_update(false).await {
|
||||
Ok(Some(v)) => (false, Some(v)),
|
||||
Ok(None) | Err(_) => (ctx.known_version.is_some(), ctx.known_version),
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref version) = latest_version {
|
||||
if ctx.auto_update_enabled && !from_cache {
|
||||
try_dispatch_update(version, ctx.skipped_version.as_deref(), &method);
|
||||
}
|
||||
}
|
||||
let latest_version = util::check_update::check_update(false).await?;
|
||||
|
||||
Ok(latest_version)
|
||||
})
|
||||
}
|
||||
|
||||
/// Waits for the background update task to finish, but no longer than a
|
||||
/// couple of seconds so that short-lived commands are not noticeably delayed.
|
||||
/// The heavy download work runs in a detached process, so this timeout only
|
||||
/// gates the fast version-check API call.
|
||||
async fn handle_update_task(
|
||||
handle: Option<tokio::task::JoinHandle<anyhow::Result<Option<String>>>>,
|
||||
) {
|
||||
use std::time::Duration;
|
||||
|
||||
if let Some(handle) = handle {
|
||||
match handle.await {
|
||||
Ok(Ok(_)) => {} // Task completed successfully
|
||||
Ok(Err(e)) => {
|
||||
if !std::io::stdout().is_terminal() {
|
||||
eprintln!("Failed to check for updates (not fatal)");
|
||||
eprintln!("{e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Check Updates: Task panicked or failed to execute.");
|
||||
eprintln!("{e}");
|
||||
}
|
||||
match tokio::time::timeout(Duration::from_secs(1), handle).await {
|
||||
Ok(Ok(Ok(_))) => {}
|
||||
Ok(Ok(Err(_))) | Ok(Err(_)) => {} // update error or task panic — non-fatal
|
||||
Err(_) => {} // timeout — the API check was slow; next invocation retries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs in a detached child process to download and stage an update.
|
||||
async fn background_stage_update(version: &str) -> Result<()> {
|
||||
use util::check_update::UpdateCheck;
|
||||
|
||||
let result = async {
|
||||
if telemetry::is_auto_update_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match util::self_update::download_and_stage(version).await {
|
||||
Ok(true) => {} // Staged successfully; cache stays until try_apply_staged() succeeds.
|
||||
Ok(false) => {} // Lock held by another process, will retry
|
||||
Err(_) => UpdateCheck::record_download_failure(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Ok(pid_path) = util::self_update::download_update_pid_path() {
|
||||
let _ = std::fs::remove_file(pid_path);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let args = build_args().try_get_matches();
|
||||
let check_updates_handle = if std::io::stdout().is_terminal() {
|
||||
let update = UpdateCheck::read().unwrap_or_default();
|
||||
// Internal: detached background download spawned by a prior invocation.
|
||||
if let Ok(version) = std::env::var(consts::RAILWAY_STAGE_UPDATE_ENV) {
|
||||
return background_stage_update(&version).await;
|
||||
}
|
||||
|
||||
if let Some(latest_version) = update.latest_version {
|
||||
if matches!(
|
||||
compare_semver(env!("CARGO_PKG_VERSION"), &latest_version),
|
||||
Ordering::Less
|
||||
) {
|
||||
println!(
|
||||
let args = build_args().try_get_matches();
|
||||
let is_tty = std::io::stdout().is_terminal();
|
||||
// Help, version, and parse-error paths are read-only: no staged-binary
|
||||
// apply, no background update spawn, no extra latency.
|
||||
let is_help_or_error = args.as_ref().is_err();
|
||||
|
||||
// Peek at the subcommand early so we can skip the staged-update
|
||||
// apply and background updater when the user is explicitly managing
|
||||
// updates (`railway upgrade` or `railway autoupdate`).
|
||||
// Check raw args too so that help/error paths (where clap returns Err)
|
||||
// are also detected — e.g. `railway upgrade --help` should not apply
|
||||
// a staged update as a side effect.
|
||||
let raw_subcommand = std::env::args().nth(1).filter(|a| !a.starts_with('-'));
|
||||
|
||||
let is_update_management_cmd = matches!(
|
||||
raw_subcommand.as_deref(),
|
||||
Some("upgrade" | "autoupdate" | "check_updates" | "check-updates")
|
||||
);
|
||||
// Bare `railway` and `railway help` show help — treat as read-only so
|
||||
// first-time users don't trigger update side effects.
|
||||
let is_read_only_invocation = is_help_or_error
|
||||
|| raw_subcommand.is_none()
|
||||
|| matches!(raw_subcommand.as_deref(), Some("help"));
|
||||
let auto_update_enabled = !telemetry::is_auto_update_disabled();
|
||||
|
||||
// Non-TTY invocations are a supported path for coding agents and other
|
||||
// automated CLI users. They are allowed to refresh the update cache and
|
||||
// kick off background installs, but we keep staged-binary apply TTY-only
|
||||
// so the running binary never changes under a scripted invocation.
|
||||
let auto_applied_version =
|
||||
if auto_update_enabled && is_tty && !is_update_management_cmd && !is_read_only_invocation {
|
||||
util::self_update::try_apply_staged()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let update = UpdateCheck::read_normalized();
|
||||
let skipped_version = update.skipped_version.clone();
|
||||
let check_gate_armed = update
|
||||
.last_update_check
|
||||
.map(|t| (chrono::Utc::now() - t) < chrono::Duration::hours(12))
|
||||
.unwrap_or(false);
|
||||
|
||||
// Pass any pending version to spawn_update_task so it can skip the
|
||||
// 12h short-circuit and retry a download that timed out in a
|
||||
// prior run. The background task clears latest_version on success.
|
||||
//
|
||||
// If the running binary has already caught up to (or surpassed) the
|
||||
// cached version, clear the stale cache so spawn_update_task falls
|
||||
// through to a fresh check_update() and can discover newer releases.
|
||||
let known_pending = update.latest_version;
|
||||
|
||||
// Show the "new version available" banner only for TTY users. Coding
|
||||
// agents and other non-interactive callers should still refresh update
|
||||
// state in the background, but they should not receive human-facing
|
||||
// upgrade prompts in command output.
|
||||
//
|
||||
// When auto-update is disabled via preference, we still show the banner
|
||||
// to cautious interactive users who want release visibility. Suppress it
|
||||
// when disabled via env var or CI, where extra output is noise.
|
||||
let env_or_ci_suppressed = telemetry::is_auto_update_disabled_by_env() || Configs::env_is_ci();
|
||||
if is_tty && !env_or_ci_suppressed {
|
||||
if let Some(ref latest_version) = known_pending {
|
||||
let is_skipped = skipped_version.as_deref() == Some(latest_version.as_str());
|
||||
if !is_skipped
|
||||
&& matches!(
|
||||
compare_semver(env!("CARGO_PKG_VERSION"), latest_version),
|
||||
Ordering::Less
|
||||
)
|
||||
{
|
||||
eprintln!(
|
||||
"{} v{} visit {} for more info",
|
||||
"New version available:".green().bold(),
|
||||
latest_version.yellow(),
|
||||
"https://docs.railway.com/guides/cli".purple(),
|
||||
);
|
||||
}
|
||||
let update = UpdateCheck {
|
||||
last_update_check: Some(chrono::Utc::now()),
|
||||
latest_version: None,
|
||||
};
|
||||
update
|
||||
.write()
|
||||
.context("Failed to save time since last update check")?;
|
||||
}
|
||||
}
|
||||
|
||||
Some(spawn_update_task())
|
||||
} else {
|
||||
// Spawn the background version check for all invocations (including
|
||||
// non-TTY) so the version cache stays fresh for both humans and coding
|
||||
// agents. Non-TTY callers are a first-class auto-update path: they may
|
||||
// trigger background downloads/package-manager installs, but staged-binary
|
||||
// apply and user-facing banners remain TTY-only.
|
||||
let check_updates_handle = if is_update_management_cmd || is_read_only_invocation {
|
||||
None
|
||||
} else {
|
||||
Some(spawn_update_task(UpdateContext {
|
||||
known_version: known_pending,
|
||||
auto_update_enabled,
|
||||
skipped_version,
|
||||
check_gate_armed,
|
||||
}))
|
||||
};
|
||||
|
||||
// https://github.com/clap-rs/clap/blob/cb2352f84a7663f32a89e70f01ad24446d5fa1e2/clap_builder/src/error/mod.rs#L210-L215
|
||||
|
|
@ -157,6 +301,7 @@ async fn main() -> Result<()> {
|
|||
"completion",
|
||||
"docs",
|
||||
"upgrade",
|
||||
"autoupdate",
|
||||
"telemetry_cmd",
|
||||
"check_updates",
|
||||
];
|
||||
|
|
@ -176,6 +321,22 @@ async fn main() -> Result<()> {
|
|||
|
||||
let exec_result = exec_cli(cli).await;
|
||||
|
||||
// Send telemetry for silent auto-update apply (after auth is available).
|
||||
if let Some(ref version) = auto_applied_version {
|
||||
telemetry::send(telemetry::CliTrackEvent {
|
||||
command: "autoupdate_apply".to_string(),
|
||||
sub_command: Some(version.clone()),
|
||||
success: true,
|
||||
error_message: None,
|
||||
duration_ms: 0,
|
||||
cli_version: env!("CARGO_PKG_VERSION"),
|
||||
os: std::env::consts::OS,
|
||||
arch: std::env::consts::ARCH,
|
||||
is_ci: Configs::env_is_ci(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Err(e) = exec_result {
|
||||
if e.root_cause().to_string() == inquire::InquireError::OperationInterrupted.to_string() {
|
||||
return Ok(()); // Exit gracefully if interrupted
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
use anyhow::Context;
|
||||
|
||||
use crate::client::{GQLClient, post_graphql};
|
||||
use crate::config::Configs;
|
||||
use crate::gql::mutations::{self, cli_event_track};
|
||||
|
|
@ -25,6 +27,8 @@ fn env_var_is_truthy(name: &str) -> bool {
|
|||
pub struct Preferences {
|
||||
#[serde(default)]
|
||||
pub telemetry_disabled: bool,
|
||||
#[serde(default)]
|
||||
pub auto_update_disabled: bool,
|
||||
}
|
||||
|
||||
impl Preferences {
|
||||
|
|
@ -39,12 +43,10 @@ impl Preferences {
|
|||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn write(&self) {
|
||||
if let Some(path) = Self::path() {
|
||||
let _ = serde_json::to_string(self)
|
||||
.ok()
|
||||
.map(|contents| std::fs::write(path, contents));
|
||||
}
|
||||
pub fn write(&self) -> anyhow::Result<()> {
|
||||
let path = Self::path().context("Failed to determine home directory")?;
|
||||
let contents = serde_json::to_string(self)?;
|
||||
crate::util::write_atomic(&path, &contents)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +54,16 @@ pub fn is_telemetry_disabled_by_env() -> bool {
|
|||
env_var_is_truthy("DO_NOT_TRACK") || env_var_is_truthy("RAILWAY_NO_TELEMETRY")
|
||||
}
|
||||
|
||||
pub fn is_auto_update_disabled_by_env() -> bool {
|
||||
env_var_is_truthy("RAILWAY_NO_AUTO_UPDATE")
|
||||
}
|
||||
|
||||
pub fn is_auto_update_disabled() -> bool {
|
||||
is_auto_update_disabled_by_env()
|
||||
|| Preferences::read().auto_update_disabled
|
||||
|| crate::config::Configs::env_is_ci()
|
||||
}
|
||||
|
||||
fn is_telemetry_disabled() -> bool {
|
||||
is_telemetry_disabled_by_env() || Preferences::read().telemetry_disabled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,23 +5,147 @@ 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 nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap();
|
||||
let pid = std::process::id();
|
||||
// almost guaranteed no collision- can be upgraded to uuid if necessary.
|
||||
let tmp_path = path.with_extension(format!("tmp.{pid}-{nanos}.json"));
|
||||
let contents = serde_json::to_string_pretty(&self)?;
|
||||
std::fs::write(&tmp_path, contents)?;
|
||||
std::fs::rename(&tmp_path, &path)?;
|
||||
Ok(())
|
||||
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> {
|
||||
|
|
@ -42,12 +166,15 @@ 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 {
|
||||
if chrono::Utc::now().date_naive() == last_update_check.date_naive() && !force {
|
||||
bail!("Update check already ran today");
|
||||
// 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::new();
|
||||
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")
|
||||
|
|
@ -58,13 +185,227 @@ pub async fn check_update(force: bool) -> anyhow::Result<Option<String>> {
|
|||
|
||||
match compare_semver(env!("CARGO_PKG_VERSION"), latest_version) {
|
||||
Ordering::Less => {
|
||||
let update = UpdateCheck {
|
||||
last_update_check: Some(chrono::Utc::now()),
|
||||
latest_version: Some(latest_version.to_owned()),
|
||||
};
|
||||
update.write()?;
|
||||
// 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()))
|
||||
}
|
||||
_ => Ok(None),
|
||||
_ => {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
251
src/util/install_method.rs
Normal file
251
src/util/install_method.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InstallMethod {
|
||||
Homebrew,
|
||||
Npm,
|
||||
Bun,
|
||||
Cargo,
|
||||
Shell,
|
||||
Scoop,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl InstallMethod {
|
||||
pub fn detect() -> Self {
|
||||
let exe_path = match std::env::current_exe() {
|
||||
Ok(path) => path,
|
||||
Err(_) => return InstallMethod::Unknown,
|
||||
};
|
||||
|
||||
// Resolve symlinks so that e.g. /usr/local/bin/railway (Intel
|
||||
// Homebrew symlink) is followed to /usr/local/Cellar/… and
|
||||
// correctly classified as Homebrew rather than Shell.
|
||||
let exe_path = exe_path.canonicalize().unwrap_or(exe_path);
|
||||
|
||||
let path_str = exe_path.to_string_lossy().to_lowercase();
|
||||
|
||||
if path_str.contains("homebrew")
|
||||
|| path_str.contains("cellar")
|
||||
|| path_str.contains("linuxbrew")
|
||||
{
|
||||
return InstallMethod::Homebrew;
|
||||
}
|
||||
|
||||
// Check for Bun global install (must be before npm since bun uses node_modules internally)
|
||||
if path_str.contains(".bun") {
|
||||
return InstallMethod::Bun;
|
||||
}
|
||||
|
||||
// pnpm paths contain "npm" as a substring — check before npm.
|
||||
if path_str.contains("pnpm") {
|
||||
return InstallMethod::Unknown;
|
||||
}
|
||||
|
||||
if path_str.contains("node_modules")
|
||||
|| path_str.contains("npm")
|
||||
|| path_str.contains(".npm")
|
||||
{
|
||||
return InstallMethod::Npm;
|
||||
}
|
||||
|
||||
if path_str.contains(".cargo") && path_str.contains("bin") {
|
||||
return InstallMethod::Cargo;
|
||||
}
|
||||
|
||||
if path_str.contains("scoop") {
|
||||
return InstallMethod::Scoop;
|
||||
}
|
||||
|
||||
// Cargo's `CARGO_INSTALL_ROOT` can place binaries in standard paths
|
||||
// like /usr/local/bin or ~/.local/bin. Check for the `.crates.toml`
|
||||
// marker *before* the shell-path heuristic so these are not
|
||||
// misclassified as Shell installs.
|
||||
if exe_path
|
||||
.parent()
|
||||
.and_then(|bin| bin.parent())
|
||||
.map(|root| root.join(".crates.toml").exists())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return InstallMethod::Cargo;
|
||||
}
|
||||
|
||||
if path_str.contains("/usr/local/bin") || path_str.contains("/.local/bin") {
|
||||
return InstallMethod::Shell;
|
||||
}
|
||||
|
||||
if path_str.contains("program files") || path_str.contains("programfiles") {
|
||||
return InstallMethod::Shell;
|
||||
}
|
||||
|
||||
// Paths owned by system package managers — must be checked before
|
||||
// the catch-all so we don't misclassify them as Shell.
|
||||
const SYSTEM_PATHS: &[&str] = &[
|
||||
"/usr/bin",
|
||||
"/usr/sbin",
|
||||
"/nix/",
|
||||
"nix-profile",
|
||||
"/snap/",
|
||||
"/flatpak/",
|
||||
];
|
||||
if SYSTEM_PATHS.iter().any(|p| path_str.contains(p)) {
|
||||
return InstallMethod::Unknown;
|
||||
}
|
||||
|
||||
// Version managers install binaries under their own directory trees.
|
||||
// Exclude them so the catch-all doesn't misclassify a managed binary
|
||||
// as a shell install and attempt to self-replace it.
|
||||
const VERSION_MANAGER_PATHS: &[&str] = &[
|
||||
".asdf/", ".mise/", ".rtx/", ".proto/", ".volta/", ".fnm/", ".nodenv/", ".rbenv/",
|
||||
".pyenv/",
|
||||
];
|
||||
if VERSION_MANAGER_PATHS.iter().any(|p| path_str.contains(p)) {
|
||||
return InstallMethod::Unknown;
|
||||
}
|
||||
|
||||
// Catch-all: if the binary lives in any directory named "bin" and no
|
||||
// package manager, system path, or version manager was detected, it
|
||||
// was most likely installed via the shell installer (possibly with a
|
||||
// custom --bin-dir like ~/tools/bin or /opt/railway/bin).
|
||||
// Note: Cargo's CARGO_INSTALL_ROOT is already caught by the
|
||||
// `.crates.toml` check above, so no need to re-check here.
|
||||
if exe_path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n == "bin")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return InstallMethod::Shell;
|
||||
}
|
||||
|
||||
InstallMethod::Unknown
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
InstallMethod::Homebrew => "Homebrew",
|
||||
InstallMethod::Npm => "npm",
|
||||
InstallMethod::Bun => "Bun",
|
||||
InstallMethod::Cargo => "Cargo",
|
||||
InstallMethod::Shell => "Shell script",
|
||||
InstallMethod::Scoop => "Scoop",
|
||||
InstallMethod::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upgrade_command(&self) -> Option<String> {
|
||||
if let Some((program, args)) = self.package_manager_command() {
|
||||
return Some(format!("{} {}", program, args.join(" ")));
|
||||
}
|
||||
match self {
|
||||
InstallMethod::Shell => Some("bash <(curl -fsSL cli.new)".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_auto_upgrade(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
InstallMethod::Homebrew
|
||||
| InstallMethod::Npm
|
||||
| InstallMethod::Bun
|
||||
| InstallMethod::Cargo
|
||||
| InstallMethod::Scoop
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this install method supports direct binary self-update
|
||||
/// (download from GitHub Releases and replace in place).
|
||||
/// Only Shell installs on platforms with published release assets qualify.
|
||||
/// Unknown means we don't know where the binary came from, so
|
||||
/// self-updating it could conflict with an undetected package manager.
|
||||
pub fn can_self_update(&self) -> bool {
|
||||
matches!(self, InstallMethod::Shell) && is_self_update_platform()
|
||||
}
|
||||
|
||||
/// Whether the current process can write to the directory containing the
|
||||
/// binary. Returns `false` for paths like `/usr/local/bin` that were
|
||||
/// installed with `sudo` and are not writable by the current user.
|
||||
pub fn can_write_binary(&self) -> bool {
|
||||
let exe_path = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let dir = match exe_path.parent() {
|
||||
Some(d) => d,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// Try creating a temp file in the same directory — the most reliable
|
||||
// cross-platform writability check (accounts for ACLs, mount flags…).
|
||||
let probe = dir.join(".railway-write-probe");
|
||||
let writable = std::fs::File::create(&probe).is_ok();
|
||||
let _ = std::fs::remove_file(&probe);
|
||||
writable
|
||||
}
|
||||
|
||||
/// Whether this install method supports auto-running the package manager
|
||||
/// in the background. Homebrew and Cargo are excluded because they can
|
||||
/// take several minutes and would keep a detached process alive far longer
|
||||
/// than is acceptable for a transparent background update.
|
||||
///
|
||||
/// Also checks that the package manager's global install directory is
|
||||
/// writable by the current user, so we don't spawn a doomed `npm update -g`
|
||||
/// (installed via `sudo`) that fails immediately on every invocation.
|
||||
pub fn can_auto_run_package_manager(&self) -> bool {
|
||||
if !matches!(
|
||||
self,
|
||||
InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Probe writability of the directory containing the binary — if we
|
||||
// can't write there, the package manager update will fail anyway.
|
||||
self.can_write_binary()
|
||||
}
|
||||
|
||||
/// Human-readable description of the auto-update strategy for this install method.
|
||||
/// Reflects the actual runtime behaviour by checking platform support and
|
||||
/// binary writability, so `autoupdate status` never overpromises.
|
||||
pub fn update_strategy(&self) -> &'static str {
|
||||
match self {
|
||||
InstallMethod::Shell if self.can_self_update() && self.can_write_binary() => {
|
||||
"Background download + auto-swap"
|
||||
}
|
||||
InstallMethod::Shell if self.can_self_update() => {
|
||||
"Notification only (binary not writable)"
|
||||
}
|
||||
InstallMethod::Shell => "Notification only (unsupported platform)",
|
||||
InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop
|
||||
if self.can_auto_run_package_manager() =>
|
||||
{
|
||||
"Auto-run package manager"
|
||||
}
|
||||
InstallMethod::Npm | InstallMethod::Bun | InstallMethod::Scoop => {
|
||||
"Notification only (binary not writable)"
|
||||
}
|
||||
InstallMethod::Homebrew | InstallMethod::Cargo | InstallMethod::Unknown => {
|
||||
"Notification only (manual upgrade)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the program and arguments to run the package manager upgrade.
|
||||
pub fn package_manager_command(&self) -> Option<(&'static str, Vec<&'static str>)> {
|
||||
match self {
|
||||
InstallMethod::Homebrew => Some(("brew", vec!["upgrade", "railway"])),
|
||||
InstallMethod::Npm => Some(("npm", vec!["update", "-g", "@railway/cli"])),
|
||||
InstallMethod::Bun => Some(("bun", vec!["update", "-g", "@railway/cli"])),
|
||||
InstallMethod::Cargo => Some(("cargo", vec!["install", "railwayapp", "--locked"])),
|
||||
InstallMethod::Scoop => Some(("scoop", vec!["update", "railway"])),
|
||||
InstallMethod::Shell | InstallMethod::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the release pipeline publishes a binary for the
|
||||
/// current OS, i.e. self-update can actually download an asset.
|
||||
/// FreeBSD is recognized by the install script but no release asset is
|
||||
/// published, so it must not enter the self-update path.
|
||||
fn is_self_update_platform() -> bool {
|
||||
matches!(std::env::consts::OS, "macos" | "linux" | "windows")
|
||||
}
|
||||
|
|
@ -1,9 +1,96 @@
|
|||
pub mod check_update;
|
||||
pub mod compare_semver;
|
||||
pub mod install_method;
|
||||
pub mod logs;
|
||||
pub mod progress;
|
||||
pub mod prompt;
|
||||
pub mod retry;
|
||||
pub mod self_update;
|
||||
pub mod time;
|
||||
pub mod two_factor;
|
||||
pub mod watcher;
|
||||
|
||||
/// Spawns a command in a fully detached process group so it survives after the
|
||||
/// parent exits and Ctrl+C does not propagate. stdout/stderr are redirected to
|
||||
/// the given log file.
|
||||
pub fn spawn_detached(
|
||||
cmd: &mut std::process::Command,
|
||||
log_path: &std::path::Path,
|
||||
) -> anyhow::Result<std::process::Child> {
|
||||
if let Some(parent) = log_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let log_file = std::fs::File::create(log_path)?;
|
||||
let log_stderr = log_file.try_clone()?;
|
||||
|
||||
cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(log_file)
|
||||
.stderr(log_stderr);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP);
|
||||
}
|
||||
|
||||
cmd.spawn().map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Atomically writes `contents` to `path` via a temp file + rename.
|
||||
/// The temp filename includes PID and nanosecond timestamp to avoid
|
||||
/// collisions between concurrent processes.
|
||||
pub fn write_atomic(path: &std::path::Path, contents: &str) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let pid = std::process::id();
|
||||
let nanos = chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default();
|
||||
let tmp_path = path.with_extension(format!("tmp.{pid}-{nanos}.json"));
|
||||
std::fs::write(&tmp_path, contents)?;
|
||||
rename_replacing(&tmp_path, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Renames `from` to `to`, overwriting `to` if it already exists.
|
||||
/// On Unix `std::fs::rename` already replaces the destination atomically.
|
||||
/// On Windows we use `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING` for an
|
||||
/// atomic single-syscall replace.
|
||||
pub fn rename_replacing(from: &std::path::Path, to: &std::path::Path) -> std::io::Result<()> {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::fs::rename(from, to)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use winapi::um::winbase::{MOVEFILE_REPLACE_EXISTING, MoveFileExW};
|
||||
|
||||
fn to_wide(path: &std::path::Path) -> Vec<u16> {
|
||||
path.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
let from_wide = to_wide(from);
|
||||
let to_wide = to_wide(to);
|
||||
let ret = unsafe {
|
||||
MoveFileExW(
|
||||
from_wide.as_ptr(),
|
||||
to_wide.as_ptr(),
|
||||
MOVEFILE_REPLACE_EXISTING,
|
||||
)
|
||||
};
|
||||
if ret == 0 {
|
||||
Err(std::io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
969
src/util/self_update.rs
Normal file
969
src/util/self_update.rs
Normal file
|
|
@ -0,0 +1,969 @@
|
|||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use colored::Colorize;
|
||||
|
||||
/// Maximum age for a staged update before it's considered stale and cleaned up.
|
||||
const STAGED_UPDATE_MAX_AGE_DAYS: i64 = 7;
|
||||
|
||||
fn railway_dir() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("Failed to get home directory")?;
|
||||
Ok(home.join(".railway"))
|
||||
}
|
||||
|
||||
fn staged_update_dir() -> Result<PathBuf> {
|
||||
Ok(railway_dir()?.join("staged-update"))
|
||||
}
|
||||
|
||||
fn backups_dir() -> Result<PathBuf> {
|
||||
Ok(railway_dir()?.join("backups"))
|
||||
}
|
||||
|
||||
pub fn update_lock_path() -> Result<PathBuf> {
|
||||
Ok(railway_dir()?.join("update.lock"))
|
||||
}
|
||||
|
||||
pub fn package_update_pid_path() -> Result<PathBuf> {
|
||||
Ok(railway_dir()?.join("package-update.pid"))
|
||||
}
|
||||
|
||||
pub fn download_update_pid_path() -> Result<PathBuf> {
|
||||
Ok(railway_dir()?.join("download-update.pid"))
|
||||
}
|
||||
|
||||
pub fn package_update_lock_path() -> Result<PathBuf> {
|
||||
Ok(railway_dir()?.join("package-update.lock"))
|
||||
}
|
||||
|
||||
pub fn auto_update_log_path() -> Result<PathBuf> {
|
||||
Ok(railway_dir()?.join("auto-update.log"))
|
||||
}
|
||||
|
||||
/// Returns the compile-time target triple of this binary, ensuring the
|
||||
/// self-updater fetches the exact same ABI variant (e.g. gnu vs musl).
|
||||
/// The value is set by `build.rs` via `BUILD_TARGET`.
|
||||
fn detect_target_triple() -> Result<&'static str> {
|
||||
Ok(env!("BUILD_TARGET"))
|
||||
}
|
||||
|
||||
const RELEASE_BASE_URL: &str = "https://github.com/railwayapp/cli/releases/download";
|
||||
|
||||
fn release_asset_name(version: &str, target: &str) -> String {
|
||||
// i686-pc-windows-gnu is cross-compiled on Linux and only ships as tar.gz.
|
||||
let ext = if target.contains("windows") && target != "i686-pc-windows-gnu" {
|
||||
"zip"
|
||||
} else {
|
||||
"tar.gz"
|
||||
};
|
||||
format!("railway-v{version}-{target}.{ext}")
|
||||
}
|
||||
|
||||
fn release_url(version: &str, asset_name: &str) -> String {
|
||||
format!("{RELEASE_BASE_URL}/v{version}/{asset_name}")
|
||||
}
|
||||
|
||||
fn binary_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"railway.exe"
|
||||
} else {
|
||||
"railway"
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire_update_lock(
|
||||
lock_path: &Path,
|
||||
wait_for_lock: bool,
|
||||
busy_message: &str,
|
||||
) -> Result<std::fs::File> {
|
||||
use fs2::FileExt;
|
||||
|
||||
if let Some(parent) = lock_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let lock_file =
|
||||
std::fs::File::create(lock_path).context("Failed to create update lock file")?;
|
||||
|
||||
if wait_for_lock {
|
||||
lock_file
|
||||
.lock_exclusive()
|
||||
.with_context(|| busy_message.to_string())?;
|
||||
} else {
|
||||
lock_file
|
||||
.try_lock_exclusive()
|
||||
.map_err(|_| anyhow::anyhow!(busy_message.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(lock_file)
|
||||
}
|
||||
|
||||
fn shell_update_busy_message_for_pid_path(pid_path: &Path) -> String {
|
||||
match crate::util::check_update::is_background_update_running(pid_path) {
|
||||
Some(pid) => format!(
|
||||
"A background shell update (PID {pid}) is already running. Please wait for it to finish or try again shortly."
|
||||
),
|
||||
None => "A background update is already in progress. Please try again shortly.".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_update_busy_message() -> String {
|
||||
match download_update_pid_path() {
|
||||
Ok(pid_path) => shell_update_busy_message_for_pid_path(&pid_path),
|
||||
Err(_) => {
|
||||
"A background update is already in progress. Please try again shortly.".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct StagedUpdate {
|
||||
version: String,
|
||||
target: String,
|
||||
staged_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl StagedUpdate {
|
||||
fn read() -> Result<Option<Self>> {
|
||||
let path = staged_update_dir()?.join("update.json");
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(contents) => {
|
||||
let update: Self = serde_json::from_str(&contents)
|
||||
.context("Failed to parse staged update metadata")?;
|
||||
Ok(Some(update))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e).context("Failed to read staged update metadata"),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self) -> Result<()> {
|
||||
let path = staged_update_dir()?.join("update.json");
|
||||
let contents = serde_json::to_string_pretty(self)?;
|
||||
super::write_atomic(&path, &contents)
|
||||
}
|
||||
|
||||
fn clean() -> Result<()> {
|
||||
let dir = staged_update_dir()?;
|
||||
match fs::remove_dir_all(&dir) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e).context("Failed to clean staged update directory"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_stale(&self) -> bool {
|
||||
let max_age = chrono::Duration::days(STAGED_UPDATE_MAX_AGE_DAYS);
|
||||
chrono::Utc::now() - self.staged_at > max_age
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry point for cleaning staged updates (e.g., when auto-updates are disabled).
|
||||
pub fn clean_staged() -> Result<()> {
|
||||
StagedUpdate::clean()
|
||||
}
|
||||
|
||||
/// Returns the version string of a staged update only if it is still valid
|
||||
/// for application on this machine. Invalid staged updates are cleaned up
|
||||
/// by the shared validator so status reporting matches runtime behavior.
|
||||
pub fn validated_staged_version() -> Option<String> {
|
||||
validate_staged().ok().map(|staged| staged.version)
|
||||
}
|
||||
|
||||
struct BackgroundPidGuard {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl BackgroundPidGuard {
|
||||
fn create(path: PathBuf) -> Result<Self> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let pid = std::process::id();
|
||||
fs::write(&path, format!("{pid} {now}"))?;
|
||||
Ok(Self { path })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BackgroundPidGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads and stages the update, assuming the caller already holds the
|
||||
/// update lock. Shared by [`download_and_stage`] (background path) and
|
||||
/// [`self_update_interactive`] (interactive path).
|
||||
async fn download_and_stage_inner(version: &str, timeout_secs: u64) -> Result<()> {
|
||||
let target = detect_target_triple()?;
|
||||
|
||||
// Authoritative post-lock re-check: `download_and_stage` also checks this
|
||||
// before acquiring the lock as a fast path, but this check is the one that
|
||||
// matters for correctness since no other process can modify staged state
|
||||
// while we hold the lock.
|
||||
if let Ok(Some(staged)) = StagedUpdate::read() {
|
||||
if staged.version == version && staged.target == target {
|
||||
if staged_update_dir()
|
||||
.map(|d| d.join(binary_name()).exists())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Metadata exists but binary is missing — clean and re-download.
|
||||
let _ = StagedUpdate::clean();
|
||||
}
|
||||
}
|
||||
|
||||
let asset_name = release_asset_name(version, target);
|
||||
let url = release_url(version, &asset_name);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "railwayapp")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to download update")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!("Failed to download update: HTTP {}", response.status());
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read update response")?;
|
||||
|
||||
let dir = staged_update_dir()?;
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let bin_name = binary_name();
|
||||
let extract_and_write = || -> Result<()> {
|
||||
if asset_name.ends_with(".zip") {
|
||||
extract_from_zip(&bytes, bin_name, &dir)?;
|
||||
} else {
|
||||
extract_from_tar_gz(&bytes, bin_name, &dir)?;
|
||||
}
|
||||
|
||||
StagedUpdate {
|
||||
version: version.to_string(),
|
||||
target: target.to_string(),
|
||||
staged_at: chrono::Utc::now(),
|
||||
}
|
||||
.write()?;
|
||||
|
||||
Ok(())
|
||||
};
|
||||
|
||||
if let Err(e) = extract_and_write() {
|
||||
let _ = StagedUpdate::clean();
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Downloads the release tarball for the given version and extracts the binary
|
||||
/// to the staged update directory. Cleans up on partial failure.
|
||||
/// Uses file locking to prevent concurrent CLI processes from racing.
|
||||
///
|
||||
/// Returns `Ok(true)` when the update was staged (or was already staged for
|
||||
/// this version/target). Returns `Ok(false)` when another process holds the
|
||||
/// update lock — the caller should **not** treat this as a completed update.
|
||||
pub async fn download_and_stage(version: &str) -> Result<bool> {
|
||||
use fs2::FileExt;
|
||||
|
||||
let target = detect_target_triple()?;
|
||||
|
||||
if let Ok(Some(staged)) = StagedUpdate::read() {
|
||||
if staged.version == version && staged.target == target {
|
||||
if staged_update_dir()
|
||||
.map(|d| d.join(binary_name()).exists())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
// Metadata exists but binary is missing — clean and re-download.
|
||||
let _ = StagedUpdate::clean();
|
||||
}
|
||||
}
|
||||
|
||||
let lock_path = update_lock_path()?;
|
||||
if let Some(parent) = lock_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let lock_file =
|
||||
std::fs::File::create(&lock_path).context("Failed to create update lock file")?;
|
||||
if lock_file.try_lock_exclusive().is_err() {
|
||||
// Another process is already staging or applying an update.
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 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() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let _pid_guard = BackgroundPidGuard::create(download_update_pid_path()?)
|
||||
.context("Failed to record background download PID")?;
|
||||
|
||||
download_and_stage_inner(version, 30).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Spawns a detached child process that downloads and stages the update.
|
||||
/// The child runs independently of the parent — it survives after the
|
||||
/// parent exits, so slow downloads are not killed by the exit timeout.
|
||||
pub fn spawn_background_download(version: &str) -> Result<()> {
|
||||
let exe = std::env::current_exe().context("Failed to get current exe path")?;
|
||||
let log_path = auto_update_log_path()?;
|
||||
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
cmd.env(crate::consts::RAILWAY_STAGE_UPDATE_ENV, version);
|
||||
|
||||
let child = super::spawn_detached(&mut cmd, &log_path)?;
|
||||
// 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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_from_tar_gz(bytes: &[u8], bin_name: &str, dest_dir: &Path) -> Result<()> {
|
||||
use flate2::read::GzDecoder;
|
||||
|
||||
let decoder = GzDecoder::new(bytes);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
|
||||
for entry in archive.entries().context("Failed to read tar entries")? {
|
||||
let mut entry = entry.context("Failed to read tar entry")?;
|
||||
let path = entry.path().context("Failed to read entry path")?;
|
||||
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) {
|
||||
let dest_path = dest_dir.join(bin_name);
|
||||
let mut file =
|
||||
fs::File::create(&dest_path).context("Failed to create staged binary file")?;
|
||||
std::io::copy(&mut entry, &mut file).context("Failed to write staged binary")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&dest_path, fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Binary '{bin_name}' not found in archive");
|
||||
}
|
||||
|
||||
fn extract_from_zip(bytes: &[u8], bin_name: &str, dest_dir: &Path) -> Result<()> {
|
||||
use std::io::Cursor;
|
||||
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).context("Failed to read zip archive")?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
|
||||
let path = file.mangled_name();
|
||||
|
||||
if path.file_name().and_then(|n| n.to_str()) == Some(bin_name) {
|
||||
let dest_path = dest_dir.join(bin_name);
|
||||
let mut out =
|
||||
fs::File::create(&dest_path).context("Failed to create staged binary file")?;
|
||||
std::io::copy(&mut file, &mut out).context("Failed to write staged binary")?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
bail!("Binary '{bin_name}' not found in zip archive");
|
||||
}
|
||||
|
||||
const BACKUP_PREFIX: &str = "railway-v";
|
||||
|
||||
/// Parse a backup filename.
|
||||
/// Handles both `railway-v{ver}` and `railway-v{ver}_{target}[.exe]` formats.
|
||||
fn parse_backup_filename(entry: &fs::DirEntry) -> (String, Option<String>) {
|
||||
let raw = entry.file_name().to_string_lossy().into_owned();
|
||||
let stem = raw
|
||||
.trim_start_matches(BACKUP_PREFIX)
|
||||
.trim_end_matches(".exe");
|
||||
match stem.split_once('_') {
|
||||
Some((ver, target)) => (ver.to_string(), Some(target.to_string())),
|
||||
None => (stem.to_string(), None),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_backups(dir: &Path) -> Result<Vec<fs::DirEntry>> {
|
||||
let mut entries: Vec<_> = fs::read_dir(dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with(BACKUP_PREFIX))
|
||||
.collect();
|
||||
|
||||
// Sort by version (oldest first) so prune_backups can drop the leading entries.
|
||||
entries.sort_by(|a, b| {
|
||||
crate::util::compare_semver::compare_semver(
|
||||
&parse_backup_filename(a).0,
|
||||
&parse_backup_filename(b).0,
|
||||
)
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn create_backup(source: &Path, destination: &Path) -> Result<()> {
|
||||
if let Err(link_err) = fs::hard_link(source, destination) {
|
||||
// hard_link fails if the backup already exists or across filesystems —
|
||||
// fall back to copy, but fail closed if that also fails so we never
|
||||
// replace the running binary without a rollback point.
|
||||
fs::copy(source, destination)
|
||||
.map(|_| ())
|
||||
.map_err(|copy_err| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to back up current binary (hard link: {link_err}; copy: {copy_err})"
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backup_current_binary_no_prune() -> Result<()> {
|
||||
let current_exe = std::env::current_exe().context("Failed to get current exe path")?;
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
let target = detect_target_triple()?;
|
||||
let dir = backups_dir()?;
|
||||
fs::create_dir_all(&dir)?;
|
||||
|
||||
let backup_name = if cfg!(target_os = "windows") {
|
||||
format!("{BACKUP_PREFIX}{current_version}_{target}.exe")
|
||||
} else {
|
||||
format!("{BACKUP_PREFIX}{current_version}_{target}")
|
||||
};
|
||||
let backup_path = dir.join(&backup_name);
|
||||
|
||||
create_backup(¤t_exe, &backup_path).context("Failed to create rollback backup")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backup_current_binary() -> Result<()> {
|
||||
let target = detect_target_triple()?;
|
||||
backup_current_binary_no_prune()?;
|
||||
prune_backups(&backups_dir()?, 3, target)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prune_backups(dir: &Path, keep: usize, target: &str) -> Result<()> {
|
||||
let entries: Vec<_> = list_backups(dir)?
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
let (_, backup_target) = parse_backup_filename(entry);
|
||||
match backup_target {
|
||||
Some(backup_target) => backup_target == target,
|
||||
// Backups created before target tracking was added are assumed
|
||||
// to belong to the current machine's target, matching rollback().
|
||||
None => true,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.len() <= keep {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let to_remove = entries.len() - keep;
|
||||
for entry in entries.into_iter().take(to_remove) {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cleans up leftover `.old.exe` from a previous Windows binary replacement.
|
||||
#[cfg(windows)]
|
||||
fn clean_old_binary() {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let old_path = exe.with_extension("old.exe");
|
||||
let _ = fs::remove_file(&old_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically replaces the binary at `target` with the binary at `source`.
|
||||
/// On Unix: copies to a temp file in the same directory, then renames (atomic).
|
||||
/// On Windows: renames running binary to .old, copies new one in, cleans up .old on next run.
|
||||
fn replace_binary(source: &Path, target: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let exe_dir = target.parent().context("Failed to get binary directory")?;
|
||||
let pid = std::process::id();
|
||||
let tmp_path = exe_dir.join(format!(".railway-tmp-{pid}"));
|
||||
|
||||
fs::copy(source, &tmp_path).context("Failed to copy new binary")?;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o755))?;
|
||||
|
||||
super::rename_replacing(&tmp_path, target).context(
|
||||
"Failed to replace binary. You may need to run with sudo or use `railway upgrade` manually.",
|
||||
)?;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let old_path = target.with_extension("old.exe");
|
||||
let _ = fs::remove_file(&old_path);
|
||||
fs::rename(target, &old_path).context("Failed to rename current binary")?;
|
||||
if let Err(e) = fs::copy(source, target) {
|
||||
let _ = fs::rename(&old_path, target);
|
||||
bail!("Failed to install new binary: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
bail!("Self-update is not supported on this platform");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a staged update by atomically replacing the current binary.
|
||||
/// Returns Ok(version) on success.
|
||||
fn apply_staged_update() -> Result<String> {
|
||||
let staged = StagedUpdate::read()?.context("No staged update found")?;
|
||||
|
||||
// Verify the staged binary matches the current platform.
|
||||
let current_target = detect_target_triple()?;
|
||||
if staged.target != current_target {
|
||||
StagedUpdate::clean()?;
|
||||
bail!(
|
||||
"Staged update is for {}, but this machine is {}",
|
||||
staged.target,
|
||||
current_target
|
||||
);
|
||||
}
|
||||
|
||||
let staged_binary = staged_update_dir()?.join(binary_name());
|
||||
if !staged_binary.exists() {
|
||||
bail!("Staged binary not found");
|
||||
}
|
||||
|
||||
backup_current_binary()?;
|
||||
|
||||
let current_exe = std::env::current_exe().context("Failed to get current exe path")?;
|
||||
replace_binary(&staged_binary, ¤t_exe)?;
|
||||
|
||||
let version = staged.version.clone();
|
||||
StagedUpdate::clean()?;
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// Reads and validates the staged update. Returns `Ok(staged)` when the
|
||||
/// staged binary is safe to apply, or an `Err` describing why not.
|
||||
/// Cleans up the staged directory when the update is stale, wrong-platform,
|
||||
/// not-newer, or skipped.
|
||||
fn validate_staged() -> Result<StagedUpdate> {
|
||||
let staged = StagedUpdate::read()?.context("No staged update found")?;
|
||||
|
||||
if staged.is_stale() {
|
||||
let _ = StagedUpdate::clean();
|
||||
bail!("Staged update is too old");
|
||||
}
|
||||
|
||||
let current_target = detect_target_triple()?;
|
||||
if staged.target != current_target {
|
||||
let _ = StagedUpdate::clean();
|
||||
bail!(
|
||||
"Staged update is for {}, but this machine is {current_target}",
|
||||
staged.target
|
||||
);
|
||||
}
|
||||
|
||||
if !matches!(
|
||||
crate::util::compare_semver::compare_semver(env!("CARGO_PKG_VERSION"), &staged.version),
|
||||
std::cmp::Ordering::Less
|
||||
) {
|
||||
let _ = StagedUpdate::clean();
|
||||
bail!("You are already on the latest version");
|
||||
}
|
||||
|
||||
if let Ok(check) = crate::util::check_update::UpdateCheck::read() {
|
||||
if check.skipped_version.as_deref() == Some(staged.version.as_str()) {
|
||||
let _ = StagedUpdate::clean();
|
||||
bail!("v{} was previously rolled back", staged.version);
|
||||
}
|
||||
}
|
||||
|
||||
if !staged_update_dir()?.join(binary_name()).exists() {
|
||||
let _ = StagedUpdate::clean();
|
||||
bail!("Staged binary missing from disk");
|
||||
}
|
||||
|
||||
Ok(staged)
|
||||
}
|
||||
|
||||
/// Try to apply a previously staged self-update.
|
||||
/// Uses file locking to prevent concurrent CLI instances from racing.
|
||||
/// Returns the applied version on success, `None` otherwise.
|
||||
pub fn try_apply_staged() -> Option<String> {
|
||||
use fs2::FileExt;
|
||||
|
||||
let lock_path = match update_lock_path() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
let lock_file = match std::fs::File::create(&lock_path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return None,
|
||||
};
|
||||
|
||||
if lock_file.try_lock_exclusive().is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Validate after acquiring the lock so another process can't delete or
|
||||
// replace the staged binary between validation and apply.
|
||||
if validate_staged().is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let result = match apply_staged_update() {
|
||||
Ok(version) => {
|
||||
crate::util::check_update::UpdateCheck::clear_after_update();
|
||||
|
||||
// Clean up the .old.exe left over from the previous binary
|
||||
// replacement — only worth doing after a successful apply.
|
||||
#[cfg(windows)]
|
||||
clean_old_binary();
|
||||
|
||||
eprintln!(
|
||||
"{} v{} (active on next run)",
|
||||
"Auto-updated Railway CLI to".green().bold(),
|
||||
version,
|
||||
);
|
||||
Some(version)
|
||||
}
|
||||
Err(e) => {
|
||||
if e.to_string().contains("Staged binary not found") {
|
||||
let _ = StagedUpdate::clean();
|
||||
}
|
||||
// Other errors kept for retry; STAGED_UPDATE_MAX_AGE_DAYS handles permanent failures.
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
drop(lock_file);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn self_update_interactive() -> Result<()> {
|
||||
// Try the network check first. If it fails and an update is already
|
||||
// staged on disk, apply that instead of surfacing a network error.
|
||||
let (latest_version, update_check_failed) =
|
||||
match crate::util::check_update::check_update(true).await {
|
||||
Ok(Some(v)) => (Some(v), false),
|
||||
Ok(None) => (None, false),
|
||||
Err(_) => {
|
||||
// Network failure — fall through and try the staged update.
|
||||
(None, true)
|
||||
}
|
||||
};
|
||||
|
||||
let lock_path = update_lock_path()?;
|
||||
let busy_message = shell_update_busy_message();
|
||||
let lock_file = acquire_update_lock(&lock_path, false, &busy_message)?;
|
||||
|
||||
if let Some(ref version) = latest_version {
|
||||
println!("{} v{}...", "Downloading".green().bold(), version);
|
||||
download_and_stage_inner(version, 120).await?;
|
||||
} else {
|
||||
match finalize_explicit_upgrade_fallback(validate_staged(), update_check_failed)? {
|
||||
Some(staged) => {
|
||||
println!("Applying previously downloaded v{}...", staged.version);
|
||||
}
|
||||
None => {
|
||||
println!("{}", "Railway CLI is already up to date.".green());
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let version = apply_staged_update()?;
|
||||
|
||||
crate::util::check_update::UpdateCheck::clear_after_update();
|
||||
|
||||
drop(lock_file);
|
||||
|
||||
println!("{} v{}", "Successfully updated to".green().bold(), version);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finalize_explicit_upgrade_fallback(
|
||||
staged: Result<StagedUpdate>,
|
||||
update_check_failed: bool,
|
||||
) -> Result<Option<StagedUpdate>> {
|
||||
match staged {
|
||||
Ok(staged) => Ok(Some(staged)),
|
||||
Err(_) if !update_check_failed => Ok(None),
|
||||
Err(err) => Err(err).context("Update check failed and no valid staged update is available"),
|
||||
}
|
||||
}
|
||||
|
||||
fn choose_rollback_candidate(
|
||||
candidates: Vec<(String, std::path::PathBuf)>,
|
||||
non_interactive: bool,
|
||||
) -> Result<(String, std::path::PathBuf)> {
|
||||
if candidates.len() == 1 {
|
||||
return Ok(candidates.into_iter().next().unwrap());
|
||||
}
|
||||
|
||||
if non_interactive {
|
||||
return candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("No rollback candidates found");
|
||||
}
|
||||
|
||||
let labels: Vec<String> = candidates.iter().map(|(v, _)| v.clone()).collect();
|
||||
let selected = inquire::Select::new("Select version to roll back to:", labels)
|
||||
.prompt()
|
||||
.context("Rollback cancelled")?;
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|(v, _)| *v == selected)
|
||||
.context("Selected rollback candidate was not found")
|
||||
}
|
||||
|
||||
pub fn rollback(non_interactive: bool) -> Result<()> {
|
||||
// Acquire the update lock first so background auto-update processes cannot
|
||||
// stage or apply while we are building the candidate list or prompting.
|
||||
let lock_path = update_lock_path()?;
|
||||
let busy_message = shell_update_busy_message();
|
||||
let lock_file = acquire_update_lock(&lock_path, false, &busy_message)?;
|
||||
|
||||
let dir = backups_dir()?;
|
||||
let current_target = detect_target_triple()?;
|
||||
|
||||
// Back up the current binary so the rollback itself can be undone.
|
||||
// Use the no-prune variant so candidates aren't removed before the user
|
||||
// sees the picker.
|
||||
backup_current_binary_no_prune()?;
|
||||
|
||||
let entries = list_backups(&dir)?;
|
||||
let current_version = env!("CARGO_PKG_VERSION");
|
||||
|
||||
// Collect (version_string, path) pairs, newest-first, excluding current.
|
||||
// Backup filenames are either the old format "railway-v{ver}" or the new
|
||||
// format "railway-v{ver}_{target}". Old-format backups (no target) are
|
||||
// assumed to match the current target since they were created locally
|
||||
// before target tracking was added.
|
||||
let candidates: Vec<(String, std::path::PathBuf)> = entries
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|e| {
|
||||
let (ver, backup_target) = parse_backup_filename(e);
|
||||
|
||||
if ver == current_version {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Filter out backups built for a different architecture.
|
||||
if let Some(t) = backup_target {
|
||||
if t != current_target {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some((ver, e.path()))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if candidates.is_empty() {
|
||||
bail!(
|
||||
"All backups match the current version (v{current_version}). Nothing to roll back to."
|
||||
);
|
||||
}
|
||||
|
||||
let (version, backup_path) = choose_rollback_candidate(candidates, non_interactive)?;
|
||||
|
||||
println!("{} v{}...", "Rolling back to".yellow().bold(), version);
|
||||
|
||||
let current_exe = std::env::current_exe().context("Failed to get current exe path")?;
|
||||
replace_binary(&backup_path, ¤t_exe)?;
|
||||
|
||||
// Clean staged updates so the rolled-back binary doesn't immediately re-apply.
|
||||
let _ = StagedUpdate::clean();
|
||||
|
||||
// Record the current version as skipped so auto-update doesn't
|
||||
// re-download and re-apply the version the user just rolled back from.
|
||||
// Auto-update resumes once a newer release supersedes the skipped version.
|
||||
crate::util::check_update::UpdateCheck::skip_version(current_version);
|
||||
|
||||
// Prune after rollback succeeds so the candidate list wasn't reduced
|
||||
// before the user picked.
|
||||
let _ = prune_backups(&dir, 3, current_target);
|
||||
|
||||
drop(lock_file);
|
||||
|
||||
println!("{} v{}", "Rolled back to".green().bold(), version);
|
||||
println!(
|
||||
"Auto-updates will skip v{}. Run {} to disable all auto-updates.",
|
||||
current_version,
|
||||
"railway autoupdate disable".bold()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prune_backups_removes_oldest() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
for i in 0..5u32 {
|
||||
let path = dir.path().join(format!("railway-v1.{i}.0"));
|
||||
fs::write(&path, format!("binary-{i}")).unwrap();
|
||||
}
|
||||
|
||||
prune_backups(dir.path(), 3, "x86_64-unknown-linux-musl").unwrap();
|
||||
|
||||
let remaining = list_backups(dir.path()).unwrap();
|
||||
assert_eq!(remaining.len(), 3);
|
||||
|
||||
let names: Vec<_> = remaining
|
||||
.iter()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
assert!(!names.contains(&"railway-v1.0.0".to_string()));
|
||||
assert!(!names.contains(&"railway-v1.1.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_backups_noop_when_fewer_than_keep() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
for i in 0..2 {
|
||||
let path = dir.path().join(format!("railway-v1.{i}.0"));
|
||||
fs::write(&path, "binary").unwrap();
|
||||
}
|
||||
|
||||
prune_backups(dir.path(), 3, "x86_64-unknown-linux-musl").unwrap();
|
||||
assert_eq!(list_backups(dir.path()).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_backups_ignores_unrelated_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
fs::write(dir.path().join("railway-v1.0.0"), "binary").unwrap();
|
||||
fs::write(dir.path().join("railway-v2.0.0"), "binary").unwrap();
|
||||
fs::write(dir.path().join("unrelated.txt"), "text").unwrap();
|
||||
fs::write(dir.path().join("railway.conf"), "config").unwrap();
|
||||
|
||||
assert_eq!(list_backups(dir.path()).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_backup_fails_when_no_backup_can_be_created() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = dir.path().join("missing-source");
|
||||
let destination = dir.path().join("backup");
|
||||
|
||||
let err = create_backup(&source, &destination)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert!(err.contains("Failed to back up current binary"));
|
||||
assert!(!destination.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_blocking_update_lock_fails_fast_when_held() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let lock_path = dir.path().join("update.lock");
|
||||
let _first = acquire_update_lock(&lock_path, true, "should acquire").unwrap();
|
||||
|
||||
let err = acquire_update_lock(&lock_path, false, "busy")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
|
||||
assert_eq!(err, "busy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_upgrade_fallback_returns_success_when_already_up_to_date() {
|
||||
let result = finalize_explicit_upgrade_fallback(Err(anyhow::anyhow!("no staged")), false);
|
||||
|
||||
assert!(result.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_upgrade_fallback_preserves_network_failure() {
|
||||
let err = match finalize_explicit_upgrade_fallback(Err(anyhow::anyhow!("no staged")), true)
|
||||
{
|
||||
Ok(_) => panic!("expected network failure to propagate"),
|
||||
Err(err) => err.to_string(),
|
||||
};
|
||||
|
||||
assert!(err.contains("Update check failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_backups_only_removes_entries_for_current_target() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
for version in ["1.0.0", "1.1.0"] {
|
||||
let path = dir
|
||||
.path()
|
||||
.join(format!("railway-v{version}_x86_64-unknown-linux-musl"));
|
||||
fs::write(&path, format!("linux-{version}")).unwrap();
|
||||
}
|
||||
|
||||
for version in ["2.0.0", "2.1.0"] {
|
||||
let path = dir
|
||||
.path()
|
||||
.join(format!("railway-v{version}_aarch64-apple-darwin"));
|
||||
fs::write(&path, format!("mac-{version}")).unwrap();
|
||||
}
|
||||
|
||||
prune_backups(dir.path(), 1, "x86_64-unknown-linux-musl").unwrap();
|
||||
|
||||
let names: Vec<_> = list_backups(dir.path())
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(!names.contains(&"railway-v1.0.0_x86_64-unknown-linux-musl".to_string()));
|
||||
assert!(names.contains(&"railway-v1.1.0_x86_64-unknown-linux-musl".to_string()));
|
||||
assert!(names.contains(&"railway-v2.0.0_aarch64-apple-darwin".to_string()));
|
||||
assert!(names.contains(&"railway-v2.1.0_aarch64-apple-darwin".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choose_rollback_candidate_prefers_newest_in_non_interactive_mode() {
|
||||
let candidates = vec![
|
||||
("2.0.0".to_string(), PathBuf::from("/tmp/railway-v2.0.0")),
|
||||
("1.9.0".to_string(), PathBuf::from("/tmp/railway-v1.9.0")),
|
||||
];
|
||||
|
||||
let (version, path) = choose_rollback_candidate(candidates, true).unwrap();
|
||||
|
||||
assert_eq!(version, "2.0.0");
|
||||
assert_eq!(path, PathBuf::from("/tmp/railway-v2.0.0"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue