|
1
|
+ |
//! `oa update` — replace this binary with the one the channel names.
|
|
2
|
+ |
//!
|
|
3
|
+ |
//! This is the installer's contract read from the other end. The script at
|
|
4
|
+ |
//! <https://openagents.com/install.sh> resolves a channel pointer to a version,
|
|
5
|
+ |
//! downloads `openagents-<version>-<platform>`, fetches
|
|
6
|
+ |
//! `SHA256SUMS-<version>` over a separate request, refuses when the sums file
|
|
7
|
+ |
//! is missing or names no entry for the artifact, refuses when the digest
|
|
8
|
+ |
//! disagrees, and only then makes the bytes executable. Every one of those
|
|
9
|
+ |
//! refusals is repeated here, because an update path that verifies less than
|
|
10
|
+ |
//! the install path would mean the second binary a reader receives is held to
|
|
11
|
+ |
//! a lower standard than the first.
|
|
12
|
+ |
//!
|
|
13
|
+ |
//! Two things this knows that the shell script has to work out at runtime. The
|
|
14
|
+ |
//! platform is decided at compile time: a binary knows its own architecture and
|
|
15
|
+ |
//! its own C library, so there is no libc probe here and no way for one to be
|
|
16
|
+ |
//! wrong. And the path to replace comes from the running process rather than
|
|
17
|
+ |
//! from a convention about where the installer puts things, so an `oa` invoked
|
|
18
|
+ |
//! through the symlinks the installer leaves in `~/.openagents/bin` updates the
|
|
19
|
+ |
//! file those symlinks point at and they keep pointing at it.
|
|
20
|
+ |
|
|
21
|
+ |
use std::path::{Path, PathBuf};
|
|
22
|
+ |
|
|
23
|
+ |
use sha2::{Digest, Sha256};
|
|
24
|
+ |
|
|
25
|
+ |
/// Where releases are published. Overridable so the flow can be exercised
|
|
26
|
+ |
/// against a fixture server without pointing a test at the real one.
|
|
27
|
+ |
pub const DEFAULT_BASE_URL: &str = "https://openagents.com/releases";
|
|
28
|
+ |
|
|
29
|
+ |
/// The channel a reader who names none is asking for.
|
|
30
|
+ |
pub const DEFAULT_CHANNEL: &str = "stable";
|
|
31
|
+ |
|
|
32
|
+ |
#[derive(Debug)]
|
|
33
|
+ |
pub enum UpdateError {
|
|
34
|
+ |
/// This build has no published artifact to update to.
|
|
35
|
+ |
UnsupportedPlatform { os: String, arch: String },
|
|
36
|
+ |
/// The channel pointer could not be read.
|
|
37
|
+ |
ChannelUnreadable { channel: String, detail: String },
|
|
38
|
+ |
/// The channel resolved to something that is not a version.
|
|
39
|
+ |
ChannelNotAVersion { channel: String, body: String },
|
|
40
|
+ |
/// A version was named that the release naming grammar does not admit.
|
|
41
|
+ |
InvalidVersion(String),
|
|
42
|
+ |
/// The artifact itself could not be fetched.
|
|
43
|
+ |
ArtifactUnavailable { name: String, detail: String },
|
|
44
|
+ |
/// The sums file could not be fetched. Nothing is installed unverified.
|
|
45
|
+ |
SumsUnavailable { version: String, detail: String },
|
|
46
|
+ |
/// The sums file exists but names no entry for this artifact.
|
|
47
|
+ |
SumsMissingEntry { version: String, name: String },
|
|
48
|
+ |
/// The bytes that arrived are not the bytes the release published.
|
|
49
|
+ |
DigestMismatch {
|
|
50
|
+ |
name: String,
|
|
51
|
+ |
expected: String,
|
|
52
|
+ |
actual: String,
|
|
53
|
+ |
},
|
|
54
|
+ |
/// The binary could not be replaced.
|
|
55
|
+ |
ReplaceFailed { path: PathBuf, detail: String },
|
|
56
|
+ |
}
|
|
57
|
+ |
|
|
58
|
+ |
impl std::fmt::Display for UpdateError {
|
|
59
|
+ |
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
60
|
+ |
match self {
|
|
61
|
+ |
Self::UnsupportedPlatform { os, arch } => write!(
|
|
62
|
+ |
formatter,
|
|
63
|
+ |
"no release is published for {os}/{arch}, so there is nothing to update to"
|
|
64
|
+ |
),
|
|
65
|
+ |
Self::ChannelUnreadable { channel, detail } => write!(
|
|
66
|
+ |
formatter,
|
|
67
|
+ |
"could not resolve the '{channel}' channel: {detail}"
|
|
68
|
+ |
),
|
|
69
|
+ |
Self::ChannelNotAVersion { channel, body } => write!(
|
|
70
|
+ |
formatter,
|
|
71
|
+ |
"the '{channel}' channel returned something that is not a version: {body}"
|
|
72
|
+ |
),
|
|
73
|
+ |
Self::InvalidVersion(version) => write!(
|
|
74
|
+ |
formatter,
|
|
75
|
+ |
"invalid version: {version} (expected X.Y.Z or X.Y.Z-suffix)"
|
|
76
|
+ |
),
|
|
77
|
+ |
Self::ArtifactUnavailable { name, detail } => {
|
|
78
|
+ |
write!(formatter, "could not download {name}: {detail}")
|
|
79
|
+ |
}
|
|
80
|
+ |
Self::SumsUnavailable { version, detail } => write!(
|
|
81
|
+ |
formatter,
|
|
82
|
+ |
"could not download SHA256SUMS-{version} ({detail}); \
|
|
83
|
+ |
refusing to install unverified bytes"
|
|
84
|
+ |
),
|
|
85
|
+ |
Self::SumsMissingEntry { version, name } => write!(
|
|
86
|
+ |
formatter,
|
|
87
|
+ |
"SHA256SUMS-{version} names no entry for {name}; refusing to install"
|
|
88
|
+ |
),
|
|
89
|
+ |
Self::DigestMismatch {
|
|
90
|
+ |
name,
|
|
91
|
+ |
expected,
|
|
92
|
+ |
actual,
|
|
93
|
+ |
} => write!(
|
|
94
|
+ |
formatter,
|
|
95
|
+ |
"checksum mismatch for {name}\n expected {expected}\n actual {actual}"
|
|
96
|
+ |
),
|
|
97
|
+ |
Self::ReplaceFailed { path, detail } => {
|
|
98
|
+ |
write!(formatter, "could not replace {}: {detail}", path.display())
|
|
99
|
+ |
}
|
|
100
|
+ |
}
|
|
101
|
+ |
}
|
|
102
|
+ |
}
|
|
103
|
+ |
|
|
104
|
+ |
impl std::error::Error for UpdateError {}
|
|
105
|
+ |
|
|
106
|
+ |
/// The platform segment of the artifact name for the build this code is
|
|
107
|
+ |
/// compiled into.
|
|
108
|
+ |
///
|
|
109
|
+ |
/// The installer works this out at runtime from `uname` and a search for the
|
|
110
|
+ |
/// glibc loader. A binary does not have to: it was built for exactly one
|
|
111
|
+ |
/// target, and `target_env = "musl"` is settled by the toolchain that produced
|
|
112
|
+ |
/// it. `None` means this build has no published counterpart, which is a
|
|
113
|
+ |
/// clearer thing to say than a request for a URL that will 404.
|
|
114
|
+ |
pub fn platform() -> Option<String> {
|
|
115
|
+ |
let os = match std::env::consts::OS {
|
|
116
|
+ |
"macos" => "macos",
|
|
117
|
+ |
"linux" => "linux",
|
|
118
|
+ |
"windows" => "windows",
|
|
119
|
+ |
_ => return None,
|
|
120
|
+ |
};
|
|
121
|
+ |
|
|
122
|
+ |
let arch = match std::env::consts::ARCH {
|
|
123
|
+ |
"x86_64" => "x86_64",
|
|
124
|
+ |
"aarch64" => "aarch64",
|
|
125
|
+ |
_ => return None,
|
|
126
|
+ |
};
|
|
127
|
+ |
|
|
128
|
+ |
// Only Linux is published in two libc flavors. The glibc artifact keeps
|
|
129
|
+ |
// the unsuffixed name it has always had; musl is the one that carries a
|
|
130
|
+ |
// suffix, exactly as the installer asks for it.
|
|
131
|
+ |
let libc = if os == "linux" && cfg!(target_env = "musl") {
|
|
132
|
+ |
"-musl"
|
|
133
|
+ |
} else {
|
|
134
|
+ |
""
|
|
135
|
+ |
};
|
|
136
|
+ |
|
|
137
|
+ |
Some(format!("{os}-{arch}{libc}"))
|
|
138
|
+ |
}
|
|
139
|
+ |
|
|
140
|
+ |
/// The grammar `ops/release-cli.sh` and the installer both apply. A version
|
|
141
|
+ |
/// one of them accepts and another rejects is a release nobody can ask for.
|
|
142
|
+ |
pub fn valid_version(value: &str) -> bool {
|
|
143
|
+ |
let (core, suffix) = match value.split_once('-') {
|
|
144
|
+ |
Some((core, suffix)) => (core, Some(suffix)),
|
|
145
|
+ |
None => (value, None),
|
|
146
|
+ |
};
|
|
147
|
+ |
|
|
148
|
+ |
let mut parts = core.split('.');
|
|
149
|
+ |
let numeric = |part: Option<&str>| {
|
|
150
|
+ |
part.is_some_and(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
|
|
151
|
+ |
};
|
|
152
|
+ |
|
|
153
|
+ |
if !numeric(parts.next()) || !numeric(parts.next()) || !numeric(parts.next()) {
|
|
154
|
+ |
return false;
|
|
155
|
+ |
}
|
|
156
|
+ |
|
|
157
|
+ |
if parts.next().is_some() {
|
|
158
|
+ |
return false;
|
|
159
|
+ |
}
|
|
160
|
+ |
|
|
161
|
+ |
match suffix {
|
|
162
|
+ |
None => true,
|
|
163
|
+ |
Some(suffix) => {
|
|
164
|
+ |
!suffix.is_empty()
|
|
165
|
+ |
&& suffix
|
|
166
|
+ |
.bytes()
|
|
167
|
+ |
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_')
|
|
168
|
+ |
}
|
|
169
|
+ |
}
|
|
170
|
+ |
}
|
|
171
|
+ |
|
|
172
|
+ |
/// Read one digest out of a `SHA256SUMS` file.
|
|
173
|
+ |
///
|
|
174
|
+ |
/// The installer's lookup is `awk '$2 == name || $2 == "*" name'`, and the
|
|
175
|
+ |
/// leading `*` is the binary-mode marker `sha256sum` writes. This is that
|
|
176
|
+ |
/// lookup, so an entry either tool accepts is an entry both accept.
|
|
177
|
+ |
pub fn digest_for(sums: &str, name: &str) -> Option<String> {
|
|
178
|
+ |
sums.lines().find_map(|line| {
|
|
179
|
+ |
let mut fields = line.split_whitespace();
|
|
180
|
+ |
let digest = fields.next()?;
|
|
181
|
+ |
let entry = fields.next()?;
|
|
182
|
+ |
|
|
183
|
+ |
if entry == name || entry.strip_prefix('*') == Some(name) {
|
|
184
|
+ |
Some(digest.to_string())
|
|
185
|
+ |
} else {
|
|
186
|
+ |
None
|
|
187
|
+ |
}
|
|
188
|
+ |
})
|
|
189
|
+ |
}
|
|
190
|
+ |
|
|
191
|
+ |
/// The published object name for a version and platform.
|
|
192
|
+ |
///
|
|
193
|
+ |
/// The artifact URL never carries a file extension, on any platform. The
|
|
194
|
+ |
/// `SHA256SUMS` entry for Windows *does*, because the installer appends `.exe`
|
|
195
|
+ |
/// to the name it searches for after downloading a URL without one. The two
|
|
196
|
+ |
/// disagree by design and `ops/release-cli.sh` publishes them that way, so
|
|
197
|
+ |
/// both spellings live here rather than being guessed at a call site.
|
|
198
|
+ |
pub fn artifact_name(version: &str, platform: &str) -> String {
|
|
199
|
+ |
format!("openagents-{version}-{platform}")
|
|
200
|
+ |
}
|
|
201
|
+ |
|
|
202
|
+ |
pub fn sums_entry_name(version: &str, platform: &str) -> String {
|
|
203
|
+ |
let name = artifact_name(version, platform);
|
|
204
|
+ |
|
|
205
|
+ |
if platform.starts_with("windows-") {
|
|
206
|
+ |
format!("{name}.exe")
|
|
207
|
+ |
} else {
|
|
208
|
+ |
name
|
|
209
|
+ |
}
|
|
210
|
+ |
}
|
|
211
|
+ |
|
|
212
|
+ |
pub fn hex_digest(bytes: &[u8]) -> String {
|
|
213
|
+ |
let digest = Sha256::digest(bytes);
|
|
214
|
+ |
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
|
215
|
+ |
}
|
|
216
|
+ |
|
|
217
|
+ |
pub struct Updater {
|
|
218
|
+ |
pub base_url: String,
|
|
219
|
+ |
pub channel: String,
|
|
220
|
+ |
http: reqwest::Client,
|
|
221
|
+ |
}
|
|
222
|
+ |
|
|
223
|
+ |
/// What an update run decided, so a caller can report it without inferring it
|
|
224
|
+ |
/// from printed text.
|
|
225
|
+ |
#[derive(Debug, PartialEq, Eq)]
|
|
226
|
+ |
pub enum Outcome {
|
|
227
|
+ |
AlreadyCurrent {
|
|
228
|
+ |
version: String,
|
|
229
|
+ |
},
|
|
230
|
+ |
Available {
|
|
231
|
+ |
version: String,
|
|
232
|
+ |
},
|
|
233
|
+ |
Replaced {
|
|
234
|
+ |
from: String,
|
|
235
|
+ |
to: String,
|
|
236
|
+ |
path: PathBuf,
|
|
237
|
+ |
},
|
|
238
|
+ |
}
|
|
239
|
+ |
|
|
240
|
+ |
impl Updater {
|
|
241
|
+ |
pub fn new(base_url: Option<String>, channel: Option<String>) -> Self {
|
|
242
|
+ |
let base_url = base_url
|
|
243
|
+ |
.or_else(|| std::env::var("OPENAGENTS_RELEASES_BASE_URL").ok())
|
|
244
|
+ |
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
|
|
245
|
+ |
|
|
246
|
+ |
let channel = channel
|
|
247
|
+ |
.or_else(|| std::env::var("OPENAGENTS_CHANNEL").ok())
|
|
248
|
+ |
.unwrap_or_else(|| DEFAULT_CHANNEL.to_string());
|
|
249
|
+ |
|
|
250
|
+ |
Self {
|
|
251
|
+ |
base_url: base_url.trim_end_matches('/').to_string(),
|
|
252
|
+ |
channel,
|
|
253
|
+ |
http: reqwest::Client::new(),
|
|
254
|
+ |
}
|
|
255
|
+ |
}
|
|
256
|
+ |
|
|
257
|
+ |
/// Resolve the channel pointer to the version it currently names.
|
|
258
|
+ |
pub async fn resolve_channel(&self) -> Result<String, UpdateError> {
|
|
259
|
+ |
let url = format!("{}/{}", self.base_url, self.channel);
|
|
260
|
+ |
|
|
261
|
+ |
let response =
|
|
262
|
+ |
self.http
|
|
263
|
+ |
.get(&url)
|
|
264
|
+ |
.send()
|
|
265
|
+ |
.await
|
|
266
|
+ |
.map_err(|error| UpdateError::ChannelUnreadable {
|
|
267
|
+ |
channel: self.channel.clone(),
|
|
268
|
+ |
detail: error.to_string(),
|
|
269
|
+ |
})?;
|
|
270
|
+ |
|
|
271
|
+ |
if !response.status().is_success() {
|
|
272
|
+ |
return Err(UpdateError::ChannelUnreadable {
|
|
273
|
+ |
channel: self.channel.clone(),
|
|
274
|
+ |
detail: format!("{} answered {}", url, response.status()),
|
|
275
|
+ |
});
|
|
276
|
+ |
}
|
|
277
|
+ |
|
|
278
|
+ |
let body = response
|
|
279
|
+ |
.text()
|
|
280
|
+ |
.await
|
|
281
|
+ |
.map_err(|error| UpdateError::ChannelUnreadable {
|
|
282
|
+ |
channel: self.channel.clone(),
|
|
283
|
+ |
detail: error.to_string(),
|
|
284
|
+ |
})?;
|
|
285
|
+ |
|
|
286
|
+ |
let version = body.trim().to_string();
|
|
287
|
+ |
|
|
288
|
+ |
if !valid_version(&version) {
|
|
289
|
+ |
return Err(UpdateError::ChannelNotAVersion {
|
|
290
|
+ |
channel: self.channel.clone(),
|
|
291
|
+ |
body: version,
|
|
292
|
+ |
});
|
|
293
|
+ |
}
|
|
294
|
+ |
|
|
295
|
+ |
Ok(version)
|
|
296
|
+ |
}
|
|
297
|
+ |
|
|
298
|
+ |
/// Download the artifact and prove it is the one the release published.
|
|
299
|
+ |
///
|
|
300
|
+ |
/// The sums file is fetched over its own request rather than alongside the
|
|
301
|
+ |
/// artifact. A digest that arrived on the same connection as the bytes it
|
|
302
|
+ |
/// describes proves only that they travelled together.
|
|
303
|
+ |
pub async fn fetch_verified(
|
|
304
|
+ |
&self,
|
|
305
|
+ |
version: &str,
|
|
306
|
+ |
platform: &str,
|
|
307
|
+ |
) -> Result<Vec<u8>, UpdateError> {
|
|
308
|
+ |
if !valid_version(version) {
|
|
309
|
+ |
return Err(UpdateError::InvalidVersion(version.to_string()));
|
|
310
|
+ |
}
|
|
311
|
+ |
|
|
312
|
+ |
let name = artifact_name(version, platform);
|
|
313
|
+ |
let url = format!("{}/{}", self.base_url, name);
|
|
314
|
+ |
|
|
315
|
+ |
let response =
|
|
316
|
+ |
self.http
|
|
317
|
+ |
.get(&url)
|
|
318
|
+ |
.send()
|
|
319
|
+ |
.await
|
|
320
|
+ |
.map_err(|error| UpdateError::ArtifactUnavailable {
|
|
321
|
+ |
name: name.clone(),
|
|
322
|
+ |
detail: error.to_string(),
|
|
323
|
+ |
})?;
|
|
324
|
+ |
|
|
325
|
+ |
if !response.status().is_success() {
|
|
326
|
+ |
return Err(UpdateError::ArtifactUnavailable {
|
|
327
|
+ |
name: name.clone(),
|
|
328
|
+ |
detail: format!("{} answered {}", url, response.status()),
|
|
329
|
+ |
});
|
|
330
|
+ |
}
|
|
331
|
+ |
|
|
332
|
+ |
let bytes = response
|
|
333
|
+ |
.bytes()
|
|
334
|
+ |
.await
|
|
335
|
+ |
.map_err(|error| UpdateError::ArtifactUnavailable {
|
|
336
|
+ |
name: name.clone(),
|
|
337
|
+ |
detail: error.to_string(),
|
|
338
|
+ |
})?
|
|
339
|
+ |
.to_vec();
|
|
340
|
+ |
|
|
341
|
+ |
let sums_url = format!("{}/SHA256SUMS-{}", self.base_url, version);
|
|
342
|
+ |
|
|
343
|
+ |
let sums_response = self.http.get(&sums_url).send().await.map_err(|error| {
|
|
344
|
+ |
UpdateError::SumsUnavailable {
|
|
345
|
+ |
version: version.to_string(),
|
|
346
|
+ |
detail: error.to_string(),
|
|
347
|
+ |
}
|
|
348
|
+ |
})?;
|
|
349
|
+ |
|
|
350
|
+ |
if !sums_response.status().is_success() {
|
|
351
|
+ |
return Err(UpdateError::SumsUnavailable {
|
|
352
|
+ |
version: version.to_string(),
|
|
353
|
+ |
detail: format!("{} answered {}", sums_url, sums_response.status()),
|
|
354
|
+ |
});
|
|
355
|
+ |
}
|
|
356
|
+ |
|
|
357
|
+ |
let sums = sums_response
|
|
358
|
+ |
.text()
|
|
359
|
+ |
.await
|
|
360
|
+ |
.map_err(|error| UpdateError::SumsUnavailable {
|
|
361
|
+ |
version: version.to_string(),
|
|
362
|
+ |
detail: error.to_string(),
|
|
363
|
+ |
})?;
|
|
364
|
+ |
|
|
365
|
+ |
let entry = sums_entry_name(version, platform);
|
|
366
|
+ |
|
|
367
|
+ |
let expected = digest_for(&sums, &entry).ok_or_else(|| UpdateError::SumsMissingEntry {
|
|
368
|
+ |
version: version.to_string(),
|
|
369
|
+ |
name: entry.clone(),
|
|
370
|
+ |
})?;
|
|
371
|
+ |
|
|
372
|
+ |
let actual = hex_digest(&bytes);
|
|
373
|
+ |
|
|
374
|
+ |
if !actual.eq_ignore_ascii_case(&expected) {
|
|
375
|
+ |
return Err(UpdateError::DigestMismatch {
|
|
376
|
+ |
name: entry,
|
|
377
|
+ |
expected,
|
|
378
|
+ |
actual,
|
|
379
|
+ |
});
|
|
380
|
+ |
}
|
|
381
|
+ |
|
|
382
|
+ |
Ok(bytes)
|
|
383
|
+ |
}
|
|
384
|
+ |
}
|
|
385
|
+ |
|
|
386
|
+ |
/// The file this process is running from, with symlinks resolved.
|
|
387
|
+ |
///
|
|
388
|
+ |
/// The installer links `~/.openagents/bin/oa` and `~/.openagents/bin/openagents`
|
|
389
|
+ |
/// at a single file under `~/.openagents/downloads`. Replacing the link target
|
|
390
|
+ |
/// is what keeps both names working; replacing a link would leave the other
|
|
391
|
+ |
/// name pointing at the old binary.
|
|
392
|
+ |
pub fn running_binary() -> Result<PathBuf, UpdateError> {
|
|
393
|
+ |
let path = std::env::current_exe().map_err(|error| UpdateError::ReplaceFailed {
|
|
394
|
+ |
path: PathBuf::from("<unknown>"),
|
|
395
|
+ |
detail: format!("could not locate the running binary: {error}"),
|
|
396
|
+ |
})?;
|
|
397
|
+ |
|
|
398
|
+ |
Ok(path.canonicalize().unwrap_or(path))
|
|
399
|
+ |
}
|
|
400
|
+ |
|
|
401
|
+ |
/// Write `bytes` over `target` without ever leaving a partial binary there.
|
|
402
|
+ |
///
|
|
403
|
+ |
/// The new file is written beside the target so the final step is a rename
|
|
404
|
+ |
/// within one filesystem, which is atomic: a reader who runs `oa` during an
|
|
405
|
+ |
/// update gets the old binary or the new one and never half of either. On Unix
|
|
406
|
+ |
/// the rename also works while the old binary is executing, because the
|
|
407
|
+ |
/// running process holds the inode rather than the name.
|
|
408
|
+ |
pub fn replace_binary(target: &Path, bytes: &[u8]) -> Result<(), UpdateError> {
|
|
409
|
+ |
let directory = target.parent().ok_or_else(|| UpdateError::ReplaceFailed {
|
|
410
|
+ |
path: target.to_path_buf(),
|
|
411
|
+ |
detail: "the running binary has no parent directory".to_string(),
|
|
412
|
+ |
})?;
|
|
413
|
+ |
|
|
414
|
+ |
let file_name = target
|
|
415
|
+ |
.file_name()
|
|
416
|
+ |
.map(|name| name.to_string_lossy().to_string())
|
|
417
|
+ |
.unwrap_or_else(|| "oa".to_string());
|
|
418
|
+ |
|
|
419
|
+ |
let staged = directory.join(format!(".{}.update.{}", file_name, std::process::id()));
|
|
420
|
+ |
|
|
421
|
+ |
let fail = |detail: String| UpdateError::ReplaceFailed {
|
|
422
|
+ |
path: target.to_path_buf(),
|
|
423
|
+ |
detail,
|
|
424
|
+ |
};
|
|
425
|
+ |
|
|
426
|
+ |
std::fs::write(&staged, bytes).map_err(|error| fail(error.to_string()))?;
|
|
427
|
+ |
|
|
428
|
+ |
#[cfg(unix)]
|
|
429
|
+ |
{
|
|
430
|
+ |
use std::os::unix::fs::PermissionsExt;
|
|
431
|
+ |
|
|
432
|
+ |
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755))
|
|
433
|
+ |
.map_err(|error| fail(error.to_string()))?;
|
|
434
|
+ |
}
|
|
435
|
+ |
|
|
436
|
+ |
// Windows refuses to rename over a running executable, so the old name is
|
|
437
|
+ |
// moved aside first. The installer does the same thing with the same
|
|
438
|
+ |
// `.old` suffix, and Windows will delete it on the next boot or the next
|
|
439
|
+ |
// update, whichever a reader reaches first.
|
|
440
|
+ |
#[cfg(windows)]
|
|
441
|
+ |
let displaced = {
|
|
442
|
+ |
let displaced = directory.join(format!("{file_name}.old"));
|
|
443
|
+ |
let _ = std::fs::remove_file(&displaced);
|
|
444
|
+ |
|
|
445
|
+ |
if target.exists() {
|
|
446
|
+ |
std::fs::rename(target, &displaced).map_err(|error| fail(error.to_string()))?;
|
|
447
|
+ |
}
|
|
448
|
+ |
|
|
449
|
+ |
Some(displaced)
|
|
450
|
+ |
};
|
|
451
|
+ |
|
|
452
|
+ |
if let Err(error) = std::fs::rename(&staged, target) {
|
|
453
|
+ |
let _ = std::fs::remove_file(&staged);
|
|
454
|
+ |
|
|
455
|
+ |
#[cfg(windows)]
|
|
456
|
+ |
if let Some(displaced) = displaced {
|
|
457
|
+ |
let _ = std::fs::rename(&displaced, target);
|
|
458
|
+ |
}
|
|
459
|
+ |
|
|
460
|
+ |
return Err(fail(error.to_string()));
|
|
461
|
+ |
}
|
|
462
|
+ |
|
|
463
|
+ |
Ok(())
|
|
464
|
+ |
}
|
|
465
|
+ |
|
|
466
|
+ |
/// Run the update.
|
|
467
|
+ |
///
|
|
468
|
+ |
/// `check` stops after resolving the channel: nothing is downloaded and
|
|
469
|
+ |
/// nothing is written, which is what a script that wants to know whether an
|
|
470
|
+ |
/// update exists should call.
|
|
471
|
+ |
pub async fn run(
|
|
472
|
+ |
channel: Option<String>,
|
|
473
|
+ |
requested: Option<String>,
|
|
474
|
+ |
check: bool,
|
|
475
|
+ |
force: bool,
|
|
476
|
+ |
) -> Result<Outcome, Box<dyn std::error::Error>> {
|
|
477
|
+ |
let platform = platform().ok_or_else(|| UpdateError::UnsupportedPlatform {
|
|
478
|
+ |
os: std::env::consts::OS.to_string(),
|
|
479
|
+ |
arch: std::env::consts::ARCH.to_string(),
|
|
480
|
+ |
})?;
|
|
481
|
+ |
|
|
482
|
+ |
let updater = Updater::new(None, channel);
|
|
483
|
+ |
let current = crate::VERSION;
|
|
484
|
+ |
|
|
485
|
+ |
let version = match requested {
|
|
486
|
+ |
Some(version) => {
|
|
487
|
+ |
if !valid_version(&version) {
|
|
488
|
+ |
return Err(Box::new(UpdateError::InvalidVersion(version)));
|
|
489
|
+ |
}
|
|
490
|
+ |
|
|
491
|
+ |
version
|
|
492
|
+ |
}
|
|
493
|
+ |
None => {
|
|
494
|
+ |
let resolved = updater.resolve_channel().await?;
|
|
495
|
+ |
|
|
496
|
+ |
println!(
|
|
497
|
+ |
"Channel '{}' names {} ({} is installed).",
|
|
498
|
+ |
updater.channel, resolved, current
|
|
499
|
+ |
);
|
|
500
|
+ |
|
|
501
|
+ |
resolved
|
|
502
|
+ |
}
|
|
503
|
+ |
};
|
|
504
|
+ |
|
|
505
|
+ |
if version == current && !force {
|
|
506
|
+ |
println!("Already running {current}. Nothing to do.");
|
|
507
|
+ |
|
|
508
|
+ |
return Ok(Outcome::AlreadyCurrent {
|
|
509
|
+ |
version: version.clone(),
|
|
510
|
+ |
});
|
|
511
|
+ |
}
|
|
512
|
+ |
|
|
513
|
+ |
if check {
|
|
514
|
+ |
println!("Update available: {current} -> {version}");
|
|
515
|
+ |
|
|
516
|
+ |
return Ok(Outcome::Available { version });
|
|
517
|
+ |
}
|
|
518
|
+ |
|
|
519
|
+ |
let target = running_binary()?;
|
|
520
|
+ |
|
|
521
|
+ |
println!(
|
|
522
|
+ |
"Downloading {} ({platform})...",
|
|
523
|
+ |
artifact_name(&version, &platform)
|
|
524
|
+ |
);
|
|
525
|
+ |
|
|
526
|
+ |
let bytes = updater.fetch_verified(&version, &platform).await?;
|
|
527
|
+ |
|
|
528
|
+ |
println!(" Verified sha256 {}.", hex_digest(&bytes));
|
|
529
|
+ |
|
|
530
|
+ |
replace_binary(&target, &bytes)?;
|
|
531
|
+ |
|
|
532
|
+ |
println!("Replaced {}.", target.display());
|
|
533
|
+ |
println!("OpenAgents CLI is now {version}.");
|
|
534
|
+ |
|
|
535
|
+ |
Ok(Outcome::Replaced {
|
|
536
|
+ |
from: current.to_string(),
|
|
537
|
+ |
to: version,
|
|
538
|
+ |
path: target,
|
|
539
|
+ |
})
|
|
540
|
+ |
}
|