From 76f5c22b4ee2b589b113344afad1612c52b34d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Tue, 27 Oct 2026 17:09:15 -0300 Subject: [PATCH 1/2] feat(update): robust auto-update and package-manager installs (#144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Network: an unreadable release feed or an unknown publish state is an error, never a silent fallback to Stable or an older build; retries and connect timeouts on metadata; downloads guarded by a stall check and resumable instead of a 120 s cap. - App replacement never leaves no app: macOS keeps the old bundle until the new one is in place and recovers a killed swap; Linux keeps the previous AppImage; Windows backs up the install dir, verifies the registered version and restores on failure. Staging is cleaned on every error. - Flow: skills and agent setups refresh only after the app sync, with the binary that stays; dev and linked builds are never overwritten; writability checked before anything is replaced; atomic update cache with failure backoff (offline atlas dev no longer waits on every start). - Homebrew and system packages report a managed status; the app no longer says 'up to date' when it installed nothing. - #144: pacman/apt/dnf/zypper installs are detected by package ownership; atlas update --check reports versions; atlas update downloads the matching package, verifies sha256 before escalating, installs with sudo or pkexec, then refreshes skills and agents with the installed binary; the daily notice covers package installs. Closes #144 Signed-off-by: Matheus Vilaça de Jesus --- CHANGELOG.md | 8 + apps/desktop/src/lib/stores/updates.svelte.ts | 22 +- apps/desktop/src/lib/updates.test.ts | 45 + apps/desktop/src/lib/updates.ts | 54 +- crates/atlas-cli/src/commands/desktop.rs | 238 ++++- .../atlas-cli/src/commands/desktop_linux.rs | 221 ++++- .../atlas-cli/src/commands/desktop_windows.rs | 241 ++++- crates/atlas-cli/src/commands/mod.rs | 1 + crates/atlas-cli/src/commands/self_update.rs | 794 ++++++++++++++-- .../atlas-cli/src/commands/update_channel.rs | 60 ++ crates/atlas-cli/src/commands/update_core.rs | 193 ++++ crates/atlas-cli/src/commands/update_pkg.rs | 349 +++++++ .../atlas-cli/src/commands/update_prompt.rs | 43 +- crates/atlas-cli/tests/update_pkg.rs | 438 +++++++++ crates/atlas-core/src/github.rs | 853 +++++++++++++++--- crates/atlas-desktop/src/updates.rs | 45 +- docs/cli.md | 14 +- docs/desktop.md | 4 +- docs/installation.md | 2 +- 19 files changed, 3293 insertions(+), 332 deletions(-) create mode 100644 crates/atlas-cli/src/commands/update_pkg.rs create mode 100644 crates/atlas-cli/tests/update_pkg.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2940cb5b..2f9b0386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to Atlas (CLI, TUI, MCP server and Atlas Desktop). Versions - **Capture: screenshots, screen recordings and GIFs from a hotkey, in any app.** ⌃⌥3 captures the screen under the pointer, ⌃⌥4 drags a region (Space switches to a window; a magnifier and the pixel size follow the pointer), ⌃⌥5 picks a window, ⌃⌥6 records a region to mp4 (3-2-1 countdown, pause, stop, 10-minute limit) and ⌃⌥7 records a GIF (all changeable, with presets, in Settings → Capture; none is a macOS system key). The file is saved to `~/Pictures/Atlas Captures` (or your folder), copied to the clipboard and shown as a quick card in the corner: **Copy**, **Save As**, **Annotate**, **Send to agent** (attaches it to the dock's composer, which sends the path), **Reveal**, **Discard**, drag it into Finder, Mail or Slack, or turn a recording into a GIF with a size estimate. **Annotate** opens an editor (arrow, rectangle, text, highlight, blur, crop) that saves a `(edited)` copy next to the original and never overwrites it. Settings → Capture shows the Screen Recording permission and the history. Atlas's own capture windows and the dock are never in the picture. macOS only for now; the first capture asks for Screen Recording and macOS applies it after a restart. - **Open anything with `atlas`, no setup.** `atlas .`, `atlas ` and `atlas ` open it in Atlas Desktop (a file opens its project, then the file; the app is started if it is not running). `atlas.yaml` is now optional: in a folder without one, `atlas dev` (also `--detach`, `--headless`, `--ci`), `atlas down` and `atlas mcp` detect the services from `package.json`, `Cargo.toml`, `pyproject.toml` or `go.mod` in memory, print one line saying so and writing nothing; `atlas init` saves exactly that config. A subcommand always wins over a folder of the same name (`atlas ./dev` opens the folder); a typo like `atlas devv` still fails with "unrecognized subcommand" and the suggestion. - **`atlas -- ` runs a command as a service of this folder's stack.** `atlas -- bun dev` is supervised like any service (logs in `.logs/`, port probe, restart from the TUI/Desktop/MCP) without touching `atlas.yaml`: it joins a running background runner live, or starts the stack in the background with it. Named from the command (`bun-dev`, de-duplicated). +- **`atlas update` updates system packages (Arch, Debian/Ubuntu, Fedora/openSUSE).** It finds the owner with `pacman -Qo`, `dpkg -S` or `rpm -qf`, always reports installed and latest with `--check`, downloads the matching `.pkg.tar.zst`, `.deb` or `.rpm`, verifies its SHA-256 **before** asking for privileges, installs it through `sudo` (`pkexec` without a terminal) after a confirmation (`--yes` skips it) while holding the update lock, then refreshes skills and agent setups with the installed binary. The daily notice and exit banner fire for package installs and print the package manager's command (#144). ### Changed @@ -34,6 +35,13 @@ All notable changes to Atlas (CLI, TUI, MCP server and Atlas Desktop). Versions - **Windows: the tray icon was a solid black dot.** The notification area never tints icons, so the black macOS template showed as a disc; it now uses the full-colour Atlas icon. Also on Windows: "Reveal in File Explorer" instead of "Reveal in Finder", Windows fallbacks in the default font stacks (Segoe UI, Cascadia Mono, Consolas), `Ctrl` key hints instead of ⌘ glyphs in the sidebar, welcome and custom-agent texts, and no `afplay` call on notifications. - **Windows: the embedded browser no longer swallows Atlas's shortcuts.** With a browser tab focused, Ctrl+1…9, Ctrl+Tab, Ctrl+PgUp/PgDn, Ctrl+Shift+T/W, Alt+1…9 and your remaps run their commands (the forwarding macOS already had), while Ctrl+F, F5, Ctrl+C and Ctrl+V stay with the page. Linux browser tabs still take every key while focused. - **Windows: F5, Ctrl+R, Ctrl+F, Ctrl+P and F12 no longer reload Atlas or open Edge UI.** WebView2's browser accelerator keys are off for the app window and the tray popover (Atlas's own keymap still receives every key); embedded browser tabs keep them. +- **Updating Atlas Desktop can no longer leave you without the app.** macOS: the old `Atlas.app` is kept as `.Atlas.app.old` until the new one is in place, and an update killed between the two renames is repaired by the next `atlas` or `atlas desktop` run; half-copied staging folders are removed on every failure (a full disk used to leave them). Linux: the previous AppImage is kept until the new one is installed and its version recorded, and restored if either fails. Windows: the install folder is moved aside before the installer runs and restored when the installer fails, leaves no app, or leaves a different version than the one wanted. +- **`atlas update` says when it left a linked Atlas Desktop alone.** With the app linked to a dev build (`atlas link --desktop`) and the CLI not linked, one line now says the app was not updated and how to unlink. +- **Update checks and downloads survive a flaky network, and never answer with the wrong release.** If the release feed cannot be read, a beta or alpha check now fails (and keeps the last known result) instead of quietly reporting "Stable", and a hiccup while confirming that the newest build is published no longer makes `atlas update` skip it for an older one: only an explicit "not found" does. Requests time out on connect and retry on transient errors; downloads have no 2-minute total cap any more (slow links and the large desktop bundle used to fail), they are stopped only when they stall, and curl resumes them. +- **A failed Desktop sync no longer leaves skills and agent hooks on the version the CLI was rolled back from.** `atlas update` now refreshes skills, MCP entries and hooks only after the app followed, and when it did not, the restored previous binary refreshes them. +- **`atlas update` no longer overwrites a dev build, and no longer fails half way on permissions.** A debug build or an `atlas link` binary is refused with the same rule the `atlas dev` prompt uses; before downloading it checks that the CLI's folder and the installed app are writable and names the one that is not, instead of replacing the CLI and rolling it back. +- **The update cache survives crashes, damage and an offline start.** `~/.atlas/.update_check` is written atomically, a damaged file keeps your "not now" answer, and a failed check is stamped so an offline `atlas dev` start no longer waits two seconds for the network every time (it retries after 30 minutes). +- **Atlas Desktop no longer says "Atlas is up to date" after an update that installed nothing.** For Homebrew and system-package installs, `atlas update` reports `managed` (`@@atlas managed …`, and `action` `managed` or `package` in `--check --json`) and the app shows how to update instead. ## [1.5.0-alpha.23] — 2026-10-26 diff --git a/apps/desktop/src/lib/stores/updates.svelte.ts b/apps/desktop/src/lib/stores/updates.svelte.ts index 7e875a54..f698171a 100644 --- a/apps/desktop/src/lib/stores/updates.svelte.ts +++ b/apps/desktop/src/lib/stores/updates.svelte.ts @@ -16,6 +16,8 @@ import { EMPTY_PREFS, EMPTY_PROGRESS, blurbOf, + finishOutcome, + finishToast, justUpdated, parseChannelState, parseChannelViews, @@ -32,6 +34,7 @@ import { type ChannelAction, type ChannelState, type ChannelView, + type InstallOutcome, type ProgressEvent, type ReleaseNotes, type UpdatePrefs, @@ -71,7 +74,7 @@ class Updates { checking = $state(false); running = $state(null); /** How the last install ended; drives the step list. */ - outcome = $state<"running" | "done" | "failed">("running"); + outcome = $state("running"); log = $state([]); /** Why the last install failed (the CLI's last line); null until it does. */ failure = $state(null); @@ -353,10 +356,19 @@ class Updates { this.#structured = false; try { const res = await invoke(cmd, args); - if (res === true) this.relaunchNeeded = true; - this.outcome = "done"; - if (!this.whatsNewOpen) - toast.success(kind === "unlink" ? "Back on the release build" : "Atlas is up to date"); + // Progress events and the command result travel on separate channels: let the last + // `@@atlas` lines land before deciding how the run ended. + const { promise: landed, resolve } = Promise.withResolvers(); + setTimeout(resolve, 0); + await landed; + const ended = finishOutcome(this.progress); + this.outcome = ended; + if (ended === "done" && res === true) this.relaunchNeeded = true; + // The sheet has no copy for a managed install, so its toast is shown either way. + if (ended === "managed" || !this.whatsNewOpen) { + const t = finishToast(kind, ended, this.progress.managed); + toast[t.level](t.text); + } await this.check(); // The app bundle itself may have been replaced even when we could not detect it. if (kind === "reinstall") this.relaunchNeeded = true; diff --git a/apps/desktop/src/lib/updates.test.ts b/apps/desktop/src/lib/updates.test.ts index 8f16a425..f3d67ed1 100644 --- a/apps/desktop/src/lib/updates.test.ts +++ b/apps/desktop/src/lib/updates.test.ts @@ -19,6 +19,8 @@ import { prereleaseBadge, dateOf, formatBytes, + finishOutcome, + finishToast, isSkipped, justUpdated, parseNotes, @@ -385,6 +387,49 @@ describe("progress reducer", () => { expect(phaseFromLog("◆ Downloading Atlas-1.0.2-aarch64.app.tar.gz...")).toEqual(phase("update-app")); expect(phaseFromLog("hello")).toBeNull(); }); + test("a managed install is remembered with who manages it", () => { + expect(run({ kind: "managed", how: "homebrew" }).managed).toBe("homebrew"); + expect(run({ kind: "managed", how: "package" }).managed).toBe("package"); + expect(EMPTY_PROGRESS.managed).toBeNull(); + }); + test("a managed event does not move the phases", () => { + const p = run(phase("download"), { kind: "managed", how: "package" }); + expect(p.at).toBe(0); + }); +}); + +describe("how a finished install ends", () => { + const run = (...events) => events.reduce(reduceProgress, EMPTY_PROGRESS); + + test("an exit-0 run that installed nothing because another tool owns Atlas is `managed`, never `done`", () => { + expect(finishOutcome(run({ kind: "managed", how: "homebrew" }))).toBe("managed"); + expect(finishOutcome(run({ kind: "managed", how: "package" }))).toBe("managed"); + }); + test("any other exit-0 run is `done`", () => { + expect(finishOutcome(EMPTY_PROGRESS)).toBe("done"); + expect(finishOutcome(run({ kind: "phase", id: "install-cli" }))).toBe("done"); + }); + test("the toast never says `up to date` for a managed install", () => { + const managed = finishToast("update", "managed", "homebrew"); + expect(managed.text).not.toMatch(/up to date/i); + expect(managed.text).toMatch(/brew upgrade atlas/); + expect(managed.level).toBe("info"); + expect(finishToast("update", "managed", "package").text).toMatch(/package manager/i); + expect(finishToast("update", "done", null)).toEqual({ level: "success", text: "Atlas is up to date" }); + expect(finishToast("unlink", "done", null).text).toBe("Back on the release build"); + }); + test("a managed run is not a finished install: no relaunch, steps stay un-ticked", () => { + const p = run({ kind: "managed", how: "homebrew" }); + expect(states(stepsFrom(p, "managed"))).toEqual([ + "pending", + "pending", + "pending", + "pending", + "pending", + "pending", + ]); + }); + const states = (s) => s.map((x) => x.state); }); describe("dateOf", () => { diff --git a/apps/desktop/src/lib/updates.ts b/apps/desktop/src/lib/updates.ts index a62f6124..6a1aac48 100644 --- a/apps/desktop/src/lib/updates.ts +++ b/apps/desktop/src/lib/updates.ts @@ -158,7 +158,8 @@ export type ProgressEvent = | { kind: "phase"; id: string } | { kind: "bytes"; done: number; total: number } | { kind: "rollback"; state: string } - | { kind: "host"; state: string; live: number }; + | { kind: "host"; state: string; live: number } + | { kind: "managed"; how: string }; /** What the install has reported so far. */ export interface Progress { @@ -171,9 +172,18 @@ export interface Progress { rollback: "restored" | "failed" | null; /** How `atlas update` left the terminal host (`needs-restart` = running terminals, user decides). */ host: { state: string; live: number } | null; + /** Another tool owns this Atlas (`homebrew`, `package`): the CLI exited 0 without installing. */ + managed: string | null; } -export const EMPTY_PROGRESS: Progress = { at: -1, done: 0, total: 0, rollback: null, host: null }; +export const EMPTY_PROGRESS: Progress = { + at: -1, + done: 0, + total: 0, + rollback: null, + host: null, + managed: null, +}; /** Fold one event into the progress; phases only move forward, bytes belong to the current phase. */ export function reduceProgress(p: Progress, e: ProgressEvent): Progress { @@ -189,6 +199,8 @@ export function reduceProgress(p: Progress, e: ProgressEvent): Progress { return { ...p, rollback: e.state === "restored" ? "restored" : "failed" }; case "host": return { ...p, host: { state: e.state, live: e.live } }; + case "managed": + return { ...p, managed: e.how }; } } @@ -203,17 +215,22 @@ export function phaseFromLog(line: string): ProgressEvent | null { return null; } +/** + * How the last install ended. `managed`: the CLI exited 0 but installed nothing because another + * tool owns this Atlas (Homebrew, a package manager with no usable command), so it is neither a + * success nor a failure and no step ran. + */ +export type InstallOutcome = "running" | "done" | "failed" | "managed"; + /** * The step list for the sheet and Settings. `failed` marks the active step; `done` completes * every step except Relaunch, which is only done once the app restarts (`relaunched`). + * `managed` leaves every step pending: nothing was installed. */ -export function stepsFrom( - p: Progress, - outcome: "running" | "done" | "failed", - relaunchNeeded = false, -): Step[] { +export function stepsFrom(p: Progress, outcome: InstallOutcome, relaunchNeeded = false): Step[] { return STEPS.map((s, i) => { let state: StepState; + if (outcome === "managed") return { ...s, state: "pending" }; if (s.id === "relaunch") state = outcome === "done" && relaunchNeeded ? "active" : "pending"; else if (outcome === "done") state = "done"; else if (i < p.at) state = "done"; @@ -225,6 +242,29 @@ export function stepsFrom( }); } +/** A run that exited 0: `managed` when the CLI said another tool owns this install, else `done`. */ +export function finishOutcome(p: Progress): "done" | "managed" { + return p.managed ? "managed" : "done"; +} + +/** The toast a finished run shows outside the sheet; a managed install is never "up to date". */ +export function finishToast( + kind: "update" | "reinstall" | "unlink", + outcome: "done" | "managed", + managed: string | null, +): { level: "success" | "info"; text: string } { + if (outcome === "managed") { + return { + level: "info", + text: + managed === "homebrew" + ? "Atlas is managed by Homebrew: run `brew upgrade atlas` to update it" + : "Atlas is managed by your package manager: update it there (or run `atlas update` in a terminal)", + }; + } + return { level: "success", text: kind === "unlink" ? "Back on the release build" : "Atlas is up to date" }; +} + /** `42%` of a known download; null while the size is unknown. */ export function percent(p: Progress): number | null { return p.total > 0 ? Math.min(100, Math.round((p.done / p.total) * 100)) : null; diff --git a/crates/atlas-cli/src/commands/desktop.rs b/crates/atlas-cli/src/commands/desktop.rs index bf8a2119..fb42d3c0 100644 --- a/crates/atlas-cli/src/commands/desktop.rs +++ b/crates/atlas-cli/src/commands/desktop.rs @@ -99,6 +99,8 @@ impl AtlasCommand for DesktopArgs { if self.dev { return dev(); } + #[cfg(target_os = "macos")] + recover_interrupted_swap(); let installed = installed_app(); if self.status { return status(installed.as_deref()); @@ -271,11 +273,20 @@ async fn sync(installed: Option<&Path>) -> Result<()> { sync_app(installed) } +/// The one line `atlas update` prints when the app is a dev-build link it will not replace. +fn linked_notice(app: &Path) -> String { + format!( + "\x1b[2m◆ Atlas Desktop is linked to a dev build ({}); left alone. Run atlas unlink to update it.\x1b[0m", + app.display() + ) +} + /// Bring the installed app to this CLI's version. Fails (instead of printing and moving on) /// so `atlas update` can roll the CLI back and the app never ends up on another version. pub(crate) fn sync_app(installed: Option<&Path>) -> Result<()> { let Some(app) = installed else { return Ok(()) }; if is_symlink(app) { + println!("{}", linked_notice(app)); return Ok(()); } let want = current_version(); @@ -329,52 +340,44 @@ fn install_app(source: &Path, target: &Path, version: Option<&str>) -> Result<() } /// Put the bundle at `source` (`.app` dir or `.app.tar.gz`) at `target`, replacing any -/// existing app only once the new one is fully in place next to it. +/// existing app only once the new one is fully in place next to it. The old bundle is kept as +/// `.Atlas.app.old` until the new one is at `target`, so a crash between the two renames leaves +/// a bundle [`recover`] puts back, never no app at all. Staging dirs are removed on every path. #[cfg(target_os = "macos")] pub(crate) fn install(source: &Path, target: &Path) -> Result<()> { let parent = target.parent().context("bad install path")?; std::fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; - let staging = parent.join(".Atlas.app.new"); - let old = parent.join(".Atlas.app.old"); - for p in [&staging, &old] { - let _ = remove(p); - } + recover(target); + let staging = Staging(parent.join(".Atlas.app.new")); + let extract = Staging(parent.join(".Atlas.app.extract")); + // Leftovers of a run that was killed before its guards could drop. + let _ = remove(&staging.0); + let _ = remove(&extract.0); let src_app = if source.is_dir() { source.to_path_buf() } else { - let x = parent.join(".Atlas.app.extract"); - let _ = std::fs::remove_dir_all(&x); - std::fs::create_dir_all(&x)?; + std::fs::create_dir_all(&extract.0)?; run(Command::new("/usr/bin/tar") .arg("xzf") .arg(source) .arg("-C") - .arg(&x))?; - let app = x.join(APP); + .arg(&extract.0))?; + let app = extract.0.join(APP); if !app.is_dir() { bail!("{} does not contain {APP}", source.display()); } app }; // ditto keeps the bundle's symlinks, xattrs and code signature intact. - run(Command::new("/usr/bin/ditto").arg(&src_app).arg(&staging))?; + run(Command::new("/usr/bin/ditto").arg(&src_app).arg(&staging.0))?; let _ = Command::new("/usr/bin/xattr") .args(["-dr", "com.apple.quarantine"]) - .arg(&staging) + .arg(&staging.0) .status(); - if target.symlink_metadata().is_ok() { - std::fs::rename(target, &old) - .with_context(|| format!("cannot move {}", target.display()))?; - } - if let Err(e) = std::fs::rename(&staging, target) { - let _ = std::fs::rename(&old, target); - return Err(e).with_context(|| format!("cannot install {}", target.display())); - } - let _ = remove(&old); - let _ = std::fs::remove_dir_all(parent.join(".Atlas.app.extract")); + swap(&staging.0, target)?; // Refresh Launch Services so Spotlight/Dock pick up the new version. let _ = Command::new( "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", @@ -385,6 +388,76 @@ pub(crate) fn install(source: &Path, target: &Path) -> Result<()> { Ok(()) } +/// Removes a staging path when dropped, so no error path leaves a half-copied bundle behind +/// (the disk-full case is exactly when a leftover hurts most). +#[cfg(target_os = "macos")] +struct Staging(PathBuf); + +#[cfg(target_os = "macos")] +impl Drop for Staging { + fn drop(&mut self) { + let _ = remove(&self.0); + } +} + +/// The old bundle of an in-flight swap, beside `target`. +#[cfg(target_os = "macos")] +fn old_path(target: &Path) -> PathBuf { + target.with_file_name(".Atlas.app.old") +} + +/// Replace `target` by the finished bundle `staging` (same folder): the old one moves to +/// `.Atlas.app.old`, the new one takes its place, and only then is the old one deleted. When the +/// second rename fails the old bundle goes back. +#[cfg(target_os = "macos")] +fn swap(staging: &Path, target: &Path) -> Result<()> { + let old = old_path(target); + let _ = remove(&old); + let had_old = target.symlink_metadata().is_ok(); + if had_old { + std::fs::rename(target, &old) + .with_context(|| format!("cannot move {}", target.display()))?; + } + if let Err(e) = std::fs::rename(staging, target) { + if had_old { + let _ = std::fs::rename(&old, target); + } + return Err(e).with_context(|| format!("cannot install {}", target.display())); + } + let _ = remove(&old); + Ok(()) +} + +/// Undo a swap that was killed between its two renames: `target` is gone but `.Atlas.app.old` +/// is the app it replaced, so put it back. When both exist the swap finished and `old` is only +/// garbage. Returns true when the old app was restored. +#[cfg(target_os = "macos")] +pub(crate) fn recover(target: &Path) -> bool { + let old = old_path(target); + if old.symlink_metadata().is_err() { + return false; + } + if target.symlink_metadata().is_ok() { + let _ = remove(&old); + return false; + } + std::fs::rename(&old, target).is_ok() +} + +/// [`recover`] for every place an app can live, before anything looks for the installed one. +#[cfg(target_os = "macos")] +fn recover_interrupted_swap() { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")); + for dir in [PathBuf::from("/Applications"), home.join("Applications")] { + if recover(&dir.join(APP)) { + eprintln!( + "\x1b[33m◆\x1b[0m Restored {} from an interrupted update", + dir.join(APP).display() + ); + } + } +} + #[cfg(target_os = "macos")] fn remove(p: &Path) -> std::io::Result<()> { if is_symlink(p) || p.is_file() { @@ -481,6 +554,30 @@ fn dev() -> Result<()> { mod tests { use super::*; + /// A linked app is never replaced by an update: `sync_app` succeeds, the link and what it + /// points at stay, and the notice names the app and the way out in one line. + #[cfg(unix)] + #[test] + fn sync_leaves_a_linked_app_alone_and_says_so() { + let tmp = tempfile::tempdir().unwrap(); + let dev = tmp.path().join("dev-build"); + std::fs::write(&dev, "dev").unwrap(); + let app = tmp.path().join("atlas-app"); + std::os::unix::fs::symlink(&dev, &app).unwrap(); + + sync_app(Some(&app)).unwrap(); + + assert_eq!(std::fs::read_link(&app).unwrap(), dev); + assert_eq!(std::fs::read_to_string(&dev).unwrap(), "dev"); + let line = linked_notice(&app); + assert!(line.contains(&app.display().to_string()), "{line}"); + assert!( + line.contains("left alone") && line.contains("atlas unlink"), + "{line}" + ); + assert_eq!(line.lines().count(), 1); + } + /// The asset the release workflow uploads per platform (`release.yml`, `generate-manifest.py`). #[test] fn the_app_asset_is_the_platforms_bundle() { @@ -560,5 +657,100 @@ mod tests { let target = tmp.path().join("Apps").join(APP); assert!(install(&tarball, &target).is_err()); assert!(target.symlink_metadata().is_err()); + let left: Vec<_> = std::fs::read_dir(target.parent().unwrap()) + .map(|d| d.map(|e| e.unwrap().file_name()).collect()) + .unwrap_or_default(); + assert!(left.is_empty(), "{left:?}"); + } + + #[cfg(target_os = "macos")] + fn names(dir: &Path) -> Vec { + let mut v: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + v.sort(); + v + } + + /// A kill between `target → .Atlas.app.old` and `staging → target` leaves no `Atlas.app`. + /// The next sync/install must put the old app back instead of reporting "not installed". + #[cfg(target_os = "macos")] + #[test] + fn a_swap_killed_between_its_renames_is_recovered() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join(APP); + let old = fake_app(&tmp.path().join("prev"), "1.0.0"); + std::fs::rename(&old, old_path(&target)).unwrap(); + assert!(target.symlink_metadata().is_err()); + + assert!(recover(&target)); + assert_eq!(app_version(&target).as_deref(), Some("1.0.0")); + assert!(old_path(&target).symlink_metadata().is_err()); + // Nothing left to recover the second time. + assert!(!recover(&target)); + } + + /// The swap finished but the process died before deleting `old`: the new app stays, `old` goes. + #[cfg(target_os = "macos")] + #[test] + fn recovery_never_replaces_a_finished_install_with_its_leftover() { + let tmp = tempfile::tempdir().unwrap(); + let target = fake_app(tmp.path(), "2.0.0"); + let stale = fake_app(&tmp.path().join("prev"), "1.0.0"); + std::fs::rename(&stale, old_path(&target)).unwrap(); + + assert!(!recover(&target)); + assert_eq!(app_version(&target).as_deref(), Some("2.0.0")); + assert_eq!(names(tmp.path()), ["Atlas.app", "prev"]); + } + + /// `install` itself heals a previous crash first, so the update that follows starts from the + /// old app and ends on the new one with nothing else on disk. + #[cfg(target_os = "macos")] + #[test] + fn install_after_a_crashed_swap_ends_on_the_new_version_with_no_leftovers() { + let tmp = tempfile::tempdir().unwrap(); + let apps = tmp.path().join("Applications"); + std::fs::create_dir_all(&apps).unwrap(); + let target = apps.join(APP); + let prev = fake_app(&tmp.path().join("prev"), "1.0.0"); + std::fs::rename(&prev, old_path(&target)).unwrap(); + let newer = fake_app(&tmp.path().join("new"), "1.1.0"); + + install(&newer, &target).unwrap(); + + assert_eq!(app_version(&target).as_deref(), Some("1.1.0")); + assert_eq!(names(&apps), [APP]); + } + + /// The old app is still where it was while the new one is not yet in place, and comes back + /// when the second rename fails. + #[cfg(target_os = "macos")] + #[test] + fn a_failed_swap_keeps_the_old_app() { + let tmp = tempfile::tempdir().unwrap(); + let target = fake_app(tmp.path(), "1.0.0"); + // The staging bundle does not exist, so the second rename fails. + let missing = tmp.path().join(".Atlas.app.new"); + assert!(swap(&missing, &target).is_err()); + assert_eq!(app_version(&target).as_deref(), Some("1.0.0")); + assert!(old_path(&target).symlink_metadata().is_err()); + } + + /// A download that is not a tarball fails `tar` after the extract dir was created: the error + /// path must not leave the extract dir (or a staging bundle) behind, and the installed app stays. + #[cfg(target_os = "macos")] + #[test] + fn a_failure_after_staging_leaves_no_leftovers_and_keeps_the_old_app() { + let tmp = tempfile::tempdir().unwrap(); + let apps = tmp.path().join("Applications"); + let target = fake_app(&apps, "1.0.0"); + let corrupt = tmp.path().join("Atlas.app.tar.gz"); + std::fs::write(&corrupt, b"not a tarball").unwrap(); + + assert!(install(&corrupt, &target).is_err()); + assert_eq!(app_version(&target).as_deref(), Some("1.0.0")); + assert_eq!(names(&apps), [APP]); } } diff --git a/crates/atlas-cli/src/commands/desktop_linux.rs b/crates/atlas-cli/src/commands/desktop_linux.rs index 26d5104c..fe820574 100644 --- a/crates/atlas-cli/src/commands/desktop_linux.rs +++ b/crates/atlas-cli/src/commands/desktop_linux.rs @@ -34,22 +34,9 @@ pub(super) fn app_version(app: &Path) -> Option { Some(v.trim().to_string()).filter(|v| !v.is_empty()) } -/// Put the AppImage `source` at `target` (staged next to it, then renamed over the old one) and -/// record `version`; then (re)write the menu entry. +/// Put the AppImage `source` at `target` and record `version`; then (re)write the menu entry. pub(super) fn install(source: &Path, target: &Path, version: Option<&str>) -> Result<()> { - install_file(source, target)?; - let vf = version_file(); - match version { - Some(v) => { - if let Some(dir) = vf.parent() { - std::fs::create_dir_all(dir)?; - } - std::fs::write(&vf, v)?; - } - None => { - let _ = std::fs::remove_file(&vf); - } - } + install_at(source, target, version, &version_file())?; if let Some(home) = dirs::home_dir() && let Err(e) = write_menu_entry(&home, target) { @@ -61,7 +48,68 @@ pub(super) fn install(source: &Path, target: &Path, version: Option<&str>) -> Re Ok(()) } -fn install_file(source: &Path, target: &Path) -> Result<()> { +/// The previous AppImage, kept beside `target` until the new one is in place and recorded. +fn prev_path(target: &Path) -> PathBuf { + target.with_file_name(".atlas-desktop.prev") +} + +/// [`install_file`] plus the version record at `vf`, as one step: the previous app is kept as +/// `.atlas-desktop.prev` until both are done and put back when either fails, so an update never +/// leaves a missing or unrecorded app. A kill in between is healed by the next install. +fn install_at(source: &Path, target: &Path, version: Option<&str>, vf: &Path) -> Result<()> { + let prev = prev_path(target); + // A previous run killed between its renames: `prev` is the app that was there. + if target.symlink_metadata().is_err() && prev.symlink_metadata().is_ok() { + let _ = std::fs::rename(&prev, target); + } + let had_old = target.symlink_metadata().is_ok(); + let old_version = std::fs::read(vf).ok(); + install_file(source, target, &prev)?; + if let Err(e) = record_version(vf, version) { + restore(target, &prev, had_old, vf, old_version); + return Err(e); + } + let _ = std::fs::remove_file(&prev); + Ok(()) +} + +/// Undo a failed install: the old app (when there was one) and its version record come back. +fn restore(target: &Path, prev: &Path, had_old: bool, vf: &Path, old_version: Option>) { + if had_old { + let _ = std::fs::rename(prev, target); + } else { + let _ = std::fs::remove_file(target); + } + match old_version { + Some(v) => { + let _ = std::fs::write(vf, v); + } + None => { + let _ = std::fs::remove_file(vf); + } + } +} + +fn record_version(vf: &Path, version: Option<&str>) -> Result<()> { + match version { + Some(v) => { + if let Some(dir) = vf.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(vf, v) + .with_context(|| format!("cannot record version in {}", vf.display())) + } + None => { + let _ = std::fs::remove_file(vf); + Ok(()) + } + } +} + +/// Stage `source` next to `target`, move the existing app to `prev`, and rename the staged file +/// into place. The staging file is removed on every path; when the final rename fails `prev` +/// goes back. +fn install_file(source: &Path, target: &Path, prev: &Path) -> Result<()> { if !is_elf(source) { bail!( "{} is not an AppImage (expected an executable ELF file)", @@ -71,18 +119,34 @@ fn install_file(source: &Path, target: &Path) -> Result<()> { let parent = target.parent().context("bad install path")?; std::fs::create_dir_all(parent) .with_context(|| format!("cannot create {}", parent.display()))?; - let staging = parent.join(".atlas-desktop.new"); - let _ = std::fs::remove_file(&staging); - std::fs::copy(source, &staging).with_context(|| format!("cannot copy {}", source.display()))?; - std::fs::set_permissions(&staging, std::fs::Permissions::from_mode(0o755))?; - // rename(2) replaces the old file atomically; a running instance keeps its (unlinked) inode. - if let Err(e) = std::fs::rename(&staging, target) { - let _ = std::fs::remove_file(&staging); + let staging = Staging(parent.join(".atlas-desktop.new")); + std::fs::copy(source, &staging.0) + .with_context(|| format!("cannot copy {}", source.display()))?; + std::fs::set_permissions(&staging.0, std::fs::Permissions::from_mode(0o755))?; + let had_old = target.symlink_metadata().is_ok(); + if had_old { + std::fs::rename(target, prev) + .with_context(|| format!("cannot move {}", target.display()))?; + } + // rename(2) replaces atomically; a running instance keeps its (unlinked) inode. + if let Err(e) = std::fs::rename(&staging.0, target) { + if had_old { + let _ = std::fs::rename(prev, target); + } return Err(e).with_context(|| format!("cannot install {}", target.display())); } Ok(()) } +/// Removes the staged file when dropped (also after the successful rename, where it is gone). +struct Staging(PathBuf); + +impl Drop for Staging { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + fn is_elf(p: &Path) -> bool { use std::io::Read; let mut magic = [0u8; 4]; @@ -163,25 +227,42 @@ mod tests { std::fs::write(path, [b"\x7fELF".as_slice(), body].concat()).unwrap(); } + /// Installs through the real path with a version file inside the sandbox. + fn put(src: &Path, target: &Path, version: Option<&str>) -> Result<()> { + install_at(src, target, version, &target.with_file_name("version")) + } + + fn names(dir: &Path) -> Vec { + let mut v: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().into_string().unwrap()) + .collect(); + v.sort(); + v + } + #[test] - fn install_replaces_the_appimage_in_place_executable_and_leaves_no_staging() { + fn install_replaces_the_appimage_in_place_executable_and_leaves_no_staging_or_backup() { let tmp = tempfile::tempdir().unwrap(); let (a, b) = (tmp.path().join("a"), tmp.path().join("b")); fake_appimage(&a, b"one"); fake_appimage(&b, b"two"); let target = tmp.path().join("bin").join("atlas-desktop"); - install_file(&a, &target).unwrap(); - install_file(&b, &target).unwrap(); + put(&a, &target, Some("1.0.0")).unwrap(); + put(&b, &target, Some("1.1.0")).unwrap(); assert_eq!(std::fs::read(&target).unwrap(), b"\x7fELFtwo"); let mode = std::fs::metadata(&target).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o755); - let names: Vec<_> = std::fs::read_dir(target.parent().unwrap()) - .unwrap() - .map(|e| e.unwrap().file_name().into_string().unwrap()) - .collect(); - assert_eq!(names, ["atlas-desktop"]); + assert_eq!( + std::fs::read_to_string(target.with_file_name("version")).unwrap(), + "1.1.0" + ); + assert_eq!( + names(target.parent().unwrap()), + ["atlas-desktop", "version"] + ); } /// A truncated download or an HTML error page must never replace a working app. @@ -191,14 +272,88 @@ mod tests { let good = tmp.path().join("good"); fake_appimage(&good, b"ok"); let target = tmp.path().join("atlas-desktop"); - install_file(&good, &target).unwrap(); + put(&good, &target, Some("1.0.0")).unwrap(); for bad in [b"404".as_slice(), b"\x7fEL", b""] { let p = tmp.path().join("bad"); std::fs::write(&p, bad).unwrap(); - assert!(install_file(&p, &target).is_err(), "{bad:?}"); + assert!(put(&p, &target, Some("2.0.0")).is_err(), "{bad:?}"); assert_eq!(std::fs::read(&target).unwrap(), b"\x7fELFok"); } + assert_eq!( + std::fs::read_to_string(tmp.path().join("version")).unwrap(), + "1.0.0" + ); + } + + /// The new file is in place but its version cannot be recorded: the old app and the old + /// record come back instead of an app whose version disagrees with the CLI's. + #[test] + fn a_failure_after_the_swap_restores_the_previous_app_and_version() { + let tmp = tempfile::tempdir().unwrap(); + let (a, b) = (tmp.path().join("a"), tmp.path().join("b")); + fake_appimage(&a, b"one"); + fake_appimage(&b, b"two"); + let bin = tmp.path().join("bin"); + let target = bin.join("atlas-desktop"); + let vf = bin.join("version"); + install_at(&a, &target, Some("1.0.0"), &vf).unwrap(); + + // A directory where the version file goes: the write fails after the swap. + std::fs::remove_file(&vf).unwrap(); + std::fs::create_dir(&vf).unwrap(); + assert!(install_at(&b, &target, Some("2.0.0"), &vf).is_err()); + + assert_eq!(std::fs::read(&target).unwrap(), b"\x7fELFone"); + assert!(vf.is_dir()); + assert_eq!(names(&bin), ["atlas-desktop", "version"]); + } + + /// A kill between "old → .prev" and "new → target" leaves no app. The next install puts the + /// old one back first, and ends on the new one with nothing else around. + #[test] + fn an_install_killed_between_its_renames_is_recovered_by_the_next_one() { + let tmp = tempfile::tempdir().unwrap(); + let (a, b) = (tmp.path().join("a"), tmp.path().join("b")); + fake_appimage(&a, b"one"); + fake_appimage(&b, b"two"); + let bin = tmp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let target = bin.join("atlas-desktop"); + std::fs::copy(&a, prev_path(&target)).unwrap(); + assert!(target.symlink_metadata().is_err()); + + // The recovery runs even when the follow-up install is refused. + let junk = tmp.path().join("junk"); + std::fs::write(&junk, "").unwrap(); + assert!(put(&junk, &target, Some("2.0.0")).is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"\x7fELFone"); + + put(&b, &target, Some("2.0.0")).unwrap(); + assert_eq!(std::fs::read(&target).unwrap(), b"\x7fELFtwo"); + assert_eq!(names(&bin), ["atlas-desktop", "version"]); + } + + /// The install fails before the swap (the folder is read-only, so nothing can be staged): + /// the old app and its record stay, and no staging or backup file appears. + #[test] + fn a_failure_before_the_swap_leaves_no_staging_file_and_keeps_the_old_app() { + let tmp = tempfile::tempdir().unwrap(); + let bin = tmp.path().join("bin"); + let target = bin.join("atlas-desktop"); + let src = tmp.path().join("src"); + fake_appimage(&src, b"x"); + put(&src, &target, Some("1.0.0")).unwrap(); + // A target whose folder is read-only: staging cannot even be created. + let ro = std::fs::Permissions::from_mode(0o555); + std::fs::set_permissions(&bin, ro).unwrap(); + let r = put(&src, &target, Some("2.0.0")); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + if r.is_ok() { + return; // root ignores the mode; nothing to assert + } + assert_eq!(std::fs::read(&target).unwrap(), b"\x7fELFx"); + assert_eq!(names(&bin), ["atlas-desktop", "version"]); } #[test] diff --git a/crates/atlas-cli/src/commands/desktop_windows.rs b/crates/atlas-cli/src/commands/desktop_windows.rs index 4337a196..6bc3ab1a 100644 --- a/crates/atlas-cli/src/commands/desktop_windows.rs +++ b/crates/atlas-cli/src/commands/desktop_windows.rs @@ -57,8 +57,9 @@ fn reg_value(out: &str, name: &str) -> Option { } /// Run the installer `source` silently with its install dir set to `target`'s folder, wait for -/// it, and check the app is there. The installer closes a running Atlas first and replaces the -/// files in place. +/// it, and check the app is there at `version`. The installer closes a running Atlas first and +/// replaces the files in place; the previous install folder is kept aside until the new one is +/// verified and put back when the installer fails or leaves another version behind. pub(super) fn install(source: &Path, target: &Path, version: Option<&str>) -> Result<()> { if !is_pe(source) { bail!( @@ -66,9 +67,17 @@ pub(super) fn install(source: &Path, target: &Path, version: Option<&str>) -> Re source.display() ); } - let dir = target.parent().context("bad install path")?; - std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?; - // NSIS: `/S` silent; `/D=` must come last and unquoted, even with spaces. + install_with( + target, + version, + &version_file(), + |dir| run_installer(source, dir), + registered_version, + ) +} + +/// NSIS: `/S` silent; `/D=` must come last and unquoted, even with spaces. +fn run_installer(source: &Path, dir: &Path) -> Result<()> { let st = Command::new(source) .arg("/S") .raw_arg(format!("/D={}", dir.display())) @@ -78,19 +87,98 @@ pub(super) fn install(source: &Path, target: &Path, version: Option<&str>) -> Re if !st.success() { bail!("the Atlas installer failed ({st})"); } - if !target.is_file() { - bail!("the installer finished but {} is missing", target.display()); + Ok(()) +} + +/// The previous install folder, beside the new one. +fn backup_dir(dir: &Path) -> PathBuf { + let mut name = dir.file_name().unwrap_or_default().to_os_string(); + name.push(".prev"); + dir.with_file_name(name) +} + +/// The install steps around the installer, with the installer (`run`: given the install folder) +/// and the registry read (`registered`) injected. On any failure the previous folder and version +/// record are restored; on success the backup is deleted. +/// +/// The check: `target` is a file and, when the installer registered a version, it equals +/// `version`. An installer that registers nothing (a `--from` build) is judged by the file only. +fn install_with( + target: &Path, + version: Option<&str>, + vf: &Path, + run: impl FnOnce(&Path) -> Result<()>, + registered: impl FnOnce() -> Option, +) -> Result<()> { + let dir = target.parent().context("bad install path")?; + let backup = backup_dir(dir); + // A previous run killed after moving the install aside: put it back first. + if !dir.exists() && backup.exists() { + let _ = std::fs::rename(&backup, dir); + } + let _ = std::fs::remove_dir_all(&backup); + let had_old = dir.exists(); + if had_old { + std::fs::rename(dir, &backup).with_context(|| { + format!( + "cannot move {} aside (is Atlas still running?)", + dir.display() + ) + })?; + } + let old_version = std::fs::read(vf).ok(); + + let result = (|| -> Result<()> { + std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?; + run(dir)?; + if !target.is_file() { + bail!("the installer finished but {} is missing", target.display()); + } + if let (Some(want), Some(got)) = (version, registered()) + && got != want + { + bail!("the installer left Atlas Desktop {got}, expected {want}"); + } + record_version(vf, version) + })(); + + match result { + Ok(()) => { + let _ = std::fs::remove_dir_all(&backup); + Ok(()) + } + Err(e) => { + let _ = std::fs::remove_dir_all(dir); + if had_old { + let _ = std::fs::rename(&backup, dir); + } + match old_version { + Some(v) => { + let _ = std::fs::write(vf, v); + } + None => { + let _ = std::fs::remove_file(vf); + } + } + Err(e.context(if had_old { + "Atlas Desktop update failed; the previous install was restored" + } else { + "Atlas Desktop install failed" + })) + } } - let vf = version_file(); +} + +fn record_version(vf: &Path, version: Option<&str>) -> Result<()> { match version { Some(v) => { if let Some(d) = vf.parent() { std::fs::create_dir_all(d)?; } - std::fs::write(&vf, v)?; + std::fs::write(vf, v)?; } None => { - let _ = std::fs::remove_file(&vf); + let _ = std::fs::remove_file(vf); } } Ok(()) @@ -157,3 +245,136 @@ mod tests { assert!(!target.exists()); } } + +#[cfg(test)] +mod install_tests { + use super::*; + + /// An installer stand-in that writes `body` as the app into the folder it is given. + fn installs(body: &'static str) -> impl FnOnce(&Path) -> Result<()> { + move |dir| { + std::fs::write(dir.join("atlas-desktop.exe"), body)?; + Ok(()) + } + } + + /// A sandbox with Atlas `1.0.0` installed: (root, target, version file). + fn installed() -> (tempfile::TempDir, PathBuf, PathBuf) { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("Atlas"); + std::fs::create_dir(&dir).unwrap(); + let target = dir.join("atlas-desktop.exe"); + std::fs::write(&target, "old").unwrap(); + std::fs::write(dir.join("data.bin"), "keep").unwrap(); + let vf = tmp.path().join("app-version"); + std::fs::write(&vf, "1.0.0").unwrap(); + (tmp, target, vf) + } + + fn names(dir: &Path) -> Vec { + let mut v: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + v.sort(); + v + } + + #[test] + fn a_verified_install_replaces_the_app_records_the_version_and_drops_the_backup() { + let (tmp, target, vf) = installed(); + install_with(&target, Some("1.1.0"), &vf, installs("new"), || { + Some("1.1.0".into()) + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "new"); + assert_eq!(std::fs::read_to_string(&vf).unwrap(), "1.1.0"); + assert_eq!(names(tmp.path()), ["Atlas", "app-version"]); + } + + #[test] + fn a_failing_installer_restores_the_previous_install_and_version() { + let (tmp, target, vf) = installed(); + let e = install_with( + &target, + Some("1.1.0"), + &vf, + |dir| { + // Half-written, then the installer dies. + std::fs::write(dir.join("atlas-desktop.exe"), "partial")?; + bail!("the Atlas installer failed (exit code: 1)") + }, + || None, + ) + .unwrap_err(); + assert!(format!("{e:#}").contains("installer failed"), "{e:#}"); + assert!(format!("{e:#}").contains("previous install was restored")); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old"); + assert_eq!( + std::fs::read_to_string(target.with_file_name("data.bin")).unwrap(), + "keep" + ); + assert_eq!(std::fs::read_to_string(&vf).unwrap(), "1.0.0"); + assert_eq!(names(tmp.path()), ["Atlas", "app-version"]); + } + + #[test] + fn an_installer_that_leaves_another_version_is_rolled_back() { + let (tmp, target, vf) = installed(); + let e = install_with(&target, Some("1.1.0"), &vf, installs("new"), || { + Some("1.0.5".into()) + }) + .unwrap_err(); + assert!(format!("{e:#}").contains("left Atlas Desktop 1.0.5, expected 1.1.0")); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(&vf).unwrap(), "1.0.0"); + assert_eq!(names(tmp.path()), ["Atlas", "app-version"]); + } + + #[test] + fn an_installer_that_finishes_without_the_app_is_rolled_back() { + let (tmp, target, vf) = installed(); + let e = install_with(&target, Some("1.1.0"), &vf, |_| Ok(()), || None).unwrap_err(); + assert!(format!("{e:#}").contains("is missing"), "{e:#}"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old"); + assert_eq!(names(tmp.path()), ["Atlas", "app-version"]); + } + + /// A first install has nothing to restore: a failure leaves no folder and no record. + #[test] + fn a_failed_first_install_leaves_nothing() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("Atlas").join("atlas-desktop.exe"); + let vf = tmp.path().join("app-version"); + assert!(install_with(&target, Some("1.0.0"), &vf, |_| bail!("boom"), || None).is_err()); + assert!(names(tmp.path()).is_empty(), "{:?}", names(tmp.path())); + } + + /// An installer that registers no version (a `--from` build) is judged by the file alone. + #[test] + fn an_unregistered_version_is_not_a_failure() { + let (_tmp, target, vf) = installed(); + install_with(&target, None, &vf, installs("new"), || None).unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "new"); + assert!(!vf.exists()); + } + + /// Killed after the install was moved aside, before the installer ran: the next run puts the + /// old install back, then replaces it. + #[test] + fn an_install_killed_after_the_move_is_recovered_by_the_next_one() { + let (tmp, target, vf) = installed(); + std::fs::rename(tmp.path().join("Atlas"), tmp.path().join("Atlas.prev")).unwrap(); + + let e = install_with(&target, Some("1.1.0"), &vf, |_| bail!("boom"), || None).unwrap_err(); + assert!(format!("{e:#}").contains("boom")); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "old"); + + install_with(&target, Some("1.1.0"), &vf, installs("new"), || { + Some("1.1.0".into()) + }) + .unwrap(); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "new"); + assert_eq!(names(tmp.path()), ["Atlas", "app-version"]); + } +} diff --git a/crates/atlas-cli/src/commands/mod.rs b/crates/atlas-cli/src/commands/mod.rs index e9fb8884..df710102 100644 --- a/crates/atlas-cli/src/commands/mod.rs +++ b/crates/atlas-cli/src/commands/mod.rs @@ -28,6 +28,7 @@ pub mod skills; pub mod uninstall; pub(crate) mod update_channel; pub mod update_core; +pub(crate) mod update_pkg; pub mod update_prompt; pub mod version; pub mod wt; diff --git a/crates/atlas-cli/src/commands/self_update.rs b/crates/atlas-cli/src/commands/self_update.rs index e6438a33..7a5a95f1 100755 --- a/crates/atlas-cli/src/commands/self_update.rs +++ b/crates/atlas-cli/src/commands/self_update.rs @@ -14,10 +14,13 @@ use super::AtlasCommand; use super::release::{REPO, download_verified}; use super::update_channel::{self as ch, Pick}; use super::update_core; +use super::update_pkg::{self, PackageManager}; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Hit GitHub at most every 6h (cached result is reused in between). const CHECK_INTERVAL_SECS: u64 = 6 * 3600; +/// After a failed live check (offline), do not try again for this long. +pub(crate) const FAILED_BACKOFF_SECS: u64 = 30 * 60; /// `atlas update` — see `docs/cli.md#atlas-update`. #[derive(Debug, Args)] @@ -36,7 +39,7 @@ pub struct UpdateArgs { /// Install the older release the channel offers when you run a newer build #[arg(long)] pub downgrade: bool, - /// Do not ask for confirmation (with --downgrade) + /// Do not ask for confirmation (a downgrade, or installing a system package as root) #[arg(short, long)] pub yes: bool, } @@ -84,6 +87,11 @@ pub(crate) struct UpdateCache { /// channels existed, which only ever knew the stable releases. #[serde(default)] pub(crate) channel: Option, + /// When the last live check *failed* (offline, GitHub down), unix secs. Until + /// [`FAILED_BACKOFF_SECS`] passed, nothing re-checks, so an offline `atlas dev` does not + /// wait on the network at every start. Cleared by the next successful check. + #[serde(default)] + pub(crate) failed_at: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] @@ -103,6 +111,25 @@ impl UpdateCache { available_version: available, dismissed: self.dismissed.clone(), channel: Some(on.as_str().to_string()), + failed_at: None, + } + } + + /// Cache after a check on `on` that could not reach GitHub: what was known for `on` stays + /// (the notice keeps working from it), the failure is stamped so nothing retries for a while. + /// An offer computed for another channel is not carried over. + pub(crate) fn failed_for(&self, on: Channel, now: u64) -> Self { + let same = self.valid_for(on); + Self { + last_checked: if same { self.last_checked } else { 0 }, + available_version: if same { + self.available_version.clone() + } else { + None + }, + dismissed: self.dismissed.clone(), + channel: Some(on.as_str().to_string()), + failed_at: Some(now), } } @@ -111,8 +138,15 @@ impl UpdateCache { self.channel.as_deref().unwrap_or(Channel::Stable.as_str()) == on.as_str() } - /// Older than the check interval, never written, or computed for another channel. + /// A check is due: the cache is older than the check interval, never written, or computed + /// for another channel, and the last attempt did not fail within the backoff window. pub(crate) fn is_stale_for(&self, on: Channel, now: u64) -> bool { + if self + .failed_at + .is_some_and(|f| now.saturating_sub(f) < FAILED_BACKOFF_SECS) + { + return false; + } !self.valid_for(on) || now.saturating_sub(self.last_checked) >= CHECK_INTERVAL_SECS } @@ -151,19 +185,37 @@ pub(crate) fn now_secs() -> u64 { } pub(crate) fn load_cache() -> UpdateCache { - let path = cache_path(); - std::fs::read_to_string(&path) + load_cache_at(&cache_path()) +} + +/// The cache at `path`. A file that does not parse as a whole (a field of an unexpected type, +/// a hand edit) still keeps what can be salvaged — the dismissal above all, so "No" to the +/// prompt is not forgotten — and counts as never checked. Missing or unreadable: default. +pub(crate) fn load_cache_at(path: &std::path::Path) -> UpdateCache { + let Ok(text) = std::fs::read_to_string(path) else { + return UpdateCache::default(); + }; + if let Ok(cache) = serde_json::from_str(&text) { + return cache; + } + let dismissed = serde_json::from_str::(&text) .ok() - .and_then(|s| serde_json::from_str(&s).ok()) - .unwrap_or_default() + .and_then(|v| serde_json::from_value::(v.get("dismissed")?.clone()).ok()); + UpdateCache { + dismissed, + ..UpdateCache::default() + } } pub(crate) fn save_cache(cache: &UpdateCache) { - let path = cache_path(); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + save_cache_at(&cache_path(), cache); +} + +/// Best-effort and atomic (`.tmp` + rename): `atlas dev` and `atlas update` may run at once. +pub(crate) fn save_cache_at(path: &std::path::Path, cache: &UpdateCache) { + if let Ok(json) = serde_json::to_string(cache) { + let _ = update_core::write_atomic(path, json.as_bytes()); } - let _ = std::fs::write(path, serde_json::to_string(cache).unwrap_or_default()); } // ── Install method detection ────────────────────────────────────────────────── @@ -172,17 +224,36 @@ pub(crate) fn save_cache(cache: &UpdateCache) { pub enum InstallMethod { Homebrew, ShellInstaller, - /// A Linux distro package (.deb, .rpm, Arch) put it in `/usr/bin`: its package manager updates it. - SystemPackage, + /// A Linux distro package (.deb, .rpm, Arch) owns the binary: its package manager updates + /// it. `Some` names the manager that owns it (asked with `pacman -Qo`, `dpkg -S`, `rpm -qf`, + /// or, for a `/usr/bin` binary no database claims, the first manager installed); `None` is + /// a `/usr/bin` binary on a machine with none of them, where only instructions can be given. + SystemPackage(Option), Manual, } impl InstallMethod { - pub fn detect() -> Self { - let exe = env::current_exe().unwrap_or_default(); - Self::of_path(&exe.to_string_lossy()) + /// How `exe` was installed. `owner` asks the package databases (`pacman -Qo`, `dpkg -S`, + /// `rpm -qf`); it is injected so the table is testable without any of them. A binary under + /// `/usr/bin` that no database claims is still a system package (the path rule), updated + /// with whichever manager the machine has. + pub(crate) fn of_exe( + exe: &std::path::Path, + owner: impl FnOnce(&std::path::Path) -> Option, + ) -> Self { + match Self::of_path(&exe.to_string_lossy()) { + Self::SystemPackage(_) => Self::SystemPackage(owner(exe).or_else(installed_manager)), + // A package can own a binary outside /usr/bin (a custom prefix); the database + // knows. A checkout's build output or a cargo install is never package-owned, so + // no database is asked about it. Machines without those tools answer None. + Self::Manual if !is_dev_location(exe) => { + owner(exe).map_or(Self::Manual, |pm| Self::SystemPackage(Some(pm))) + } + other => other, + } } + /// The path rule alone: where each installer puts the binary. fn of_path(path: &str) -> Self { let path = &path.replace('\\', "/"); if path.contains("/opt/homebrew") @@ -195,13 +266,37 @@ impl InstallMethod { } else if cfg!(target_os = "linux") && (path.starts_with("/usr/bin/") || path.starts_with("/bin/")) { - Self::SystemPackage + Self::SystemPackage(None) } else { Self::Manual } } } +/// A checkout's build output or a cargo install: never package-owned, so no database is asked. +fn is_dev_location(exe: &std::path::Path) -> bool { + let p = exe.to_string_lossy().replace('\\', "/"); + p.contains("/target/") || p.contains("/.cargo/bin/") +} + +/// The package manager this machine has, for a `/usr/bin` binary no package database claims. +fn installed_manager() -> Option { + let has = |n: &str| atlas_core::paths::which(n).is_some(); + if has("pacman") { + Some(PackageManager::Pacman) + } else if has("dpkg") { + Some(PackageManager::Apt) + } else if has("rpm") { + Some(if has("zypper") && !has("dnf") { + PackageManager::Zypper + } else { + PackageManager::Dnf + }) + } else { + None + } +} + // ── Platform ────────────────────────────────────────────────────────────────── pub fn current_platform() -> String { @@ -274,16 +369,26 @@ async fn background_check_inner() -> Result<()> { if !cache.is_stale_for(on, now_secs()) { return Ok(()); } - let available = newer_than_current(&fetch_latest_version(on).await?); - if let Some(v) = &available { - atlas_tui::update_notice::publish(v.clone()); + match fetch_latest_version(on).await { + Ok(latest) => { + let available = newer_than_current(&latest); + if let Some(v) = &available { + atlas_tui::update_notice::publish(v.clone()); + } + save_cache(&cache.checked_for(on, available)); + Ok(()) + } + Err(e) => { + // Keep what was known; do not ask again before the backoff passed. + save_cache(&cache.failed_for(on, now_secs())); + Err(e) + } } - save_cache(&cache.checked_for(on, available)); - Ok(()) } /// Newest version for the pre-flight prompt: the cache when fresh, otherwise a live -/// check bounded by `timeout`. Timeout or error falls back to the cached value silently. +/// check bounded by `timeout`. Timeout or error falls back to the cached value silently and +/// stamps the failure, so an offline machine does not wait `timeout` at every start. pub(crate) async fn newest_for_preflight( timeout: std::time::Duration, ) -> (UpdateCache, Option) { @@ -300,7 +405,24 @@ pub(crate) async fn newest_for_preflight( save_cache(&fresh); (fresh, available) } - _ => (cache, cached), + _ => { + let failed = cache.failed_for(on, now_secs()); + save_cache(&failed); + (failed, cached) + } + } +} + +/// What the user runs to take `version`: `atlas update` where atlas installs it itself, the +/// package manager's own command for a package install (`atlas update` also does it, but the +/// exact command is what one can read, copy and audit), `brew upgrade atlas` for Homebrew. +pub(crate) fn update_command(method: &InstallMethod, version: &str) -> String { + match method { + InstallMethod::Homebrew => "brew upgrade atlas".to_string(), + InstallMethod::SystemPackage(Some(pm)) => { + format!("atlas update ({})", pm.manual_command(version)) + } + _ => "atlas update".to_string(), } } @@ -317,11 +439,15 @@ pub fn print_update_notice() { .unwrap_or(80) .clamp(40, 200); let rule = "─".repeat(width); + let run = update_command( + &InstallMethod::of_exe(&update_core::judged_exe(), update_pkg::owner), + &v, + ); eprintln!(); eprintln!("\x1b[2m{rule}\x1b[0m"); eprintln!(" \x1b[1;32m⬆ Update available\x1b[0m"); eprintln!( - " atlas \x1b[2mv{CURRENT_VERSION}\x1b[0m → \x1b[1mv{v}\x1b[0m Run: \x1b[1;36matlas update\x1b[0m" + " atlas \x1b[2mv{CURRENT_VERSION}\x1b[0m → \x1b[1mv{v}\x1b[0m Run: \x1b[1;36m{run}\x1b[0m" ); eprintln!("\x1b[2m{rule}\x1b[0m"); } @@ -350,8 +476,12 @@ async fn fetch_latest_version(on: Channel) -> Result { pub(crate) enum UpdateOutcome { /// The binary on disk is now the newer release. Installed, - /// Nothing was replaced (Homebrew, already current, `--check`). + /// Nothing was replaced (already current, `--check`, a declined confirmation). Unchanged, + /// Nothing was installed because another tool owns this Atlas (Homebrew, or a system + /// package with no usable package manager): the user was told what to run. Callers must not + /// report "up to date" or re-exec for it. + Managed, } /// Ask before installing an older build (default No). `--yes` skips the question; outside a @@ -476,7 +606,8 @@ fn installed_version() -> semver::Version { } pub(crate) async fn run_update(req: Request) -> Result { - let method = InstallMethod::detect(); + let judged = update_core::judged_exe(); + let method = InstallMethod::of_exe(&judged, update_pkg::owner); // `--channel X` sticks, even where this binary cannot install anything itself. if let Some(on) = req.channel { @@ -484,34 +615,16 @@ pub(crate) async fn run_update(req: Request) -> Result { } let on = req.channel.unwrap_or_else(channel::current); - if method == InstallMethod::Homebrew { - if req.json { - bail!( - "--json needs an Atlas installed by the shell installer; Homebrew manages this one" - ); - } - println!("\x1b[1;34m◆\x1b[0m Installed via Homebrew — run:"); - println!(" brew upgrade atlas"); - return Ok(UpdateOutcome::Unchanged); - } - if method == InstallMethod::SystemPackage { - if req.json { - bail!( - "--json needs an Atlas installed by the shell installer; a system package manages this one" - ); - } - println!( - "\x1b[1;34m◆\x1b[0m Installed by a system package — update it with your package manager:" - ); - println!( - " sudo pacman -U codeatlas-atlas-bin-*.pkg.tar.zst # Arch (or your AUR helper)" + // A dev build or an `atlas link` symlink is never overwritten by a release, whatever + // installed it: the one rule the `atlas dev` prompt uses (`update_core::is_dev_binary`). + // `--check` only reports, so it is allowed. + if !req.check_only && update_core::is_dev_binary() { + bail!( + "this atlas is a dev build or linked to one ({}); `atlas update` only replaces release installs. \ + `atlas unlink` goes back to the release build", + judged.display() ); - println!(" sudo apt install ./atlas_*_amd64.deb # Debian, Ubuntu"); - println!(" sudo dnf install ./atlas-*.x86_64.rpm # Fedora, openSUSE"); - println!(" (packages: https://github.com/{REPO}/releases/latest)"); - return Ok(UpdateOutcome::Unchanged); } - // `atlas link` made ~/.atlas/bin/atlas a symlink to target/debug/atlas: replacing // "the binary" would overwrite the dev build, not install a release. if !req.check_only @@ -536,28 +649,40 @@ pub(crate) async fn run_update(req: Request) -> Result { println!(); } - let tag = fetch_latest_tag(on) - .await - .context("Could not check for updates. Check your network connection (needs curl).")?; + // A failed check keeps the cache as it was (plus a backoff stamp): a network error is + // neither "current" nor "ahead", and must not silence the notice. + let tag = match fetch_latest_tag(on).await { + Ok(tag) => tag, + Err(e) => { + save_cache(&load_cache().failed_for(on, now_secs())); + return Err(e.context( + "Could not check for updates. Check your network connection (needs curl).", + )); + } + }; if req.json { // `--json` is a report: it never installs. - println!( - "{}", - serde_json::to_string_pretty(&ch::check_json(¤t, on, Some(&tag)))? - ); + let report = ch::check_json(¤t, on, Some(&tag)); + let report = managed_report(&method, ¤t, &tag, report); + println!("{}", serde_json::to_string_pretty(&report)?); return Ok(UpdateOutcome::Unchanged); } let (offered, decision) = ch::offer(¤t, &tag).with_context(|| format!("Bad remote version '{tag}'"))?; + let managed = !matches!( + method, + InstallMethod::ShellInstaller | InstallMethod::Manual + ); match decision { Decision::Current => { println!("{}", ch::describe(¤t, &tag, on, req.downgrade)); // Clear any stale cache entry save_cache(&load_cache().checked_for(on, None)); - // The app may still lag behind (an earlier sync failed or was skipped). + // The app may still lag behind (an earlier sync failed or was skipped). Homebrew + // and packages own the CLI, not the app: the sync is still ours to do. if !req.check_only { run_self(&["desktop", "--sync"]); } @@ -569,6 +694,11 @@ pub(crate) async fn run_update(req: Request) -> Result { if !req.downgrade || req.check_only { return Ok(UpdateOutcome::Unchanged); } + if managed { + bail!( + "--downgrade needs an Atlas installed by the shell installer; another tool manages this one" + ); + } if !confirm_downgrade(¤t, &offered, on, req.yes)? { println!("Cancelled; nothing was changed."); return Ok(UpdateOutcome::Unchanged); @@ -589,12 +719,61 @@ pub(crate) async fn run_update(req: Request) -> Result { "\x1b[1;33m◆\x1b[0m {}", ch::describe(¤t, &tag, on, false) ); + if let InstallMethod::SystemPackage(Some(pm)) = &method { + println!( + " Installed by {}: `atlas update` downloads, verifies and installs the package.", + pm.name() + ); + } return Ok(UpdateOutcome::Unchanged); } } } let latest = offered.to_string(); + match &method { + InstallMethod::Homebrew => { + println!("\x1b[1;34m◆\x1b[0m Installed via Homebrew — run:"); + println!(" brew upgrade atlas"); + println!(" (latest: v{latest})"); + update_core::managed_event("homebrew"); + return Ok(UpdateOutcome::Managed); + } + InstallMethod::SystemPackage(Some(pm)) => { + return install_package(*pm, &latest, &tag, req.yes).await; + } + InstallMethod::SystemPackage(None) => { + println!( + "\x1b[1;34m◆\x1b[0m Installed by a system package, but no package manager (pacman, dpkg, rpm) was found." + ); + println!( + " Install the v{latest} package for your distribution: https://github.com/{REPO}/releases/tag/{tag}" + ); + update_core::managed_event("package"); + return Ok(UpdateOutcome::Managed); + } + InstallMethod::ShellInstaller | InstallMethod::Manual => {} + } + + let exe = env::current_exe().context("cannot locate the running atlas binary")?; + + // Every artifact this update replaces must be writable *before* anything is: the CLI's + // directory (new binary and `.atlas.prev`) and the installed app (`atlas desktop --sync`). + // Otherwise the CLI would be replaced, the app sync would fail and the CLI be rolled back. + let app = atlas_core::paths::installed_app(); + let mut artifacts: Vec<(&str, &std::path::Path)> = vec![("Atlas CLI", exe.as_path())]; + if let Some(app) = app.as_deref() { + artifacts.push(("Atlas Desktop", app)); + } + let blocked = update_core::unwritable(&artifacts); + if !blocked.is_empty() { + bail!( + "cannot update, nothing was changed: {}. Fix the permissions, or reinstall into your home \ + with the shell installer (`sudo atlas update` would leave root-owned files there)", + blocked.join("; ") + ); + } + // Held until this function returns; the kernel frees it if we die. The spawned // `atlas desktop --sync` must not wait on us, hence the env marker on that child. let _lock = update_core::UpdateLock::acquire()?; @@ -632,11 +811,10 @@ pub(crate) async fn run_update(req: Request) -> Result { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(tmp_bin.path(), std::fs::Permissions::from_mode(0o755))?; } - let exe = env::current_exe().context("cannot locate the running atlas binary")?; let backup = update_core::CliBackup::save(&exe)?; if let Err(e) = self_replace::self_replace(tmp_bin.path()) { backup.discard(); - return Err(e).context("Failed to replace binary. Try: sudo atlas update"); + return Err(e).context("Failed to replace the atlas binary"); } // Clear cache @@ -646,14 +824,7 @@ pub(crate) async fn run_update(req: Request) -> Result { println!("\x1b[1;32m✓\x1b[0m Updated to v{latest}"); println!(); - // The skills (and agent hook) are embedded in the *new* binary, so refresh them by - // running it; the running process still holds the old embedded copy. - println!("\x1b[1;34m◆\x1b[0m Refreshing skills..."); - refresh_with_new_binary(); - - // The app must follow the CLI. When it cannot, the previous CLI is put back so the two - // never sit on different versions unnoticed, and the error says what happened. - backup.finish(sync_desktop_with_new_binary(&exe), CURRENT_VERSION, &latest)?; + settle(&exe, backup, CURRENT_VERSION, &latest)?; // Last step: the terminal host outlives updates, so it is usually older than the CLI and // app by now. The new binary upgrades it live, restarts it when it is empty, or says @@ -663,6 +834,109 @@ pub(crate) async fn run_update(req: Request) -> Result { Ok(UpdateOutcome::Installed) } +/// The `--check --json` report for an install another tool owns. +fn managed_report( + method: &InstallMethod, + current: &semver::Version, + tag: &str, + report: serde_json::Value, +) -> serde_json::Value { + match method { + InstallMethod::Homebrew => ch::managed_json(report, "homebrew", None, "brew upgrade atlas"), + InstallMethod::SystemPackage(pm) => { + let command = match (*pm, ch::offer(current, tag)) { + (Some(pm), Some((v, _))) => pm.manual_command(&v.to_string()), + _ => "atlas update".to_string(), + }; + ch::managed_json(report, "package", pm.map(PackageManager::name), &command) + } + InstallMethod::ShellInstaller | InstallMethod::Manual => report, + } +} + +/// Settle a CLI update once the binary at `exe` is the new one and `backup` holds the old one: +/// bring the app to the new version, then refresh skills and agent hooks with whichever binary +/// stays on disk. Order matters. The refresh used to run first, so a failed app sync rolled +/// the CLI back but left skills, MCP entries and hooks on the new version. +fn settle( + exe: &std::path::Path, + backup: update_core::CliBackup, + from: &str, + to: &str, +) -> Result<()> { + let synced = sync_desktop_with_new_binary(exe); + let result = backup.finish(synced, from, to); + println!("\x1b[1;34m◆\x1b[0m Refreshing skills..."); + refresh_with(exe); + result +} + +/// `#144`: update a package-managed install through its package manager. The asset and its +/// checksum are downloaded and verified *before* anything asks for privileges; then one +/// escalated package-manager command installs it, under the update lock, and the installed +/// binary refreshes skills and agent hooks. +async fn install_package( + pm: PackageManager, + latest: &str, + tag: &str, + yes: bool, +) -> Result { + let exe = update_core::judged_exe(); + let asset = pm.asset(latest); + println!("\x1b[1;34m◆\x1b[0m Downloading {asset}..."); + update_core::phase(update_core::Phase::Download); + let tmp_dir = tempfile::Builder::new().prefix("atlas-update-").tempdir()?; + let file = + download_verified(tag, &asset, tmp_dir.path(), update_core::bytes).with_context(|| { + format!("Download failed. Releases: https://github.com/{REPO}/releases/tag/{tag}") + })?; + update_core::phase(update_core::Phase::Verify); + println!("\x1b[1;32m✓\x1b[0m Checksum verified"); + + let tty = interactive(); + let has = |n: &str| atlas_core::paths::which(n).is_some(); + let is_root = update_pkg::is_root(); + let argv = update_pkg::command_line(update_pkg::escalation(is_root, tty, has)?, pm, &file); + println!(); + println!(" This installs v{latest} with {} as root:", pm.name()); + println!(" {}", argv.join(" ")); + if !yes { + if !tty { + bail!( + "installing a system package needs confirmation: run it in a terminal, or pass --yes" + ); + } + print!("Install v{latest} now? [y/N] "); + std::io::stdout().flush()?; + if !ch::parse_confirm(&read_line().unwrap_or_default()) { + println!("Cancelled; nothing was changed."); + return Ok(UpdateOutcome::Unchanged); + } + } + + // Nothing above changed the machine. From here a second update (another terminal, the + // app) must wait: the package manager and the refresh are one critical section. + let _lock = update_core::UpdateLock::acquire()?; + update_core::phase(update_core::Phase::InstallCli); + println!("\x1b[1;34m◆\x1b[0m Installing with {}...", pm.name()); + update_pkg::run_install(&argv)?; + save_cache(&load_cache().checked_for(channel::current(), None)); + println!(); + println!("\x1b[1;32m✓\x1b[0m Updated to v{latest}"); + println!(); + + // The package manager replaced the file at `exe`; the *installed* binary carries the new + // skills and hooks, this process still holds the old ones. + println!("\x1b[1;34m◆\x1b[0m Refreshing skills..."); + refresh_with(&exe); + // The app is package-owned too (`atlas-desktop` in the same package set); nothing to sync + // unless a user-level app exists, which `desktop --sync` handles. + update_core::phase(update_core::Phase::UpdateApp); + sync_desktop_best_effort(&exe); + reconcile_host_with_new_binary(&exe); + Ok(UpdateOutcome::Installed) +} + /// `atlas hostd --reconcile` of the new binary. A failure is reported, not fatal: the CLI and /// the app are already on the new version, and rolling them back would not fix the host. fn reconcile_host_with_new_binary(exe: &std::path::Path) { @@ -699,21 +973,28 @@ fn sync_desktop_with_new_binary(exe: &std::path::Path) -> Result<()> { } } -/// Run the freshly installed binary to refresh the skills and, when already set up, -/// the OMP / Claude Code / Codex integrations (hooks, MCP entry), all embedded in that binary. -/// Failures are non-fatal. -fn refresh_with_new_binary() { - let Ok(exe) = env::current_exe() else { - eprintln!("\x1b[33m◆\x1b[0m Skills refresh skipped: cannot locate the new binary"); - return; - }; +/// `atlas desktop --sync` of the installed binary after a package install: the app follows +/// the CLI when a user-level one exists. A failure is reported, never fatal (the package +/// manager already committed the CLI; there is no CLI backup to restore). +fn sync_desktop_best_effort(exe: &std::path::Path) { + if let Err(e) = sync_desktop_with_new_binary(exe) { + eprintln!( + "\x1b[33m◆\x1b[0m Atlas Desktop could not follow: {e:#} (non-fatal); run `atlas desktop --install`" + ); + } +} + +/// Run `exe` to refresh the skills and, when already set up, the OMP / Claude Code / Codex +/// integrations (hooks, MCP entry), all embedded in that binary: the running process still +/// holds its own (old) copy. Failures are non-fatal. +fn refresh_with(exe: &std::path::Path) { for args in [ &["skills", "install"][..], &["setup", "omp", "--refresh"][..], &["setup", "claude-code", "--refresh"][..], &["setup", "codex", "--refresh"][..], ] { - run_self_with(&exe, args); + run_self_with(exe, args); } } @@ -774,14 +1055,14 @@ mod tests { } #[test] - fn test_install_method_detect() { - let m = InstallMethod::detect(); + fn test_install_method_of_this_binary() { + let m = InstallMethod::of_exe(&update_core::judged_exe(), update_pkg::owner); assert!(matches!( m, InstallMethod::Manual | InstallMethod::ShellInstaller | InstallMethod::Homebrew - | InstallMethod::SystemPackage + | InstallMethod::SystemPackage(_) )); } @@ -790,7 +1071,7 @@ mod tests { #[test] fn a_binary_in_usr_bin_is_a_system_package_on_linux_only() { let pkg = if cfg!(target_os = "linux") { - InstallMethod::SystemPackage + InstallMethod::SystemPackage(None) } else { InstallMethod::Manual }; @@ -834,6 +1115,7 @@ mod tests { available_version: Some("1.2.3".into()), dismissed: None, channel: Some("beta".into()), + failed_at: None, }; cache.dismiss("1.2.3", 1000); let json = serde_json::to_string(&cache).unwrap(); @@ -1084,3 +1366,349 @@ mod tests { ); } } + +#[cfg(test)] +mod robustness_tests { + use super::*; + use std::path::Path; + + fn claims(pm: PackageManager) -> impl FnOnce(&Path) -> Option { + move |_| Some(pm) + } + fn nobody(_: &Path) -> Option { + None + } + + /// The detection table of #144: the package database decides, the path rule is the fallback. + #[test] + fn install_method_detection_table() { + use InstallMethod::*; + let p = |s: &str| std::path::PathBuf::from(s); + // A database claims a binary even outside /usr/bin (custom prefix). + assert_eq!( + InstallMethod::of_exe(&p("/opt/atlas/bin/atlas"), claims(PackageManager::Apt)), + SystemPackage(Some(PackageManager::Apt)) + ); + assert_eq!( + InstallMethod::of_exe(&p("/usr/local/bin/atlas"), claims(PackageManager::Pacman)), + SystemPackage(Some(PackageManager::Pacman)) + ); + // Nobody claims it: a hand-copied binary stays Manual. + assert_eq!( + InstallMethod::of_exe(&p("/usr/local/bin/atlas"), nobody), + Manual + ); + // Homebrew and the shell installer are decided by path, never by a database. + assert_eq!( + InstallMethod::of_exe(&p("/opt/homebrew/bin/atlas"), claims(PackageManager::Apt)), + Homebrew + ); + assert_eq!( + InstallMethod::of_exe(&p("/home/u/.atlas/bin/atlas"), claims(PackageManager::Apt)), + ShellInstaller + ); + // A checkout's build output and a cargo install are never package-owned. + assert_eq!( + InstallMethod::of_exe( + &p("/home/u/src/atlas/target/debug/atlas"), + claims(PackageManager::Apt) + ), + Manual + ); + assert_eq!( + InstallMethod::of_exe(&p("/home/u/.cargo/bin/atlas"), claims(PackageManager::Apt)), + Manual + ); + // /usr/bin on Linux is a system package even when no database answers. + if cfg!(target_os = "linux") { + assert!(matches!( + InstallMethod::of_exe(&p("/usr/bin/atlas"), nobody), + SystemPackage(_) + )); + assert_eq!( + InstallMethod::of_exe(&p("/usr/bin/atlas"), claims(PackageManager::Dnf)), + SystemPackage(Some(PackageManager::Dnf)) + ); + } + } + + #[test] + fn the_notice_names_the_command_for_each_install_method() { + let apt = InstallMethod::SystemPackage(Some(PackageManager::Apt)); + let line = update_command(&apt, "1.6.0"); + assert!(line.starts_with("atlas update"), "{line}"); + assert!( + line.contains("sudo apt install ./atlas_1.6.0_amd64.deb"), + "{line}" + ); + assert_eq!( + update_command(&InstallMethod::Homebrew, "1.6.0"), + "brew upgrade atlas" + ); + assert_eq!( + update_command(&InstallMethod::ShellInstaller, "1.6.0"), + "atlas update" + ); + assert_eq!( + update_command(&InstallMethod::Manual, "1.6.0"), + "atlas update" + ); + assert_eq!( + update_command(&InstallMethod::SystemPackage(None), "1.6.0"), + "atlas update" + ); + } + + // ── G7: the cache is written atomically and a damaged file keeps the dismissal ── + + #[test] + fn save_cache_leaves_no_temp_file_and_roundtrips() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".update_check"); + let mut c = UpdateCache::default().checked_for(Channel::Alpha, Some("9.9.9".into())); + c.dismiss("9.9.9", 5); + save_cache_at(&path, &c); + assert_eq!(load_cache_at(&path), c); + let names: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, [".update_check"], "{names:?}"); + } + + #[test] + fn a_truncated_cache_still_counts_as_never_checked() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("c"); + std::fs::write(&path, r#"{"last_checked":17,"available_ver"#).unwrap(); + assert_eq!(load_cache_at(&path), UpdateCache::default()); + assert!(load_cache_at(&path).is_stale_for(Channel::Alpha, 20)); + } + + /// The reported loss: a cache with a good dismissal but one field of the wrong type used to + /// reset to `default()`, so "No" to the prompt was forgotten and `atlas dev` asked again. + #[test] + fn a_cache_that_does_not_parse_keeps_its_dismissal() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("c"); + std::fs::write( + &path, + r#"{"last_checked":"yesterday","available_version":"9.9.9", + "dismissed":{"version":"9.9.9","until":4102444800}}"#, + ) + .unwrap(); + let c = load_cache_at(&path); + assert!(c.is_dismissed("9.9.9", 1_000), "{c:?}"); + assert_eq!(c.available_version, None, "the rest is not trusted"); + assert!( + c.is_stale_for(Channel::Alpha, 1_000), + "and the next check refetches" + ); + } + + #[test] + fn garbage_or_a_missing_cache_is_the_default() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + load_cache_at(&dir.path().join("none")), + UpdateCache::default() + ); + let p = dir.path().join("g"); + std::fs::write(&p, "not json at all").unwrap(); + assert_eq!(load_cache_at(&p), UpdateCache::default()); + } + + // ── G14: a failed check is stamped with a backoff ── + + #[test] + fn a_failed_check_backs_off_and_keeps_what_was_known() { + let known = UpdateCache { + last_checked: 1_000, + available_version: Some("9.9.9".into()), + dismissed: None, + channel: Some("alpha".into()), + failed_at: None, + }; + let now = 1_000 + CHECK_INTERVAL_SECS + 10; // stale: a check is due + assert!(known.is_stale_for(Channel::Alpha, now)); + let failed = known.failed_for(Channel::Alpha, now); + assert_eq!( + failed.available_version.as_deref(), + Some("9.9.9"), + "notice keeps working" + ); + assert_eq!(failed.last_checked, 1_000, "not pretended fresh"); + assert!( + !failed.is_stale_for(Channel::Alpha, now + 1), + "no retry inside the backoff" + ); + assert!(!failed.is_stale_for(Channel::Alpha, now + FAILED_BACKOFF_SECS - 1)); + assert!( + failed.is_stale_for(Channel::Alpha, now + FAILED_BACKOFF_SECS), + "then it retries" + ); + } + + #[test] + fn a_successful_check_clears_the_failure() { + let failed = UpdateCache::default().failed_for(Channel::Alpha, 50); + assert_eq!(failed.failed_at, Some(50)); + assert_eq!(failed.checked_for(Channel::Alpha, None).failed_at, None); + } + + #[test] + fn a_failure_never_carries_another_channels_offer() { + let beta = UpdateCache::default().checked_for(Channel::Beta, Some("2.0.0-beta.1".into())); + let failed = beta.failed_for(Channel::Alpha, 99); + assert_eq!(failed.available_version, None); + assert_eq!(failed.channel.as_deref(), Some("alpha")); + assert_eq!(failed.available_for(&ver("1.0.0"), Channel::Alpha), None); + } + + #[test] + fn a_failure_keeps_the_dismissal() { + let mut c = UpdateCache::default(); + c.dismiss("2.0.0", 10); + assert!(c.failed_for(Channel::Alpha, 20).is_dismissed("2.0.0", 21)); + } + + fn ver(s: &str) -> semver::Version { + semver::Version::parse(s).unwrap() + } + + // ── G11: the JSON report names the owner and what to run ── + + #[test] + fn managed_report_for_each_install_method() { + let cur = ver("1.0.0"); + let base = ch::check_json(&cur, Channel::Stable, Some("v1.1.0")); + let pkg = managed_report( + &InstallMethod::SystemPackage(Some(PackageManager::Pacman)), + &cur, + "v1.1.0", + base.clone(), + ); + assert_eq!(pkg["action"], "package"); + assert_eq!(pkg["manager"], "pacman"); + assert_eq!( + pkg["command"], + PackageManager::Pacman.manual_command("1.1.0") + ); + let brew = managed_report(&InstallMethod::Homebrew, &cur, "v1.1.0", base.clone()); + assert_eq!(brew["action"], "managed"); + let own = managed_report(&InstallMethod::ShellInstaller, &cur, "v1.1.0", base.clone()); + assert_eq!(own, base, "self-installed reports are untouched"); + } +} + +#[cfg(all(test, unix))] +mod settle_tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + /// A stand-in for an atlas binary: logs ` ` to `log`, and fails `desktop --sync` + /// when `fail_sync`. + fn stub(path: &std::path::Path, name: &str, log: &std::path::Path, fail_sync: bool) { + let fail = if fail_sync { + "[ \"$1 $2\" = 'desktop --sync' ] && exit 1\n" + } else { + "" + }; + std::fs::write( + path, + format!( + "#!/bin/sh\necho \"{name} $*\" >> '{}'\n{fail}exit 0\n", + log.display() + ), + ) + .unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + fn lines(log: &std::path::Path) -> Vec { + std::fs::read_to_string(log) + .unwrap_or_default() + .lines() + .map(String::from) + .collect() + } + + /// The incident (G1): the CLI is rolled back when `atlas desktop --sync` fails, but skills, + /// MCP entries and hooks had already been refreshed by the *new* binary, so the CLI that + /// stays was older than what it left on disk. The refresh must come after, by whichever + /// binary stays. + #[test] + fn a_failed_app_sync_rolls_back_and_the_old_binary_refreshes() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("calls"); + let exe = dir.path().join("atlas"); + stub(&exe, "old", &log, false); + let backup = update_core::CliBackup::save(&exe).unwrap(); + stub(&exe, "new", &log, true); // the freshly installed CLI; its app sync fails + + let e = settle(&exe, backup, "1.0.0", "1.1.0").unwrap_err(); + assert!(format!("{e:#}").contains("stays on v1.0.0"), "{e:#}"); + + let calls = lines(&log); + assert_eq!(calls[0], "new desktop --sync", "{calls:?}"); + assert!( + !calls + .iter() + .any(|c| c.starts_with("new skills") || c.starts_with("new setup")), + "the rolled-back binary's replacement must not have refreshed anything: {calls:?}" + ); + assert!( + calls.contains(&"old skills install".to_string()), + "{calls:?}" + ); + assert!( + calls.contains(&"old setup omp --refresh".to_string()), + "{calls:?}" + ); + } + + #[test] + fn a_successful_sync_refreshes_with_the_new_binary_afterwards() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("calls"); + let exe = dir.path().join("atlas"); + stub(&exe, "old", &log, false); + let backup = update_core::CliBackup::save(&exe).unwrap(); + stub(&exe, "new", &log, false); + + settle(&exe, backup, "1.0.0", "1.1.0").unwrap(); + + let calls = lines(&log); + assert_eq!(calls[0], "new desktop --sync", "{calls:?}"); + let skills = calls + .iter() + .position(|c| c == "new skills install") + .unwrap(); + assert!(skills > 0, "refresh follows the sync: {calls:?}"); + assert!(!calls.iter().any(|c| c.starts_with("old ")), "{calls:?}"); + assert!(!dir.path().join(".atlas.prev").exists(), "backup discarded"); + } + + #[test] + fn when_the_rollback_itself_fails_the_binary_on_disk_still_refreshes() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("calls"); + let exe = dir.path().join("atlas"); + stub(&exe, "old", &log, false); + let backup = update_core::CliBackup::save(&exe).unwrap(); + stub(&exe, "new", &log, true); + std::fs::remove_file(dir.path().join(".atlas.prev")).unwrap(); // nothing to restore + + let e = settle(&exe, backup, "1.0.0", "1.1.0").unwrap_err(); + assert!( + format!("{e:#}").contains("could not be rolled back"), + "{e:#}" + ); + // The new CLI is what is left, so it is the one whose skills match. + assert!( + lines(&log).contains(&"new skills install".to_string()), + "{:?}", + lines(&log) + ); + } +} diff --git a/crates/atlas-cli/src/commands/update_channel.rs b/crates/atlas-cli/src/commands/update_channel.rs index 7f6f3b40..50545062 100644 --- a/crates/atlas-cli/src/commands/update_channel.rs +++ b/crates/atlas-cli/src/commands/update_channel.rs @@ -60,6 +60,30 @@ pub(crate) fn check_json(installed: &Version, on: Channel, tag: Option<&str>) -> }) } +/// `check_json` for an install another tool owns (`how` = `homebrew` | `package`): the same +/// report plus who updates it. `action` becomes `package` / `managed` only when an upgrade is on +/// offer (the one case where something must be installed by that tool); `none` and `downgrade` +/// stay as they are. `command` is the exact line to run, only present for that upgrade. +pub(crate) fn managed_json( + mut report: Value, + how: &str, + manager: Option<&str>, + command: &str, +) -> Value { + let upgrade = report["action"] == "update"; + report["managed"] = json!(true); + report["manager"] = json!(manager); + report["command"] = if upgrade { json!(command) } else { Value::Null }; + if upgrade { + report["action"] = json!(if how == "package" { + "package" + } else { + "managed" + }); + } + report +} + /// `atlas update channel --json`: the real serialized views plus the selection. pub(crate) fn channels_json( views: &[ChannelView], @@ -253,6 +277,42 @@ mod tests { assert_eq!(none["action"], "none"); } + #[test] + fn managed_json_keeps_the_report_and_says_who_installs() { + let base = check_json(&ver("1.3.0"), Channel::Beta, Some("v1.4.0-beta.1")); + let pkg = managed_json(base.clone(), "package", Some("pacman"), "sudo pacman -U x"); + assert_eq!( + pkg["action"], "package", + "an update a package manager must install" + ); + assert_eq!(pkg["managed"], true); + assert_eq!(pkg["manager"], "pacman"); + assert_eq!(pkg["command"], "sudo pacman -U x"); + assert_eq!(pkg["installed"], "1.3.0"); + assert_eq!(pkg["offered"], "1.4.0-beta.1"); + let brew = managed_json(base, "homebrew", None, "brew upgrade atlas"); + assert_eq!(brew["action"], "managed"); + assert_eq!(brew["manager"], Value::Null); + } + + #[test] + fn managed_json_has_no_command_when_nothing_is_to_install() { + let cur = check_json(&ver("1.3.0"), Channel::Stable, Some("v1.3.0")); + let j = managed_json(cur, "package", Some("apt"), "sudo apt install ./x.deb"); + assert_eq!( + j["action"], "none", + "already current: nothing for anyone to install" + ); + assert_eq!(j["managed"], true); + assert_eq!(j["command"], Value::Null); + let down = check_json(&ver("1.5.0-alpha.1"), Channel::Stable, Some("v1.3.0")); + assert_eq!( + managed_json(down, "package", Some("apt"), "c")["action"], + "downgrade", + "a downgrade is reported as such, never as a package install" + ); + } + fn views(selected: Channel, installed: &Version) -> Vec { Channel::ALL .into_iter() diff --git a/crates/atlas-cli/src/commands/update_core.rs b/crates/atlas-cli/src/commands/update_core.rs index eb134f23..24d0ac21 100644 --- a/crates/atlas-cli/src/commands/update_core.rs +++ b/crates/atlas-cli/src/commands/update_core.rs @@ -73,6 +73,94 @@ pub fn rollback_event(state: &str) { } } +/// `@@atlas managed `: nothing was installed because another tool owns this Atlas +/// (`homebrew`, `package`). The app must not report "up to date" for it. +pub fn managed_event(how: &str) { + if enabled() { + println!("{}", managed_line(how)); + } +} + +fn managed_line(how: &str) -> String { + format!("@@atlas managed {how}") +} + +/// Write `bytes` to `path` through a sibling `.tmp` and a rename: a concurrent reader (or a +/// crash mid-write) sees the old file or the whole new one, never a truncated one. +pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let mut tmp = path.as_os_str().to_owned(); + tmp.push(format!(".{}.tmp", std::process::id())); + let tmp = PathBuf::from(tmp); + let write = std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)); + if write.is_err() { + let _ = std::fs::remove_file(&tmp); + } + write +} + +/// The path the install method and the dev-build rule are judged on: `current_exe()`, except +/// that debug builds honour `ATLAS_UPDATE_EXE` so the sandbox tests can stand a stub install +/// in a temp dir. Release builds ignore it. It never redirects what an update overwrites: +/// that is always the running binary. +pub fn judged_exe() -> PathBuf { + #[cfg(debug_assertions)] + if let Some(p) = std::env::var_os("ATLAS_UPDATE_EXE") { + return PathBuf::from(p); + } + std::env::current_exe().unwrap_or_default() +} + +/// This binary is a dev build (`cargo` debug profile) or an `atlas link` symlink: an update +/// would overwrite a developer's checkout output, so nothing here may replace it. +/// `ATLAS_UPDATE_PROMPT=force` lifts the debug-build half only (the pty integration tests run a +/// debug binary). +pub fn is_dev_binary() -> bool { + let forced = std::env::var("ATLAS_UPDATE_PROMPT").is_ok_and(|v| v == "force"); + is_dev_binary_at(&judged_exe(), forced) +} + +/// [`is_dev_binary`] for `exe`, with the debug-build rule lifted by `forced`. +pub fn is_dev_binary_at(exe: &Path, forced: bool) -> bool { + let symlinked = std::fs::symlink_metadata(exe).is_ok_and(|m| m.file_type().is_symlink()); + symlinked || (cfg!(debug_assertions) && !forced) +} + +/// The artifacts in `artifacts` (label, path) whose directory this user cannot write to, +/// as one sentence each, so an update can refuse before it has replaced anything. A path that +/// does not exist is not an artifact of this install and is skipped. +pub fn unwritable(artifacts: &[(&str, &Path)]) -> Vec { + artifacts + .iter() + .filter(|(_, p)| std::fs::symlink_metadata(p).is_ok()) + .filter_map(|(label, p)| { + let dir = p.parent()?; + (!can_write_dir(dir)).then(|| { + format!( + "{label} ({}) needs write access to {}", + p.display(), + dir.display() + ) + }) + }) + .collect() +} + +/// Create-and-remove a probe file: the only reliable writability test (mode bits, ACLs, +/// read-only mounts and immutable flags all show up as the same failure). +fn can_write_dir(dir: &Path) -> bool { + let probe = dir.join(format!(".atlas-write-probe-{}", std::process::id())); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + fn lock_path() -> PathBuf { atlas_core::paths::atlas_home().join(".update.lock") } @@ -272,3 +360,108 @@ mod tests { ); } } + +#[cfg(test)] +mod helper_tests { + use super::*; + + #[test] + fn write_atomic_replaces_whole_file_and_leaves_no_temp() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("cache"); + write_atomic(&p, b"old").unwrap(); + write_atomic(&p, b"newer content").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"newer content"); + let names: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, ["cache"], "no .tmp left behind: {names:?}"); + } + + #[test] + fn write_atomic_creates_missing_parents() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("a/b/cache"); + write_atomic(&p, b"x").unwrap(); + assert_eq!(std::fs::read(&p).unwrap(), b"x"); + } + + /// A failed atomic write must not clobber the previous file (the reader sees old or new, + /// never a truncated one). + #[test] + fn write_atomic_keeps_the_old_file_when_the_target_is_a_directory() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("cache"); + std::fs::create_dir(&p).unwrap(); + assert!(write_atomic(&p, b"x").is_err()); + assert!(p.is_dir()); + } + + #[test] + fn dev_binary_is_a_debug_build_or_a_symlinked_exe() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("atlas-real"); + std::fs::write(&real, b"x").unwrap(); + let plain = dir.path().join("atlas"); + std::fs::write(&plain, b"x").unwrap(); + // Release builds: only a symlink is a dev binary. Debug builds: always (unless forced). + assert_eq!(is_dev_binary_at(&plain, false), cfg!(debug_assertions)); + assert!( + !is_dev_binary_at(&plain, true), + "force lifts the debug rule" + ); + #[cfg(unix)] + { + let link = dir.path().join("linked"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + assert!( + is_dev_binary_at(&link, true), + "a symlink is never updatable" + ); + assert!(is_dev_binary_at(&link, false)); + } + } + + #[test] + fn managed_event_is_gated_on_progress_env_and_names_the_method() { + assert_eq!(managed_line("homebrew"), "@@atlas managed homebrew"); + assert_eq!(managed_line("package"), "@@atlas managed package"); + } + + #[cfg(unix)] + #[test] + fn writable_report_names_each_artifact_that_needs_privileges() { + use std::os::unix::fs::PermissionsExt; + if unsafe { libc::geteuid() } == 0 { + return; // root can write anywhere: nothing to demonstrate + } + let dir = tempfile::tempdir().unwrap(); + let ok = dir.path().join("ok"); + let ro = dir.path().join("ro"); + std::fs::create_dir(&ok).unwrap(); + std::fs::create_dir(&ro).unwrap(); + let ok_bin = ok.join("atlas"); + let ro_bin = ro.join("atlas-desktop"); + std::fs::write(&ok_bin, b"x").unwrap(); + std::fs::write(&ro_bin, b"x").unwrap(); + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o555)).unwrap(); + + let none = unwritable(&[("Atlas CLI", &ok_bin)]); + assert!(none.is_empty(), "{none:?}"); + + let bad = unwritable(&[("Atlas CLI", &ok_bin), ("Atlas Desktop", &ro_bin)]); + assert_eq!(bad.len(), 1, "{bad:?}"); + assert!(bad[0].contains("Atlas Desktop"), "{bad:?}"); + assert!(bad[0].contains(&ro.display().to_string()), "{bad:?}"); + + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[test] + fn a_missing_artifact_is_not_reported_as_unwritable() { + let dir = tempfile::tempdir().unwrap(); + let gone = dir.path().join("not-installed"); + assert!(unwritable(&[("Atlas Desktop", &gone)]).is_empty()); + } +} diff --git a/crates/atlas-cli/src/commands/update_pkg.rs b/crates/atlas-cli/src/commands/update_pkg.rs new file mode 100644 index 00000000..f07c74cc --- /dev/null +++ b/crates/atlas-cli/src/commands/update_pkg.rs @@ -0,0 +1,349 @@ +//! `atlas update` for an Atlas that a Linux package manager owns (#144): who owns the binary, +//! which release asset matches, and how to install it (checksum first, then one escalated +//! package-manager command). The orchestration lives in `self_update::run_update`. + +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; + +/// This process runs as root (no escalation needed). Package installs only exist on Linux. +pub fn is_root() -> bool { + #[cfg(unix)] + { + // SAFETY: `geteuid` has no preconditions and cannot fail. + unsafe { libc::geteuid() == 0 } + } + #[cfg(not(unix))] + { + false + } +} + +/// The package manager that owns `/usr/bin/atlas`, i.e. the asset format to install. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageManager { + /// Arch: `pacman -U codeatlas-atlas-bin-*.pkg.tar.zst`. + Pacman, + /// Debian, Ubuntu: `apt install ./atlas_*_amd64.deb`. + Apt, + /// Fedora and friends: `dnf install ./atlas-*.x86_64.rpm`. + Dnf, + /// openSUSE: `zypper install ./atlas-*.x86_64.rpm`. + Zypper, +} + +impl PackageManager { + /// The release asset for `version` (SemVer, no `v`). Names match `release.yml` / + /// `scripts/generate-manifest.py`; pacman drops the `-` of a prerelease (`1.6.0-beta.2` is + /// `1.6.0beta.2`, see `packaging/arch/render.sh`). + pub fn asset(self, version: &str) -> String { + match self { + Self::Pacman => format!( + "codeatlas-atlas-bin-{}-1-x86_64.pkg.tar.zst", + version.replacen('-', "", 1) + ), + Self::Apt => format!("atlas_{version}_amd64.deb"), + Self::Dnf | Self::Zypper => format!("atlas-{version}-1.x86_64.rpm"), + } + } + + /// Program and arguments that install the downloaded `file` (run under `sudo`/`pkexec`). + pub fn install_argv(self, file: &Path) -> Vec { + let f = file.display().to_string(); + match self { + Self::Pacman => ["pacman", "-U", "--noconfirm", &f] + .map(String::from) + .to_vec(), + Self::Apt => ["apt-get", "install", "-y", &f].map(String::from).to_vec(), + Self::Dnf => ["dnf", "install", "-y", &f].map(String::from).to_vec(), + Self::Zypper => [ + "zypper", + "--non-interactive", + "install", + "--allow-unsigned-rpm", + &f, + ] + .map(String::from) + .to_vec(), + } + } + + /// The command a human runs by hand (printed by the notice and `--check`). + pub fn manual_command(self, version: &str) -> String { + let asset = self.asset(version); + match self { + Self::Pacman => format!("sudo pacman -U {asset}"), + Self::Apt => format!("sudo apt install ./{asset}"), + Self::Dnf => format!("sudo dnf install ./{asset}"), + Self::Zypper => format!("sudo zypper install ./{asset}"), + } + } + + /// Short name for messages and the `--json` report. + pub const fn name(self) -> &'static str { + match self { + Self::Pacman => "pacman", + Self::Apt => "apt", + Self::Dnf => "dnf", + Self::Zypper => "zypper", + } + } +} + +/// How a package-owned binary is found out: by asking each package manager's database, in the +/// order that makes a wrong answer impossible (a file is owned by at most one database). +/// `probe` runs `program args…` and returns its stdout on exit 0, so tests stub it with the +/// real tools' output shapes. +pub fn owner_with( + path: &Path, + mut probe: impl FnMut(&str, &[&str]) -> Option, +) -> Option { + let p = path.to_str()?; + if let Some(out) = probe("pacman", &["-Qo", p]) { + // `/usr/bin/atlas is owned by codeatlas-atlas-bin 1.5.0alpha.13-2` + if out.contains(" is owned by ") { + return Some(PackageManager::Pacman); + } + } + if let Some(out) = probe("dpkg", &["-S", p]) { + // `atlas: /usr/bin/atlas` + if out + .lines() + .any(|l| l.rsplit_once(": ").is_some_and(|(_, f)| f == p)) + { + return Some(PackageManager::Apt); + } + } + if let Some(out) = probe("rpm", &["-qf", p]) { + // `atlas-1.5.0-1.x86_64`; `file … is not owned by any package` exits 1 and never lands here. + if !out.trim().is_empty() && !out.contains("not owned by") { + return Some(rpm_manager()); + } + } + None +} + +/// An rpm system updates through `zypper` on openSUSE and `dnf` elsewhere. +fn rpm_manager() -> PackageManager { + rpm_manager_on(|name| atlas_core::paths::which(name).is_some()) +} + +fn rpm_manager_on(has: impl Fn(&str) -> bool) -> PackageManager { + if has("dnf") { + PackageManager::Dnf + } else if has("zypper") { + PackageManager::Zypper + } else { + PackageManager::Dnf + } +} + +/// [`owner_with`] against the real package managers on `PATH`. +pub fn owner(path: &Path) -> Option { + owner_with(path, |program, args| { + let out = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).into_owned()) + }) +} + +/// What escalates the install command to root. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Escalation { + /// Already root: run it directly. + None, + /// `sudo` (needs a terminal for its password prompt unless it is passwordless). + Sudo, + /// `pkexec`: a graphical polkit prompt, usable without a terminal (the desktop app). + Pkexec, +} + +/// Pick how to become root. A terminal gets `sudo`; without one (the desktop app) `pkexec` +/// shows a graphical prompt, and `sudo` is the last resort (it works when passwordless). +pub fn escalation(is_root: bool, tty: bool, has: impl Fn(&str) -> bool) -> Result { + if is_root { + return Ok(Escalation::None); + } + match (tty, has("sudo"), has("pkexec")) { + (true, true, _) => Ok(Escalation::Sudo), + (false, _, true) => Ok(Escalation::Pkexec), + (false, true, false) => Ok(Escalation::Sudo), + (true, false, true) => Ok(Escalation::Pkexec), + _ => bail!("neither sudo nor pkexec is available to install the package as root"), + } +} + +/// The full command line: escalation prefix + package manager + file. +pub fn command_line(esc: Escalation, pm: PackageManager, file: &Path) -> Vec { + let mut argv = match esc { + Escalation::None => Vec::new(), + Escalation::Sudo => vec!["sudo".to_string()], + Escalation::Pkexec => vec!["pkexec".to_string()], + }; + argv.extend(pm.install_argv(file)); + argv +} + +/// Run `argv` attached to this terminal (sudo must be able to prompt) and fail on a non-zero +/// exit. Nothing has been escalated before this point: the checksum is already verified. +pub fn run_install(argv: &[String]) -> Result<()> { + let (program, args) = argv.split_first().context("empty install command")?; + let st = Command::new(program) + .args(args) + .status() + .with_context(|| format!("cannot run {program}"))?; + if st.success() { + Ok(()) + } else { + bail!("`{}` failed ({st})", argv.join(" ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn probe_table<'a>( + answers: &'a [(&'a str, Option<&'a str>)], + ) -> impl FnMut(&str, &[&str]) -> Option + 'a { + move |program, _| { + answers + .iter() + .find(|(p, _)| *p == program) + .and_then(|(_, out)| out.map(String::from)) + } + } + + #[test] + fn ownership_detection_table() { + let bin = Path::new("/usr/bin/atlas"); + let arch = [( + "pacman", + Some("/usr/bin/atlas is owned by codeatlas-atlas-bin 1.5.0alpha.13-2\n"), + )]; + assert_eq!( + owner_with(bin, probe_table(&arch)), + Some(PackageManager::Pacman) + ); + let deb = [("pacman", None), ("dpkg", Some("atlas: /usr/bin/atlas\n"))]; + assert_eq!( + owner_with(bin, probe_table(&deb)), + Some(PackageManager::Apt) + ); + let rpm = [ + ("pacman", None), + ("dpkg", None), + ("rpm", Some("atlas-1.5.0-1.x86_64\n")), + ]; + let got = owner_with(bin, probe_table(&rpm)); + assert!( + matches!(got, Some(PackageManager::Dnf | PackageManager::Zypper)), + "{got:?}" + ); + // Not owned by any database (a hand-copied /usr/bin/atlas): fall back to the path rule. + let none = [("pacman", None), ("dpkg", None), ("rpm", None)]; + assert_eq!(owner_with(bin, probe_table(&none)), None); + } + + /// `dpkg -S` also prints diversions and prefix matches for other files: only a + /// `: ` line counts. + #[test] + fn dpkg_output_must_name_the_binary() { + let bin = Path::new("/usr/bin/atlas"); + let other = [( + "dpkg", + Some("diversion by foo from: /usr/bin/atlas-x\natlas-x: /usr/bin/atlas-x\n"), + )]; + assert_eq!(owner_with(bin, probe_table(&other)), None); + } + + #[test] + fn rpm_manager_prefers_dnf_then_zypper() { + assert_eq!( + rpm_manager_on(|n| n == "dnf" || n == "zypper"), + PackageManager::Dnf + ); + assert_eq!(rpm_manager_on(|n| n == "zypper"), PackageManager::Zypper); + assert_eq!(rpm_manager_on(|_| false), PackageManager::Dnf); + } + + #[test] + fn asset_names_match_the_release_workflow() { + assert_eq!(PackageManager::Apt.asset("1.6.0"), "atlas_1.6.0_amd64.deb"); + assert_eq!( + PackageManager::Dnf.asset("1.6.0"), + "atlas-1.6.0-1.x86_64.rpm" + ); + assert_eq!( + PackageManager::Zypper.asset("1.6.0-beta.2"), + "atlas-1.6.0-beta.2-1.x86_64.rpm" + ); + assert_eq!( + PackageManager::Pacman.asset("1.6.0-beta.2"), + "codeatlas-atlas-bin-1.6.0beta.2-1-x86_64.pkg.tar.zst" + ); + assert_eq!( + PackageManager::Pacman.asset("1.5.0"), + "codeatlas-atlas-bin-1.5.0-1-x86_64.pkg.tar.zst" + ); + } + + #[test] + fn install_commands_use_the_right_manager() { + let f = Path::new("/tmp/x/atlas.pkg"); + assert_eq!( + command_line(Escalation::Sudo, PackageManager::Pacman, f), + ["sudo", "pacman", "-U", "--noconfirm", "/tmp/x/atlas.pkg"] + ); + assert_eq!( + command_line(Escalation::Pkexec, PackageManager::Apt, f)[..3], + ["pkexec", "apt-get", "install"] + ); + assert_eq!( + command_line(Escalation::None, PackageManager::Dnf, f)[..2], + ["dnf", "install"] + ); + let z = command_line(Escalation::Sudo, PackageManager::Zypper, f); + assert!(z.contains(&"zypper".to_string()) && z.contains(&"install".to_string())); + } + + #[test] + fn escalation_choice() { + let both = |n: &str| n == "sudo" || n == "pkexec"; + assert_eq!( + escalation(true, false, |_| false).unwrap(), + Escalation::None + ); + assert_eq!(escalation(false, true, both).unwrap(), Escalation::Sudo); + assert_eq!(escalation(false, false, both).unwrap(), Escalation::Pkexec); + assert_eq!( + escalation(false, false, |n| n == "sudo").unwrap(), + Escalation::Sudo, + "no tty and no pkexec: sudo may still be passwordless" + ); + assert_eq!( + escalation(false, true, |n| n == "pkexec").unwrap(), + Escalation::Pkexec + ); + assert!(escalation(false, true, |_| false).is_err()); + } + + #[test] + fn manual_command_is_what_a_human_runs() { + assert_eq!( + PackageManager::Apt.manual_command("1.6.0"), + "sudo apt install ./atlas_1.6.0_amd64.deb" + ); + assert_eq!( + PackageManager::Pacman.manual_command("1.6.0"), + "sudo pacman -U codeatlas-atlas-bin-1.6.0-1-x86_64.pkg.tar.zst" + ); + } +} diff --git a/crates/atlas-cli/src/commands/update_prompt.rs b/crates/atlas-cli/src/commands/update_prompt.rs index 6443e545..7120fc31 100644 --- a/crates/atlas-cli/src/commands/update_prompt.rs +++ b/crates/atlas-cli/src/commands/update_prompt.rs @@ -59,15 +59,12 @@ pub(crate) fn decide(env: &PromptEnv) -> Decision { Decision::Prompt(newer) } -/// Binary is an `atlas link` symlink or a debug build. `ATLAS_UPDATE_PROMPT=force` lifts the -/// debug-build half only (used by the pty integration test, which runs a debug binary). +/// Binary is an `atlas link` symlink or a debug build: the one rule `atlas update` itself +/// applies (`update_core::is_dev_binary`), so the prompt never offers what the update refuses. +/// `ATLAS_UPDATE_PROMPT=force` lifts the debug-build half only (used by the pty integration +/// test, which runs a debug binary). fn is_linked() -> bool { - let symlinked = std::env::current_exe() - .ok() - .and_then(|p| std::fs::read_link(p).ok()) - .is_some(); - let forced = std::env::var("ATLAS_UPDATE_PROMPT").is_ok_and(|v| v == "force"); - symlinked || (cfg!(debug_assertions) && !forced) + super::update_core::is_dev_binary() } fn env_flag(name: &str) -> bool { @@ -158,8 +155,8 @@ where // `reexec` only returns when exec failed; keep running the old version. Flow::Continue } - // Homebrew etc.: the update command told the user what to run; carry on. - Ok(UpdateOutcome::Unchanged) => Flow::Continue, + // Homebrew, a declined confirmation, already current: nothing changed, carry on. + Ok(UpdateOutcome::Unchanged | UpdateOutcome::Managed) => Flow::Continue, Err(e) => { eprintln!("\x1b[1;31merror:\x1b[0m update failed: {e}"); for cause in e.chain().skip(1) { @@ -248,7 +245,8 @@ pub(crate) async fn run_on_exit_update() { ); } } - Ok(UpdateOutcome::Unchanged) => {} + // Another tool owns this install and the user was told what to run. + Ok(UpdateOutcome::Unchanged | UpdateOutcome::Managed) => {} Err(e) => { eprintln!("\x1b[1;31merror:\x1b[0m update failed: {e}"); for cause in e.chain().skip(1) { @@ -448,3 +446,26 @@ mod tests { assert_eq!(flow, Flow::Abort); } } + +#[cfg(test)] +mod managed_tests { + use super::*; + use std::io::Cursor; + + /// Homebrew / a package manager owns this install and `atlas update` only told the user + /// what to run: the binary did not change, so the session must not re-exec. + #[tokio::test] + async fn accept_but_managed_elsewhere_does_not_reexec() { + let mut execd = false; + let mut input = Cursor::new(Vec::new()); + let flow = on_accept( + "0.4.1", + &mut input, + || async { Ok(UpdateOutcome::Managed) }, + || execd = true, + ) + .await; + assert!(!execd); + assert_eq!(flow, Flow::Continue); + } +} diff --git a/crates/atlas-cli/tests/update_pkg.rs b/crates/atlas-cli/tests/update_pkg.rs new file mode 100644 index 00000000..9db98117 --- /dev/null +++ b/crates/atlas-cli/tests/update_pkg.rs @@ -0,0 +1,438 @@ +//! `atlas update` against the real binary in a sandbox: a temp HOME, and stub `curl`, package +//! managers (`pacman`, `dpkg`, `rpm`) and privilege helpers (`sudo`, `pkexec`) first on `PATH`. +//! Nothing here touches the network, a real package manager or a real `sudo`. +//! +//! The binary under test is a debug build, which `atlas update` refuses to replace (it is a dev +//! binary). `ATLAS_UPDATE_PROMPT=force` lifts that half of the rule, and `ATLAS_UPDATE_EXE` +//! (debug builds only) stands a stub "installed" binary at `/usr/bin/atlas`, which is +//! what the install method is judged on and what the post-install refresh runs: it logs its +//! argv, so "refresh ran with the installed binary" is observable. + +#![cfg(unix)] + +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use sha2::{Digest, Sha256}; + +const BIN: &str = env!("CARGO_BIN_EXE_atlas"); +const LATEST: &str = "99.0.0"; + +fn script(path: &Path, body: &str) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +struct Sandbox { + root: tempfile::TempDir, +} + +impl Sandbox { + /// `owner` = which package database claims `/usr/bin/atlas`: `pacman`, `dpkg`, `rpm`, or + /// `none` (a hand-copied binary). `tty_less` is the default: `Command` has no tty. + fn new(owner: &str) -> Self { + let sb = Self { + root: tempfile::tempdir().unwrap(), + }; + let bin = sb.bin(); + let exe = sb.exe(); + let log = sb.log(); + + // The "installed" CLI: records every invocation (`$0 args`), fails `desktop --sync` + // when asked to. + script( + &exe, + &format!( + "echo \"atlas $*\" >> '{log}'\n\ + if [ \"$1 $2\" = 'desktop --sync' ] && [ -n \"$FAIL_SYNC\" ]; then exit 1; fi\n\ + exit 0", + log = log.display() + ), + ); + + let owned = exe.display().to_string(); + script( + &bin.join("pacman"), + &if owner == "pacman" { + format!( + "[ \"$1\" = -Qo ] && echo \"{owned} is owned by codeatlas-atlas-bin 1.5.0alpha.13-2\" && exit 0\nexit 1" + ) + } else { + "exit 1".into() + }, + ); + script( + &bin.join("dpkg"), + &if owner == "dpkg" { + format!("[ \"$1\" = -S ] && echo \"atlas: {owned}\" && exit 0\nexit 1") + } else { + "exit 1".into() + }, + ); + script( + &bin.join("rpm"), + &if owner == "rpm" { + "[ \"$1\" = -qf ] && echo 'atlas-1.5.0-1.x86_64' && exit 0\nexit 1".to_string() + } else { + "exit 1".into() + }, + ); + // Privilege helpers record what they were asked to run and do not run it. + for helper in ["sudo", "pkexec"] { + script( + &bin.join(helper), + &format!("echo \"{helper} $*\" >> '{}'\nexit 0", log.display()), + ); + } + script(&bin.join("curl"), &sb.curl_stub()); + sb + } + + fn bin(&self) -> PathBuf { + self.root.path().join("stubs") + } + fn home(&self) -> PathBuf { + self.root.path().join("home") + } + fn exe(&self) -> PathBuf { + self.root.path().join("usr/bin/atlas") + } + fn log(&self) -> PathBuf { + self.root.path().join("calls.log") + } + fn assets(&self) -> PathBuf { + self.root.path().join("assets") + } + + /// Serves the three pages the update check reads and any file in `assets/` by basename. + fn curl_stub(&self) -> String { + let assets = self.assets(); + format!( + r#" +for a in "$@"; do url="$a"; done +prev="" +out="" +for a in "$@"; do + if [ "$prev" = "-o" ]; then out="$a"; fi + prev="$a" +done +case "$url" in + */releases/latest) printf '%s' 'https://github.com/codeatlasdev/atlas/releases/tag/v{LATEST}'; exit 0;; + */releases.atom*) cat <<'EOF' + +EOF + exit 0;; + */manifest.json) printf '200'; exit 0;; +esac +name="${{url##*/}}" +if [ -f '{assets}'/"$name" ] && [ -n "$out" ] && [ "$out" != /dev/null ]; then + cp '{assets}'/"$name" "$out"; exit 0 +fi +case " $* " in *" -I "*) printf '1024'; exit 0;; esac +echo "curl stub: no fixture for $url" >&2 +exit 22 +"#, + assets = assets.display() + ) + } + + /// Put a release asset and its `.sha256` where the curl stub serves them. `sum` overrides + /// the digest (to stage a corrupted download). + fn asset(&self, name: &str, bytes: &[u8], sum: Option<&str>) { + let dir = self.assets(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(name), bytes).unwrap(); + let digest = sum + .map(str::to_string) + .unwrap_or_else(|| hex::encode(Sha256::digest(bytes))); + std::fs::write( + dir.join(format!("{name}.sha256")), + format!("{digest} {name}\n"), + ) + .unwrap(); + } + + fn calls(&self) -> Vec { + std::fs::read_to_string(self.log()) + .unwrap_or_default() + .lines() + .map(String::from) + .collect() + } + + fn run(&self, args: &[&str], env: &[(&str, &str)]) -> Output { + let path = format!( + "{}:/usr/bin:/bin", + self.bin().display() // stubs first, then only the base system tools + ); + let mut cmd = Command::new(BIN); + cmd.args(args) + .env_clear() + .env("HOME", self.home()) + .env("PATH", path) + .env("ATLAS_UPDATE_EXE", self.exe()) + .env("ATLAS_UPDATE_PROMPT", "force") + .env("ATLAS_UPDATE_CHANNEL", "alpha"); + for (k, v) in env { + cmd.env(k, v); + } + cmd.output().unwrap() + } +} + +fn text(o: &Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ) +} + +// ── #144: detection and reporting ───────────────────────────────────────────── + +/// Path rule + package database: each manager's database claims the binary and picks the asset. +#[test] +fn check_reports_current_and_latest_for_each_package_manager() { + for (owner, asset_hint) in [ + ("pacman", "pacman -U"), + ("dpkg", "apt install"), + ("rpm", "install ./atlas-"), + ] { + let sb = Sandbox::new(owner); + let o = sb.run(&["update", "--check"], &[]); + let out = text(&o); + assert!(o.status.success(), "{owner}: {out}"); + assert!(out.contains("Latest: v99.0.0"), "{owner}: {out}"); + assert!( + out.contains(&format!("Current: v{}", env!("CARGO_PKG_VERSION"))), + "{out}" + ); + assert!( + sb.calls().is_empty(), + "--check never escalates or installs: {:?}", + sb.calls() + ); + let j = sb.run(&["update", "--check", "--json"], &[]); + let v: serde_json::Value = serde_json::from_slice(&j.stdout) + .unwrap_or_else(|e| panic!("{owner}: {e}: {}", text(&j))); + assert_eq!(v["action"], "package", "{owner}: {v}"); + assert_eq!(v["managed"], true); + assert_eq!(v["offered"], LATEST); + assert!( + v["command"] + .as_str() + .unwrap() + .contains(asset_hint.split(' ').next().unwrap()), + "{owner}: {v}" + ); + } +} + +// ── #144: install ───────────────────────────────────────────────────────────── + +fn stage_deb(sb: &Sandbox) -> String { + let name = format!("atlas_{LATEST}_amd64.deb"); + sb.asset(&name, b"deb-bytes", None); + name +} + +#[test] +fn package_update_downloads_verifies_then_installs_through_sudo_and_refreshes() { + let sb = Sandbox::new("dpkg"); + // No terminal in a test, so pkexec would win when present: leave only sudo. + std::fs::remove_file(sb.bin().join("pkexec")).unwrap(); + let name = stage_deb(&sb); + let o = sb.run(&["update", "--yes"], &[]); + let out = text(&o); + assert!(o.status.success(), "{out}"); + let calls = sb.calls(); + + let sudo = calls + .iter() + .position(|c| c.starts_with("sudo apt-get install")) + .unwrap_or_else(|| panic!("no sudo apt-get install in {calls:?}\n{out}")); + assert!(calls[sudo].contains(&name), "{}", calls[sudo]); + // The refresh runs with the *installed* binary, after the package manager returned. + let refresh = calls + .iter() + .position(|c| c == "atlas skills install") + .unwrap_or_else(|| panic!("no refresh in {calls:?}\n{out}")); + assert!(refresh > sudo, "refresh must follow the install: {calls:?}"); + for agent in ["omp", "claude-code", "codex"] { + assert!( + calls + .iter() + .any(|c| c == &format!("atlas setup {agent} --refresh")), + "{agent}: {calls:?}" + ); + } +} + +#[test] +fn a_checksum_mismatch_aborts_before_any_escalation() { + let sb = Sandbox::new("dpkg"); + let name = format!("atlas_{LATEST}_amd64.deb"); + sb.asset(&name, b"deb-bytes", Some(&"0".repeat(64))); + let o = sb.run(&["update", "--yes"], &[]); + let out = text(&o); + assert!(!o.status.success(), "{out}"); + assert!(out.contains("checksum mismatch"), "{out}"); + assert!( + sb.calls().is_empty(), + "sudo/pkexec/package manager must never run on an unverified file: {:?}", + sb.calls() + ); +} + +#[test] +fn a_missing_checksum_file_also_aborts_before_any_escalation() { + let sb = Sandbox::new("pacman"); + let name = format!("codeatlas-atlas-bin-{LATEST}-1-x86_64.pkg.tar.zst"); + sb.asset(&name, b"pkg", None); + std::fs::remove_file(sb.assets().join(format!("{name}.sha256"))).unwrap(); + let o = sb.run(&["update", "--yes"], &[]); + assert!(!o.status.success(), "{}", text(&o)); + assert!(sb.calls().is_empty(), "{:?}", sb.calls()); +} + +#[test] +fn declining_the_confirmation_installs_nothing() { + let sb = Sandbox::new("dpkg"); + stage_deb(&sb); + // No `--yes` and no terminal: there is nobody to ask, which is a refusal, not a guess. + let o = sb.run(&["update"], &[]); + let out = text(&o); + assert!(!o.status.success(), "{out}"); + assert!(out.contains("--yes"), "tells how to proceed: {out}"); + assert!(sb.calls().is_empty(), "{:?}", sb.calls()); +} + +#[test] +fn without_a_terminal_pkexec_is_the_escalation() { + let sb = Sandbox::new("rpm"); + sb.asset(&format!("atlas-{LATEST}-1.x86_64.rpm"), b"rpm", None); + let o = sb.run(&["update", "--yes"], &[]); + let out = text(&o); + assert!(o.status.success(), "{out}"); + let calls = sb.calls(); + assert!( + calls.iter().any(|c| c.starts_with("pkexec ")), + "no tty → pkexec: {calls:?}" + ); + assert!(!calls.iter().any(|c| c.starts_with("sudo ")), "{calls:?}"); +} + +#[test] +fn a_failing_package_manager_is_an_error_and_skips_the_refresh() { + let sb = Sandbox::new("dpkg"); + stage_deb(&sb); + script( + &sb.bin().join("pkexec"), + &format!("echo \"pkexec $*\" >> '{}'\nexit 5", sb.log().display()), + ); + let o = sb.run(&["update", "--yes"], &[]); + assert!(!o.status.success(), "{}", text(&o)); + assert!( + !sb.calls().iter().any(|c| c == "atlas skills install"), + "nothing was installed, nothing is refreshed: {:?}", + sb.calls() + ); +} + +// ── G11: a managed install reports `managed`, never a silent success ───────── + +#[test] +fn homebrew_reports_a_managed_action_in_json() { + let sb = Sandbox::new("none"); + let brew = sb.root.path().join("opt/homebrew/bin/atlas"); + script(&brew, "exit 0"); + let o = sb.run( + &["update", "--check", "--json"], + &[("ATLAS_UPDATE_EXE", brew.to_str().unwrap())], + ); + let v: serde_json::Value = + serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{e}: {}", text(&o))); + assert_eq!(v["action"], "managed", "{v}"); + assert_eq!(v["command"], "brew upgrade atlas"); +} + +#[test] +fn homebrew_update_emits_the_managed_event_instead_of_succeeding_silently() { + let sb = Sandbox::new("none"); + let brew = sb.root.path().join("opt/homebrew/bin/atlas"); + script(&brew, "exit 0"); + let o = sb.run( + &["update"], + &[ + ("ATLAS_UPDATE_EXE", brew.to_str().unwrap()), + ("ATLAS_UPDATE_PROGRESS", "1"), + ], + ); + let out = text(&o); + assert!(o.status.success(), "{out}"); + assert!(out.contains("@@atlas managed homebrew"), "{out}"); + assert!(out.contains("brew upgrade atlas"), "{out}"); +} + +// ── G3: dev builds and linked binaries are never replaced ──────────────────── + +#[test] +fn a_debug_build_is_refused_unless_forced() { + let sb = Sandbox::new("none"); + // No force: this debug-profile test binary is a dev build. + let managed = sb.root.path().join("home/.atlas/bin/atlas"); + script(&managed, "exit 0"); + let o = sb.run( + &["update"], + &[ + ("ATLAS_UPDATE_PROMPT", ""), + ("ATLAS_UPDATE_EXE", managed.to_str().unwrap()), + ], + ); + let out = text(&o); + if cfg!(debug_assertions) { + assert!(!o.status.success(), "{out}"); + assert!(out.contains("dev build"), "{out}"); + assert!(sb.calls().is_empty()); + } +} + +#[test] +fn a_symlinked_binary_is_refused_even_when_forced() { + let sb = Sandbox::new("none"); + let real = sb.root.path().join("checkout/target/debug/atlas"); + script(&real, "exit 0"); + let link = sb.home().join(".atlas/bin/atlas"); + std::fs::create_dir_all(link.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let o = sb.run(&["update"], &[("ATLAS_UPDATE_EXE", link.to_str().unwrap())]); + let out = text(&o); + assert!(!o.status.success(), "{out}"); + assert!(out.contains("dev build") || out.contains("linked"), "{out}"); +} + +// ── G7/G14: the cache survives a broken write and an offline start ─────────── + +#[test] +fn a_failed_check_is_stamped_so_offline_starts_do_not_retry() { + let sb = Sandbox::new("none"); + // curl that always fails: offline. + script(&sb.bin().join("curl"), "exit 7"); + let managed = sb.home().join(".atlas/bin/atlas"); + script(&managed, "exit 0"); + let env = [("ATLAS_UPDATE_EXE", managed.to_str().unwrap())]; + let o = sb.run(&["update", "--check"], &env); + assert!(!o.status.success(), "{}", text(&o)); + let cache: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(sb.home().join(".atlas/.update_check")).unwrap(), + ) + .unwrap(); + assert!( + cache["failed_at"] + .as_u64() + .is_some_and(|t| t > 1_000_000_000), + "failure stamped: {cache}" + ); +} diff --git a/crates/atlas-core/src/github.rs b/crates/atlas-core/src/github.rs index 7302e84d..3ee01aa4 100644 --- a/crates/atlas-core/src/github.rs +++ b/crates/atlas-core/src/github.rs @@ -12,6 +12,12 @@ //! - release notes are the `## [X.Y.Z] - date` sections of `CHANGELOG.md` at that tag //! (the release body is the same section, extracted by `release.yml`); //! - assets come from the public `releases/download` URLs. +//! +//! Failure policy: no lookup ever answers with a different release than the one asked for. +//! An unreadable feed is [`GithubError::Feed`]; a newest tag whose `manifest.json` could not +//! be checked (network error, 5xx, rate limit; only 404/410 mean "not published") is +//! [`GithubError::Unknown`]; callers keep what they knew. Metadata requests have a connect +//! timeout and `--retry`; downloads have a stall guard instead of a total time cap and resume. use std::path::Path; use std::process::{Command, Stdio}; @@ -31,6 +37,12 @@ pub enum GithubError { Missing, #[error("GitHub request failed: {0}")] Failed(String), + /// The Atom feed could not be read, so the beta/alpha offer is unknown (not "nothing newer"). + #[error("could not read the GitHub release feed: {0}")] + Feed(String), + /// Whether a tag has a published release could not be determined (network error, not a 404). + #[error("could not check whether {tag} is published: {reason}")] + Unknown { tag: String, reason: String }, } #[derive(Debug, Clone, PartialEq)] @@ -54,16 +66,55 @@ impl ReleaseInfo { } } -/// Base `curl` invocation: silent, fail on HTTP errors, bounded time. No console window: the -/// desktop's update check runs this from the GUI app on Windows. +/// Metadata `curl` against the real program. fn curl() -> Command { - let mut c = Command::new("curl"); + Net::system().curl() +} + +/// What every request shares: quiet (errors still print), connect timeout, retry policy. +/// `-f` is left to the caller: a status probe wants the HTTP code, not an exit code. `program` +/// is `curl` outside the tests, which point it at a stub. +fn base_curl(program: impl AsRef) -> Command { + let mut c = Command::new(program); crate::sys::CommandExt::no_window(&mut c) - .args(["-fsS", "--max-time", "120", "-H", "User-Agent: atlas-cli"]) + .args([ + "-sS", + "--connect-timeout", + "10", + "--retry", + "3", + "--retry-all-errors", + "--retry-delay", + "1", + "-H", + "User-Agent: atlas-cli", + ]) .stdin(Stdio::null()); c } +/// Seconds a download may stay below [`STALL_BYTES_PER_SEC`] before curl gives up. +const STALL_SECS: &str = "30"; +/// A transfer slower than this for [`STALL_SECS`] is stalled (slow but moving is fine). +const STALL_BYTES_PER_SEC: &str = "1024"; + +/// `curl` for a large asset: no total time cap (the Desktop app is big and links are slow), a +/// stall guard instead, and resume (`-C -`) so a retry continues the partial file. +fn curl_download(program: impl AsRef) -> Command { + let mut c = base_curl(program); + c.arg("-f"); + c.args([ + "--speed-limit", + STALL_BYTES_PER_SEC, + "--speed-time", + STALL_SECS, + "-C", + "-", + "-L", + ]); + c +} + /// Run `cmd` and return its stdout; a spawn failure means curl is missing. fn run(mut cmd: Command) -> Result, GithubError> { let out = cmd.output().map_err(|_| GithubError::Missing)?; @@ -75,18 +126,46 @@ fn run(mut cmd: Command) -> Result, GithubError> { )) } -/// Tag the redirect `github.com//releases/latest` → `.../releases/tag/` points at. -/// Pre-GA that is the newest build of the default channel (an alpha), not necessarily a Stable: -/// only [`stable_tag`] turns it into one. -fn latest_tag(repo: &str) -> Result { - let mut c = curl(); - c.args(["-o", NULL_DEVICE, "-w", "%{redirect_url}"]) - .arg(format!("https://github.com/{repo}/releases/latest")); - let url = String::from_utf8_lossy(&run(c)?).trim().to_string(); - url.rsplit_once("/releases/tag/") - .map(|(_, tag)| tag.to_string()) - .filter(|t| !t.is_empty()) - .ok_or_else(|| GithubError::Failed(format!("{repo} has no published release"))) +/// Where requests go: the `curl` program. [`Net::system`] is the real one; the tests aim it at +/// a stub so the network-facing logic runs without a network. +struct Net { + curl: std::ffi::OsString, +} + +impl Net { + fn system() -> Self { + Net { + curl: "curl".into(), + } + } + + /// Metadata request: fails on HTTP errors, bounded transfer time. + fn curl(&self) -> Command { + let mut c = self.probe(); + c.arg("-f"); + c + } + + /// Like [`Net::curl`] but HTTP errors are not failures: the caller reads `%{http_code}`. + fn probe(&self) -> Command { + let mut c = base_curl(&self.curl); + c.args(["--max-time", "30"]); + c + } + + /// Tag the redirect `github.com//releases/latest` → `.../releases/tag/` points + /// at. Pre-GA that is the newest build of the default channel (an alpha), not necessarily + /// a Stable: only [`Net::stable_tag`] turns it into one. + fn latest_tag(&self, repo: &str) -> Result { + let mut c = self.curl(); + c.args(["-o", NULL_DEVICE, "-w", "%{redirect_url}"]) + .arg(format!("https://github.com/{repo}/releases/latest")); + let url = String::from_utf8_lossy(&run(c)?).trim().to_string(); + url.rsplit_once("/releases/tag/") + .map(|(_, tag)| tag.to_string()) + .filter(|t| !t.is_empty()) + .ok_or_else(|| GithubError::Failed(format!("{repo} has no published release"))) + } } /// `CHANGELOG.md` of `repo` at `tag`. @@ -175,18 +254,26 @@ fn percent_decode(s: &str) -> String { String::from_utf8_lossy(&out).into_owned() } -fn atom(repo: &str) -> Result { - atom_page(repo, None) -} +impl Net { + fn atom(&self, repo: &str) -> Result { + self.atom_page(repo, None) + } -/// A page of the feed: the 10 newest releases, or the 10 that follow `after` (`?after=`). -fn atom_page(repo: &str, after: Option<&str>) -> Result { - let mut c = curl(); - c.arg(match after { - Some(tag) => format!("https://github.com/{repo}/releases.atom?after={tag}"), - None => format!("https://github.com/{repo}/releases.atom"), - }); - Ok(String::from_utf8_lossy(&run(c)?).into_owned()) + /// A page of the feed: the 10 newest releases, or the 10 that follow `after` + /// (`?after=`). Any failure is [`GithubError::Feed`]: an unreadable feed is not an + /// empty one. + fn atom_page(&self, repo: &str, after: Option<&str>) -> Result { + let mut c = self.curl(); + c.arg(match after { + Some(tag) => format!("https://github.com/{repo}/releases.atom?after={tag}"), + None => format!("https://github.com/{repo}/releases.atom"), + }); + match run(c) { + Ok(body) => Ok(String::from_utf8_lossy(&body).into_owned()), + Err(GithubError::Failed(e)) => Err(GithubError::Feed(e)), + Err(e) => Err(e), + } + } } /// Feed pages read at most while looking for a Stable behind a run of prereleases (10 releases each). @@ -202,73 +289,109 @@ fn stable_from<'a>( channel::resolve(Channel::Stable, tags, latest) } -/// The tag Stable follows: [`stable_from`] over the feed and the redirect, reading older feed -/// pages (`?after=` the oldest tag seen) while a run of prereleases hides every Stable. The -/// first page is `first` (already fetched by the caller), so the common case costs nothing. -fn stable_tag(repo: &str, first: &[String], latest: Option<&str>) -> Option { - let mut seen: Vec = first.to_vec(); - let mut page = first.len(); - for _ in 0..STABLE_PAGES { - let found = stable_from(seen.iter().map(String::as_str), latest); - // A feed page is 10 long; a shorter one is the end of the history. - if found.is_some() || page < 10 { - return found; - } - let oldest = seen.last()?.clone(); - let older = atom_tags(&atom_page(repo, Some(&oldest)).ok()?); - page = older.len(); - if older.is_empty() { - return None; +impl Net { + /// The tag Stable follows: [`stable_from`] over the feed and the redirect, reading older + /// feed pages (`?after=` the oldest tag seen) while a run of prereleases hides every + /// Stable. The first page is `first` (already fetched by the caller), so the common case + /// costs nothing. `Ok(None)` means there is no Stable; a feed page that cannot be read is + /// an error, because a Stable may be behind it. + fn stable_tag( + &self, + repo: &str, + first: &[String], + latest: Option<&str>, + ) -> Result, GithubError> { + let mut seen: Vec = first.to_vec(); + let mut page = first.len(); + for _ in 0..STABLE_PAGES { + let found = stable_from(seen.iter().map(String::as_str), latest); + // A feed page is 10 long; a shorter one is the end of the history. + if found.is_some() || page < 10 { + return Ok(found); + } + let Some(oldest) = seen.last().cloned() else { + return Ok(None); + }; + let older = atom_tags(&self.atom_page(repo, Some(&oldest))?); + page = older.len(); + if older.is_empty() { + return Ok(None); + } + seen.extend(older); } - seen.extend(older); + Ok(stable_from(seen.iter().map(String::as_str), latest)) } - stable_from(seen.iter().map(String::as_str), latest) -} -/// `HEAD` on the release's `manifest.json`: the tag is pushed before the release is built and -/// published, so a tag alone does not mean there is something to install. -fn is_published(repo: &str, tag: &str) -> bool { - content_length(&format!( - "https://github.com/{repo}/releases/download/{tag}/manifest.json" - )) - .is_some() -} + /// `HEAD` on the release's `manifest.json`: the tag is pushed before the release is built + /// and published, so a tag alone does not mean there is something to install. + /// + /// Only an explicit 404/410 means "not published". Any other answer or a network failure + /// is [`GithubError::Unknown`], so a blip never drops the newest tag. + fn is_published(&self, repo: &str, tag: &str) -> Result { + let unknown = |reason: String| GithubError::Unknown { + tag: tag.to_string(), + reason, + }; + // No `-f`: the status code is the answer, not an exit code. + let mut c = self.probe(); + c.args(["-L", "-I", "-o", NULL_DEVICE, "-w", "%{http_code}"]) + .arg(format!( + "https://github.com/{repo}/releases/download/{tag}/manifest.json" + )); + let out = run(c).map_err(|e| unknown(e.to_string()))?; + match String::from_utf8_lossy(&out).trim() { + "404" | "410" => Ok(false), + code if code.starts_with('2') => Ok(true), + code => Err(unknown(format!("HTTP {code}"))), + } + } -/// The newest tag in `candidates` that `channel` accepts and that is actually published (the tag -/// is pushed before the release exists, so walk down past unpublished ones); `stable` when none. -fn newest_published( - repo: &str, - channel: Channel, - mut candidates: Vec<&str>, - stable: Option<&str>, -) -> Option { - while let Some(tag) = channel::resolve(channel, candidates.iter().copied(), None) { - if Some(tag.as_str()) == stable || is_published(repo, &tag) { - return Some(tag); + /// The newest tag in `candidates` that `channel` accepts and that is actually published + /// (the tag is pushed before the release exists, so walk down past unpublished ones); + /// `stable` when none. A tag whose publication cannot be determined is an error rather + /// than skipped. + fn newest_published( + &self, + repo: &str, + channel: Channel, + mut candidates: Vec<&str>, + stable: Option<&str>, + ) -> Result, GithubError> { + while let Some(tag) = channel::resolve(channel, candidates.iter().copied(), None) { + if Some(tag.as_str()) == stable || self.is_published(repo, &tag)? { + return Ok(Some(tag)); + } + candidates.retain(|c| *c != tag); } - candidates.retain(|c| *c != tag); + Ok(stable.map(str::to_string)) + } + + /// The tag `channel` follows. Every channel reads the Atom feed and an unreadable feed is + /// an error ([`GithubError::Feed`]): answering "Stable" for beta/alpha would report a + /// downgrade as "nothing newer". Callers keep their previous answer on error. + fn channel_tag(&self, repo: &str, channel: Channel) -> Result { + let latest = self.latest_tag(repo); + let feed = atom_tags(&self.atom(repo)?); + let stable = self.stable_tag(repo, &feed, latest.as_deref().ok())?; + let found = if channel == Channel::Stable { + stable.clone() + } else { + let mut candidates: Vec<&str> = feed.iter().map(String::as_str).collect(); + candidates.extend(stable.as_deref()); + self.newest_published(repo, channel, candidates, stable.as_deref())? + }; + found.ok_or_else(|| match latest { + Err(e) => e, + Ok(_) => GithubError::Failed(format!("{repo} has no Stable release")), + }) } - stable.map(str::to_string) } -/// The tag `channel` follows, without fetching the changelog (what update checks need). Every -/// channel reads the Atom feed; if it cannot be read, the channel falls back to the Stable the -/// `releases/latest` redirect names (when it names one). +/// The tag `channel` follows, without fetching the changelog (what update checks need). An +/// unreadable Atom feed, or a newest tag whose publication cannot be determined, is an error: +/// the caller keeps what it knew instead of being told a different channel's release. pub fn channel_tag(repo: &str, channel: Channel) -> Result { - let latest = latest_tag(repo); - let feed = atom(repo).map(|x| atom_tags(&x)).unwrap_or_default(); - let stable = stable_tag(repo, &feed, latest.as_deref().ok()); - let found = if channel == Channel::Stable { - stable.clone() - } else { - let mut candidates: Vec<&str> = feed.iter().map(String::as_str).collect(); - candidates.extend(stable.as_deref()); - newest_published(repo, channel, candidates, stable.as_deref()) - }; - found.ok_or_else(|| match latest { - Err(e) => e, - Ok(_) => GithubError::Failed(format!("{repo} has no Stable release")), - }) + Net::system().channel_tag(repo, channel) } /// The section of `tag` in `sections` as a [`ReleaseInfo`]. @@ -340,22 +463,41 @@ pub fn channel_views( installed: &semver::Version, selected: Channel, ) -> Vec { - // One feed fetch and one Stable lookup serve all three channels. - let latest = latest_tag(repo).ok(); - let feed = atom(repo).map(|x| atom_tags(&x)).unwrap_or_default(); - let stable = stable_tag(repo, &feed, latest.as_deref()); - let tags = |c: Channel| -> Option { - if c == Channel::Stable { - return stable.clone(); - } - let mut cand: Vec<&str> = feed.iter().map(String::as_str).collect(); - cand.extend(stable.as_deref()); - newest_published(repo, c, cand, stable.as_deref()) - }; - Channel::ALL - .into_iter() - .map(|c| channel::view(c, selected, installed, tags(c))) - .collect() + Net::system().channel_views(repo, installed, selected) +} + +impl Net { + fn channel_views( + &self, + repo: &str, + installed: &semver::Version, + selected: Channel, + ) -> Vec { + // One feed fetch and one Stable lookup serve all three channels. A channel whose offer + // cannot be determined (feed or HEAD failure) is shown with no offer, never with a + // different channel's release. + let latest = self.latest_tag(repo).ok(); + let feed = self.atom(repo).map(|x| atom_tags(&x)); + let stable = match &feed { + Ok(f) => self.stable_tag(repo, f, latest.as_deref()).ok().flatten(), + Err(_) => None, + }; + let tags = |c: Channel| -> Option { + if c == Channel::Stable { + return stable.clone(); + } + let feed = feed.as_ref().ok()?; + let mut cand: Vec<&str> = feed.iter().map(String::as_str).collect(); + cand.extend(stable.as_deref()); + self.newest_published(repo, c, cand, stable.as_deref()) + .ok() + .flatten() + }; + Channel::ALL + .into_iter() + .map(|c| channel::view(c, selected, installed, tags(c))) + .collect() + } } /// Download the release asset `name` of `tag` into `dest`. @@ -365,7 +507,9 @@ pub fn download_asset(repo: &str, tag: &str, name: &str, dest: &Path) -> Result< /// Like [`download_asset`], reporting `(bytes_done, bytes_total)` (total 0 while unknown) /// as the file grows. curl writes straight to `dest`; its size is polled from this thread -/// until curl exits, so no output parsing and no extra copy are involved. +/// until curl exits, so no output parsing and no extra copy are involved. There is no total +/// time limit: curl aborts a transfer that stalls (see [`curl_download`]) and retries resume +/// the partial file. pub fn download_asset_progress( repo: &str, tag: &str, @@ -373,33 +517,67 @@ pub fn download_asset_progress( dest: &Path, on_progress: impl FnMut(u64, u64), ) -> Result<(), GithubError> { - let url = format!("https://github.com/{repo}/releases/download/{tag}/{name}"); - let total = content_length(&url).unwrap_or(0); - let mut c = curl(); - c.arg("-L").arg("-o").arg(dest).arg(&url); - watch_download( - c, - dest, - total, - std::time::Duration::from_millis(120), - on_progress, - ) + Net::system().download(repo, tag, name, dest, on_progress) +} + +impl Net { + fn download( + &self, + repo: &str, + tag: &str, + name: &str, + dest: &Path, + on_progress: impl FnMut(u64, u64), + ) -> Result<(), GithubError> { + let url = format!("https://github.com/{repo}/releases/download/{tag}/{name}"); + let total = self.content_length(&url).unwrap_or(0); + // `-C -` continues whatever is already in `dest`: a leftover from an earlier attempt + // must not be appended to, so start from an empty file. + let _ = std::fs::remove_file(dest); + let mut c = curl_download(&self.curl); + c.arg("-o").arg(dest).arg(&url); + watch_download( + c, + dest, + total, + Watch { + poll: std::time::Duration::from_millis(120), + stall: STALL_LIMIT, + }, + on_progress, + ) + } +} + +/// Longest the file may stay the same size before the transfer is declared stalled. curl's +/// own `--speed-time` ([`STALL_SECS`]) normally fires first; this is the backstop for a curl +/// that hangs without exiting, and makes the guarantee testable without a network. +const STALL_LIMIT: std::time::Duration = std::time::Duration::from_secs(60); + +/// Polling cadence and stall limit of [`watch_download`]. +#[derive(Clone, Copy)] +struct Watch { + poll: std::time::Duration, + stall: std::time::Duration, } /// Run `cmd` (which writes `dest`) to completion, calling `on_progress(size, total)` every /// `poll` while the file grows and once more at the end with the final size. A failed exit -/// is an error carrying the command's stderr. +/// is an error carrying the command's stderr. A file that does not grow for `stall` is a +/// stalled transfer: the command is killed and the call fails; a slow download that keeps +/// growing is never cut off, however long it takes. fn watch_download( mut cmd: Command, dest: &Path, total: u64, - poll: std::time::Duration, + watch: Watch, mut on_progress: impl FnMut(u64, u64), ) -> Result<(), GithubError> { cmd.stdout(Stdio::null()).stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(|_| GithubError::Missing)?; let size = || std::fs::metadata(dest).map_or(0, |m| m.len()); let mut last = u64::MAX; + let mut moved_at = std::time::Instant::now(); loop { if let Some(st) = child .try_wait() @@ -417,9 +595,17 @@ fn watch_download( let done = size(); if done != last { last = done; + moved_at = std::time::Instant::now(); on_progress(done, total); + } else if moved_at.elapsed() >= watch.stall { + let _ = child.kill(); + let _ = child.wait(); + return Err(GithubError::Failed(format!( + "download stalled at {done} bytes (no data for {}s)", + watch.stall.as_secs_f32() + ))); } - std::thread::sleep(poll); + std::thread::sleep(watch.poll); } let done = size(); on_progress(done, done.max(total)); @@ -428,25 +614,28 @@ fn watch_download( /// `Content-Length` of release asset `name` of `tag`; `None` when unknown or offline. pub fn asset_size(repo: &str, tag: &str, name: &str) -> Option { - content_length(&format!( + Net::system().content_length(&format!( "https://github.com/{repo}/releases/download/{tag}/{name}" )) } -/// `Content-Length` of the final response of `url` (follows redirects); `None` when unknown. -fn content_length(url: &str) -> Option { - let mut c = curl(); - c.args([ - "-L", - "-I", - "-o", - NULL_DEVICE, - "-w", - "%header{content-length}", - ]) - .arg(url); - let out = run(c).ok()?; - String::from_utf8_lossy(&out).trim().parse().ok() +impl Net { + /// `Content-Length` of the final response of `url` (follows redirects); `None` when + /// unknown. + fn content_length(&self, url: &str) -> Option { + let mut c = self.curl(); + c.args([ + "-L", + "-I", + "-o", + NULL_DEVICE, + "-w", + "%header{content-length}", + ]) + .arg(url); + let out = run(c).ok()?; + String::from_utf8_lossy(&out).trim().parse().ok() + } } #[cfg(test)] @@ -668,27 +857,397 @@ mod tests { c } - // The fake download is a POSIX sh script. + /// Fast polling with a stall limit that only the stall tests reach. + fn quick(ms: u64) -> Watch { + Watch { + poll: std::time::Duration::from_millis(ms), + stall: std::time::Duration::from_secs(30), + } + } + + /// A `curl` stand-in: a shell script in `dir` that answers by URL from files in `dir`, so + /// the network-facing logic runs for real without a network. `feed` is the Atom body (the + /// feed request fails with curl's exit 7 when the file is absent), `latest` the redirect + /// target, and `code-` the HTTP status of that tag's `manifest.json` (a missing file + /// is a dropped connection: curl exit 6). + #[cfg(unix)] + fn stub_curl(dir: &Path) -> Net { + use std::os::unix::fs::PermissionsExt; + let script = format!( + r#"#!/bin/sh +d='{d}' +for last; do :; done +case "$last" in + *releases.atom*) [ -f "$d/feed" ] || {{ echo 'curl: (7) Failed to connect' >&2; exit 7; }}; cat "$d/feed" ;; + */releases/latest) printf '%s' "$(cat "$d/latest")" ;; + */manifest.json) + tag=${{last%/manifest.json}}; tag=${{tag##*/}} + [ -f "$d/code-$tag" ] || {{ echo 'curl: (6) Could not resolve host' >&2; exit 6; }} + cat "$d/code-$tag" ;; + */releases/download/*) + # Asset: `payload` is the body. HEAD (-I) prints its length; GET writes `-o ` and, with + # `-C -`, only the bytes the file does not have yet (curl's resume contract). + head=0; out=; resume=0; prev= + for a; do + [ "$a" = -I ] && head=1 + [ "$prev" = -o ] && out=$a + [ "$prev" = -C ] && [ "$a" = - ] && resume=1 + prev=$a + done + if [ $head = 1 ]; then wc -c < "$d/payload" | tr -d ' '; exit 0; fi + [ -f "$d/fail-download" ] && {{ echo 'curl: (28) Operation too slow' >&2; exit 28; }} + have=0; [ $resume = 1 ] && [ -f "$out" ] && have=$(wc -c < "$out" | tr -d ' ') + if [ "$have" -gt 0 ]; then tail -c +$((have + 1)) "$d/payload" >> "$out"; else cp "$d/payload" "$out"; fi ;; + *) echo "stub curl: unexpected $last" >&2; exit 2 ;; +esac +"#, + d = dir.display() + ); + let path = dir.join("curl"); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + Net { + curl: path.into_os_string(), + } + } + + #[cfg(unix)] + fn atom_of(tags: &[&str]) -> String { + let entries: String = tags + .iter() + .map(|t| { + format!( + r#""# + ) + }) + .collect(); + format!("{entries}") + } + + #[cfg(unix)] + fn put(dir: &Path, name: &str, body: &str) { + std::fs::write(dir.join(name), body).unwrap(); + } + + // G4: an unreadable feed used to degrade beta/alpha to "the Stable the redirect names". #[cfg(unix)] #[test] - fn watch_reports_growth_then_the_final_size() { + fn an_unreadable_feed_is_an_error_for_every_channel_not_a_stable_fallback() { + let dir = tempfile::tempdir().unwrap(); + put( + dir.path(), + "latest", + "https://github.com/o/r/releases/tag/v1.4.0", + ); + let net = stub_curl(dir.path()); + for c in Channel::ALL { + let e = net.channel_tag("o/r", c).unwrap_err(); + assert!(matches!(e, GithubError::Feed(_)), "{c}: {e:?}"); + assert!(e.to_string().contains("Failed to connect"), "{c}: {e}"); + } + } + + #[cfg(unix)] + #[test] + fn a_readable_feed_still_resolves_every_channel() { + let dir = tempfile::tempdir().unwrap(); + put( + dir.path(), + "latest", + "https://github.com/o/r/releases/tag/v1.5.0-alpha.2", + ); + put( + dir.path(), + "feed", + &atom_of(&["v1.5.0-alpha.2", "v1.4.0", "v1.4.0-beta.1"]), + ); + put(dir.path(), "code-v1.5.0-alpha.2", "200"); + let net = stub_curl(dir.path()); + assert_eq!(net.channel_tag("o/r", Channel::Stable).unwrap(), "v1.4.0"); + assert_eq!( + net.channel_tag("o/r", Channel::Alpha).unwrap(), + "v1.5.0-alpha.2" + ); + assert_eq!(net.channel_tag("o/r", Channel::Beta).unwrap(), "v1.4.0"); + } + + // G5: a network failure on the HEAD used to look like "unpublished" and drop the newest tag. + #[cfg(unix)] + #[test] + fn a_head_network_failure_is_unknown_not_unpublished() { + let dir = tempfile::tempdir().unwrap(); + let net = stub_curl(dir.path()); + // No code-v1.5.0-alpha.2 file: the stub drops the connection. + let e = net.is_published("o/r", "v1.5.0-alpha.2").unwrap_err(); + assert!( + matches!(&e, GithubError::Unknown { tag, .. } if tag == "v1.5.0-alpha.2"), + "{e:?}" + ); + } + + #[cfg(unix)] + #[test] + fn only_an_explicit_404_means_unpublished() { + let dir = tempfile::tempdir().unwrap(); + let net = stub_curl(dir.path()); + for (code, want) in [ + ("200", Some(true)), + ("404", Some(false)), + ("410", Some(false)), + ] { + put(dir.path(), "code-v1.0.0", code); + assert_eq!(net.is_published("o/r", "v1.0.0").ok(), want, "HTTP {code}"); + } + // A server error or a rate limit says nothing about the release. + for code in ["500", "503", "403", "429"] { + put(dir.path(), "code-v1.0.0", code); + assert!( + matches!( + net.is_published("o/r", "v1.0.0"), + Err(GithubError::Unknown { .. }) + ), + "HTTP {code}" + ); + } + } + + #[cfg(unix)] + #[test] + fn the_walk_down_skips_a_404_tag_but_stops_on_an_unknown_one() { + let dir = tempfile::tempdir().unwrap(); + let net = stub_curl(dir.path()); + let cands = || vec!["v1.5.0-alpha.3", "v1.5.0-alpha.2", "v1.4.0"]; + // The newest tag is pushed but not built yet: walk down to the published one. + put(dir.path(), "code-v1.5.0-alpha.3", "404"); + put(dir.path(), "code-v1.5.0-alpha.2", "200"); + assert_eq!( + net.newest_published("o/r", Channel::Alpha, cands(), Some("v1.4.0")) + .unwrap() + .as_deref(), + Some("v1.5.0-alpha.2") + ); + // The newest tag cannot be checked (no answer): not "older build", an error. + std::fs::remove_file(dir.path().join("code-v1.5.0-alpha.3")).unwrap(); + let e = net + .newest_published("o/r", Channel::Alpha, cands(), Some("v1.4.0")) + .unwrap_err(); + assert!( + matches!(&e, GithubError::Unknown { tag, .. } if tag == "v1.5.0-alpha.3"), + "{e:?}" + ); + } + + #[cfg(unix)] + #[test] + fn channel_tag_fails_instead_of_dropping_the_newest_prerelease_on_a_head_error() { + let dir = tempfile::tempdir().unwrap(); + put( + dir.path(), + "latest", + "https://github.com/o/r/releases/tag/v1.4.0", + ); + put(dir.path(), "feed", &atom_of(&["v1.5.0-alpha.2", "v1.4.0"])); + let net = stub_curl(dir.path()); + let e = net.channel_tag("o/r", Channel::Alpha).unwrap_err(); + assert!(matches!(e, GithubError::Unknown { .. }), "{e:?}"); + // Stable needs no HEAD: it is unaffected. + assert_eq!(net.channel_tag("o/r", Channel::Stable).unwrap(), "v1.4.0"); + } + + #[cfg(unix)] + #[test] + fn channel_views_never_offer_another_channels_release_when_the_feed_is_down() { + let dir = tempfile::tempdir().unwrap(); + put( + dir.path(), + "latest", + "https://github.com/o/r/releases/tag/v1.4.0", + ); + let net = stub_curl(dir.path()); + let installed = semver::Version::parse("1.3.0").unwrap(); + let views = net.channel_views("o/r", &installed, Channel::Alpha); + assert_eq!(views.len(), Channel::ALL.len()); + for v in &views { + assert_eq!( + (v.tag.as_deref(), v.offered.as_deref()), + (None, None), + "{:?}", + v.channel + ); + } + } + + // G6: curl flags. Metadata retries and has a connect timeout; downloads have a stall guard + // and resume instead of a total time cap. + #[test] + fn metadata_requests_retry_and_bound_connect_and_total_time() { + let args = |c: &Command| -> Vec { + c.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect() + }; + let a = args(&Net::system().curl()); + for flag in [ + "--connect-timeout", + "--retry", + "--retry-all-errors", + "--max-time", + "-f", + ] { + assert!(a.iter().any(|x| x == flag), "{flag} in {a:?}"); + } + } + + #[test] + fn downloads_have_a_stall_guard_and_resume_but_no_total_time_cap() { + let c = curl_download("curl"); + let a: Vec = c + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + for flag in [ + "--speed-limit", + "--speed-time", + "-C", + "--retry", + "--connect-timeout", + ] { + assert!(a.iter().any(|x| x == flag), "{flag} in {a:?}"); + } + assert!(!a.iter().any(|x| x == "--max-time"), "{a:?}"); + } + + // The watcher's stall guard: a download that stops growing is an error, one that keeps + // growing slowly is fine. + #[cfg(unix)] + #[test] + fn a_stalled_download_is_an_error_and_the_process_is_stopped() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("a.bin"); + let script = format!("printf aaaa > {}; sleep 30", dest.display()); + let started = std::time::Instant::now(); + let e = watch_download( + sh(&script), + &dest, + 100, + Watch { + poll: std::time::Duration::from_millis(10), + stall: std::time::Duration::from_millis(400), + }, + |_, _| {}, + ) + .unwrap_err(); + assert!(e.to_string().contains("stalled at 4 bytes"), "{e}"); + assert!( + started.elapsed() < std::time::Duration::from_secs(10), + "the hung process must be killed, not waited for" + ); + } + + #[cfg(unix)] + #[test] + fn a_slow_download_that_keeps_moving_is_never_cut_off() { let dir = tempfile::tempdir().unwrap(); let dest = dir.path().join("a.bin"); + // 6 chunks 150 ms apart (~0.9 s total) against a 400 ms stall limit: slower than the + // limit overall, but never idle for as long as it. let script = format!( - "printf aaaa > {d}; sleep 0.4; printf bbbb >> {d}; sleep 0.4; printf cccc >> {d}", + "for i in 1 2 3 4 5 6; do printf x >> {d}; sleep 0.15; done", d = dest.display() ); - let mut seen = Vec::new(); + let mut last = (0, 0); watch_download( sh(&script), &dest, - 12, - std::time::Duration::from_millis(20), - |d, t| { - seen.push((d, t)); + 6, + Watch { + poll: std::time::Duration::from_millis(10), + stall: std::time::Duration::from_millis(400), }, + |d, t| last = (d, t), ) .unwrap(); + assert_eq!(last, (6, 6)); + } + + #[cfg(unix)] + #[test] + fn download_writes_the_asset_and_reports_the_known_total() { + let dir = tempfile::tempdir().unwrap(); + put(dir.path(), "payload", "0123456789"); + let net = stub_curl(dir.path()); + let dest = dir.path().join("asset.bin"); + let mut last = (0, 0); + net.download("o/r", "v1.0.0", "asset.bin", &dest, |d, t| last = (d, t)) + .unwrap(); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "0123456789"); + assert_eq!(last, (10, 10)); + } + + // `-C -` continues whatever is in the file, so a leftover from an earlier, different + // attempt must not be appended to: the sha256 check would fail on a corrupt mix. + #[cfg(unix)] + #[test] + fn download_starts_from_an_empty_file_even_with_resume_on() { + let dir = tempfile::tempdir().unwrap(); + put(dir.path(), "payload", "0123456789"); + let net = stub_curl(dir.path()); + let dest = dir.path().join("asset.bin"); + std::fs::write(&dest, "STALE-LEFTOVER").unwrap(); + net.download("o/r", "v1.0.0", "asset.bin", &dest, |_, _| {}) + .unwrap(); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "0123456789"); + } + + // curl's exit 28 is `--speed-time` firing (a stalled transfer): it surfaces as an error + // carrying curl's message, never as a silent success with a short file. + #[cfg(unix)] + #[test] + fn a_download_curl_aborts_as_too_slow_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + put(dir.path(), "payload", "0123456789"); + put(dir.path(), "fail-download", ""); + let net = stub_curl(dir.path()); + let dest = dir.path().join("asset.bin"); + let e = net + .download("o/r", "v1.0.0", "asset.bin", &dest, |_, _| {}) + .unwrap_err(); + assert!(e.to_string().contains("too slow"), "{e}"); + } + + // The curl contract the download relies on: with `-C -` a retry appends only the missing + // tail, so a partial file plus a resumed transfer is the whole asset. + #[cfg(unix)] + #[test] + fn a_resumed_transfer_appends_only_the_missing_bytes() { + let dir = tempfile::tempdir().unwrap(); + put(dir.path(), "payload", "0123456789"); + let net = stub_curl(dir.path()); + let dest = dir.path().join("asset.bin"); + std::fs::write(&dest, "0123").unwrap(); + let mut c = curl_download(&net.curl); + c.arg("-o") + .arg(&dest) + .arg("https://github.com/o/r/releases/download/v1.0.0/asset.bin"); + watch_download(c, &dest, 10, quick(10), |_, _| {}).unwrap(); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "0123456789"); + } + + // The fake download is a POSIX sh script. + #[cfg(unix)] + #[test] + fn watch_reports_growth_then_the_final_size() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("a.bin"); + let script = format!( + "printf aaaa > {d}; sleep 0.4; printf bbbb >> {d}; sleep 0.4; printf cccc >> {d}", + d = dest.display() + ); + let mut seen = Vec::new(); + watch_download(sh(&script), &dest, 12, quick(20), |d, t| { + seen.push((d, t)); + }) + .unwrap(); assert!( seen.windows(2).all(|w| w[0].0 <= w[1].0), "monotonic: {seen:?}" @@ -707,15 +1266,9 @@ mod tests { let dest = dir.path().join("a.bin"); let script = format!("printf hello > {}", dest.display()); let mut last = (0, 0); - watch_download( - sh(&script), - &dest, - 0, - std::time::Duration::from_millis(10), - |d, t| { - last = (d, t); - }, - ) + watch_download(sh(&script), &dest, 0, quick(10), |d, t| { + last = (d, t); + }) .unwrap(); assert_eq!(last, (5, 5)); } @@ -729,7 +1282,7 @@ mod tests { sh("echo 'curl: (22) 404' >&2; exit 22"), &dest, 0, - std::time::Duration::from_millis(10), + quick(10), |_, _| {}, ) .unwrap_err(); @@ -743,7 +1296,7 @@ mod tests { Command::new("definitely-not-a-program"), &dir.path().join("x"), 0, - std::time::Duration::from_millis(10), + quick(10), |_, _| {}, ) .unwrap_err(); diff --git a/crates/atlas-desktop/src/updates.rs b/crates/atlas-desktop/src/updates.rs index 2fffdc50..9595af18 100644 --- a/crates/atlas-desktop/src/updates.rs +++ b/crates/atlas-desktop/src/updates.rs @@ -53,13 +53,29 @@ impl Drop for InstallGuard { #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Progress { - Phase { id: String }, - Bytes { done: u64, total: u64 }, - Rollback { state: String }, - Host { state: String, live: u64 }, -} - -/// `@@atlas phase download` / `@@atlas bytes 10 100` / `@@atlas rollback restored`. + Phase { + id: String, + }, + Bytes { + done: u64, + total: u64, + }, + Rollback { + state: String, + }, + Host { + state: String, + live: u64, + }, + /// Nothing was installed: Homebrew or a system package manager owns this Atlas. + /// `how` is `homebrew` or `package`. + Managed { + how: String, + }, +} + +/// `@@atlas phase download` / `@@atlas bytes 10 100` / `@@atlas rollback restored` / +/// `@@atlas managed homebrew`. fn parse_progress(line: &str) -> Option { let mut it = line.strip_prefix("@@atlas ")?.split_whitespace(); match it.next()? { @@ -77,6 +93,9 @@ fn parse_progress(line: &str) -> Option { state: it.next()?.to_string(), live: it.next().and_then(|n| n.parse().ok()).unwrap_or(0), }), + "managed" => Some(Progress::Managed { + how: it.next()?.to_string(), + }), _ => None, } } @@ -645,12 +664,24 @@ mod tests { live: 2 }) ); + assert_eq!( + parse_progress("@@atlas managed package"), + Some(Progress::Managed { + how: "package".into() + }) + ); + // The wire shape the frontend reads: `{kind:"managed", how}`. + assert_eq!( + serde_json::to_value(parse_progress("@@atlas managed homebrew").unwrap()).unwrap(), + serde_json::json!({"kind": "managed", "how": "homebrew"}) + ); for bad in [ "◆ Downloading v1", "@@atlas bytes x 1", "@@atlas bytes 1", "@@atlas nope 1", "@@atlas phase", + "@@atlas managed", ] { assert_eq!(parse_progress(bad), None, "{bad}"); } diff --git a/docs/cli.md b/docs/cli.md index 005ef6ee..5252b4b7 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -232,6 +232,8 @@ atlas update channel [] [--json] Looks up the newest release of your [release channel](versioning.md#channels) (with `curl`, no login and no GitHub API calls: the public `releases.atom` feed for every channel: Stable is the newest tag without a prerelease label, never the `releases/latest` redirect alone, which is the newest alpha while Alpha is the default) and, if it is newer than the running version, downloads `atlas--.tar.gz` and its `.sha256` from the release, verifies the checksum, replaces the running binary in place (keeping a backup that is restored if the app cannot follow), and then refreshes skills and any OMP, Claude Code or Codex setup it finds, using the new binary (failures are non-fatal). An installed [Atlas Desktop](desktop.md) is brought to the same version (`atlas desktop --sync`) and the terminal host is reconciled. `--check` only reports. Homebrew installs are told to run `brew upgrade atlas`. Refuses while `atlas link` is active (it would overwrite the dev build); run `atlas unlink` first. +**Network failures.** Every request has a 10 s connect timeout and retries transient errors up to 3 times; a download has no total time limit (the desktop bundle is large) but is aborted when it stays under 1 KB/s for 30 s, and curl resumes it on retry. A failed lookup is an error, never a different answer: if the release feed cannot be read, or the newest beta/alpha build cannot be confirmed as published (only an explicit 404 means "not published yet"), `atlas update` reports the failure instead of offering Stable or an older build, and the cached check result is kept. + **Channels.** `stable`, `beta` and `alpha` (the default while Atlas is in alpha; Stable again once a real Stable exists) describe how far a release has been soaked, not how finished Atlas is; see [Versioning and release channels](versioning.md). The channel is one word in `~/.atlas/update-channel`, shared with Atlas Desktop; with no file (or an unreadable one) you follow the default. An install that never chose a channel therefore sees the newest alpha as an update (never a downgrade). `ATLAS_UPDATE_CHANNEL=` overrides it for one process (scripts, CI); an invalid value is ignored. - `--channel ` **saves** that channel, then updates from it. Without the flag the saved channel is used. An unknown name is an error that lists the valid ones (exit code 2). @@ -244,7 +246,17 @@ Looks up the newest release of your [release channel](versioning.md#channels) (w `action` is `update` (a newer build is offered), `downgrade` (only an older build is offered) or `none`; `offered` and `tag` are `null` when the channel has nothing published. `atlas update channel --json` prints `{ "selected", "installed", "channels": [...] }`, one entry per channel with the same copy the command prints plus `selected`, `tag`, `offered` and `action`. -**Downgrades are never automatic.** When the channel offers something *older* than the build you run (for example you run an alpha and switch to Stable), `atlas update` installs nothing: it says which version you run, which one the channel offers, that you get updates again once the channel passes your build, and how to go back. `atlas update --downgrade` installs that older release anyway, after a data-compatibility warning and a `[y/N]` confirmation (default No; `--yes` skips it, and outside a terminal it is required). The download goes through the same checksum verification, backup and rollback as any update. +**Downgrades are never automatic.** When the channel offers something *older* than the build you run (for example you run an alpha and switch to Stable), `atlas update` installs nothing: it says which version you run, which one the channel offers, that you get updates again once the channel passes your build, and how to go back. `atlas update --downgrade` installs that older release anyway, after a data-compatibility warning and a `[y/N]` confirmation (default No; `--yes` skips it, and outside a terminal it is required). The download goes through the same checksum verification, backup and rollback as any update. It is not available for Homebrew or system-package installs (their package manager owns the version). + +**Installs another tool owns.** A binary installed by Homebrew, or by a Linux system package, is never overwritten by `atlas update` itself. + +- *Homebrew:* `atlas update` prints `brew upgrade atlas` and exits 0 (the desktop app sees `@@atlas managed homebrew` and does not report "up to date"). +- *System package (Arch, Debian/Ubuntu, Fedora/openSUSE):* the owner is found with `pacman -Qo`, `dpkg -S` or `rpm -qf` (the `/usr/bin` path rule is the fallback). `atlas update --check` reports current and latest. `atlas update` downloads the matching asset and its `.sha256`, verifies it **before any privilege escalation**, prints the command, confirms (`--yes` skips; required without a terminal) and installs it via `sudo` (`pkexec` without a terminal), holding `~/.atlas/.update.lock`. Then it refreshes skills and agent setups with the installed binary. See [Linux packages](installation.md#linux-packages). If no package manager is found, it prints where to get the package and emits `@@atlas managed package`. +- `--check --json` adds `"managed": true`, `"manager"` (`pacman`, `apt`, `dnf`, `zypper` or `null`) and `"command"` (the exact line, only when an upgrade is offered), and `action` is `package` (Linux package) or `managed` (Homebrew) instead of `update` when an upgrade is offered; `none` and `downgrade` stay as they are. + +**What it refuses.** A debug build or a binary reached through an `atlas link` symlink is never replaced (the same rule `atlas dev` uses before it offers an update; `atlas unlink` goes back to the release build). Before downloading, it checks that the CLI's directory and the installed app can be written and names the artifact that cannot, so it never replaces the CLI and then rolls it back because the app is read-only. Skills and agent hooks are refreshed only after the app followed; when the app cannot, the CLI is rolled back and the previous binary refreshes them, so they are never newer than the CLI that stays. + +**Cache.** The daily-notice cache `~/.atlas/.update_check` is written atomically (a `.tmp` file and a rename); an unreadable file keeps its "not now" dismissal; and a failed check (offline) is stamped, so nothing retries for 30 minutes (an offline `atlas dev` start no longer waits for the network). The exit banner prints the package manager's command for package installs. ## `atlas desktop` diff --git a/docs/desktop.md b/docs/desktop.md index bfe972e0..d2b2ac0f 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -100,7 +100,9 @@ An idle host does not linger forever: with **no running terminal and no connecte ### How an update stays safe -`atlas update` (the app runs the same command) verifies the SHA-256 of every download (the CLI tarball and the app tarball) and refuses an asset without a checksum. It takes `~/.atlas/.update.lock` (`flock`, released by the kernel if the process dies), so a second update from another terminal or window stops with a clear message. The CLI is replaced in place after a backup of the previous one (`~/.atlas/bin/.atlas.prev`); if the app then cannot be brought to the same version the CLI is restored and the error says so, so the two never sit on different versions in silence. With `ATLAS_UPDATE_PROGRESS=1` the CLI prints `@@atlas phase|bytes|rollback|host …` lines next to its normal output; the app turns them into the step list and the progress bar, and an older CLI without them still advances the steps from its plain text. +`atlas update` (the app runs the same command) verifies the SHA-256 of every download (the CLI tarball and the app tarball) and refuses an asset without a checksum. It takes `~/.atlas/.update.lock` (`flock`, released by the kernel if the process dies), so a second update from another terminal or window stops with a clear message. The CLI is replaced in place after a backup of the previous one (`~/.atlas/bin/.atlas.prev`); if the app then cannot be brought to the same version the CLI is restored and the error says so, so the two never sit on different versions in silence. With `ATLAS_UPDATE_PROGRESS=1` the CLI prints `@@atlas phase|bytes|rollback|host|managed …` lines next to its normal output (`managed homebrew|package` means the CLI exited 0 but installed nothing because Homebrew or a system package manager owns this Atlas: the app shows how to update it instead of "Atlas is up to date"); the app turns them into the step list and the progress bar, and an older CLI without them still advances the steps from its plain text. + +The app is replaced the same way on every OS: the previous app stays until the new one is verified, and an update that fails or is killed never leaves you without Atlas Desktop. **macOS:** the new bundle is built next to `Atlas.app` (`.Atlas.app.new`), the old one moves to `.Atlas.app.old`, the new one takes its place and only then is the old one deleted. If the process dies between the two renames, the next `atlas`/`atlas desktop` run puts `.Atlas.app.old` back before doing anything else. **Linux:** the previous AppImage is kept as `~/.local/bin/.atlas-desktop.prev` until the new one is in place and its version recorded, and put back when either fails. **Windows:** the install folder (`%LOCALAPPDATA%\Atlas`) moves aside to `Atlas.prev` before the installer runs; if the installer fails, leaves no `atlas-desktop.exe`, or leaves a version other than the wanted one (the `DisplayVersion` it registers), the folder is restored. A running Atlas on Windows keeps the folder locked, so the update stops before touching it and asks you to close the app. Staging files and folders are removed on every path, including a full disk. When the app is a symlink to a dev build (`atlas link --desktop`) and the CLI is not linked, `atlas update` leaves the app alone and prints one line saying so. ## Architecture diff --git a/docs/installation.md b/docs/installation.md index 392b7178..a90c6787 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -93,7 +93,7 @@ Every release also ships Atlas as native packages for Linux x86_64. Each one ins Arch users can also build the package themselves: the release attaches the `PKGBUILD` it was built from (`makepkg -si` next to it); it repackages the release's `.deb`. -A package manager owns what it installed: `atlas update` does not touch `/usr/bin/atlas` and tells you to update with your package manager instead, and `atlas desktop` opens the packaged app instead of downloading an AppImage. Pick one install method per machine (packages, or `install.sh` + `atlas desktop`); with both, the `atlas` that comes first on your `PATH` wins. +A package manager owns what it installed, and `atlas update` works through it (it never overwrites `/usr/bin/atlas` itself). It asks `pacman -Qo`, `dpkg -S` or `rpm -qf` who owns the binary (a `/usr/bin` binary no database claims uses the first manager it finds), downloads the matching release asset (`codeatlas-atlas-bin-*.pkg.tar.zst`, `atlas_*_amd64.deb`, `atlas-*.x86_64.rpm`) and its `.sha256`, **verifies it before anything asks for privileges**, shows the exact command, asks `[y/N]` (`--yes` skips it, and outside a terminal it is required) and runs it as root through `sudo` (or `pkexec` when there is no terminal): `pacman -U`, `apt-get install`, `dnf install` or `zypper install`. It holds the update lock while it runs and afterwards refreshes skills and the OMP / Claude Code / Codex setups with the **installed** binary. `atlas update --check` always reports installed and latest. `atlas desktop` opens the packaged app instead of downloading an AppImage. Pick one install method per machine (packages, or `install.sh` + `atlas desktop`); with both, the `atlas` that comes first on your `PATH` wins. AppImages need FUSE 2 to start (`fuse2` on Arch, `libfuse2` on Debian/Ubuntu). Without it, run `./Atlas-*.AppImage --appimage-extract-and-run`. From ccebd178f7fc20a190e775706b6b8fe978a2e068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Tue, 27 Oct 2026 17:09:16 -0300 Subject: [PATCH 2/2] test(desktop): drop the wall-clock comparison from the PNG fast-level test (flaked in a parallel run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Matheus Vilaça de Jesus --- crates/atlas-desktop/src/shots/pixels.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/crates/atlas-desktop/src/shots/pixels.rs b/crates/atlas-desktop/src/shots/pixels.rs index a6a6d7cc..1f32781b 100644 --- a/crates/atlas-desktop/src/shots/pixels.rs +++ b/crates/atlas-desktop/src/shots/pixels.rs @@ -692,10 +692,10 @@ mod tests { assert!(encode_png_fast(&[0; 7], 2, 1).is_err()); } - /// The reason it exists: a screen-sized frame encodes faster than at the default level. Measured on this - /// machine's own encoder: the fast level must not be slower than the default one, and decodes to the same pixels. + /// The fast level is lossless: a screen-like frame decodes to exactly the pixels the default level keeps. (Its + /// speed is a benchmark, not a test: a wall-clock comparison inside a parallel test run flaked.) #[test] - fn the_fast_level_is_not_slower_than_the_default_on_a_screenlike_frame() { + fn the_fast_level_decodes_to_the_same_pixels_as_the_default_on_a_screenlike_frame() { let (w, h) = (1200u32, 800u32); let mut px = vec![255u8; (w * h * 4) as usize]; for y in 0..h { @@ -708,16 +708,8 @@ mod tests { } } } - let t = std::time::Instant::now(); let slow = encode_png(&px, w, h).unwrap(); - let slow_s = t.elapsed().as_secs_f64(); - let t = std::time::Instant::now(); let fast = encode_png_fast(&px, w, h).unwrap(); - let fast_s = t.elapsed().as_secs_f64(); - assert!( - fast_s <= slow_s * 1.5, - "fast {fast_s}s vs default {slow_s}s" - ); assert_eq!( decode_png(&fast).unwrap().data, decode_png(&slow).unwrap().data