1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
#[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")
}