From 2815b10c1abb75b84cc7faa4948b8718bd46754b Mon Sep 17 00:00:00 2001 From: Fabio Wakim Trentini Date: Fri, 12 Jun 2026 20:25:14 -0300 Subject: [PATCH] feat(cli): silent opt-out self-update (#102) Keep the CLI up to date automatically, mirroring the agent-telemetry consent pattern: default-on once with a visible disclosure and an easy opt-out. Reuses the same install.sh the user already trusted at install time, so it adds no new trust surface. CLI: - iris/update/auto.py: auto_update_enabled/initialized flags, enable/ disable, IRIS_NO_AUTO_UPDATE env opt-out, version compare, managed- install guard (pipx / ~/.iris only), daily stamp + log, and a detached best-effort upgrade that never blocks the push that triggers it. - cli.py: first-run init (TTY + managed-install gated so the disclosure is never swallowed by the background hook), trigger after a push, and `iris upgrade --disable-auto|--enable-auto|--auto-status`. Platform: - lib/cli-version.ts: cached GitHub Releases lookup (RocketBus/iris) + version compare, fail-safe. - /api/ingest now returns latest_version + update_available. Tests: 15 Python + 6 vitest. Co-Authored-By: Claude Opus 4.8 (1M context) --- iris/cli.py | 88 +++++++++++- iris/update/__init__.py | 7 + iris/update/auto.py | 195 ++++++++++++++++++++++++++ platform/lib/cli-version.ts | 85 ++++++++++++ platform/src/app/api/ingest/route.ts | 12 +- platform/tests/cli-version.test.ts | 84 ++++++++++++ tests/test_auto_update.py | 198 +++++++++++++++++++++++++++ 7 files changed, 665 insertions(+), 4 deletions(-) create mode 100644 iris/update/__init__.py create mode 100644 iris/update/auto.py create mode 100644 platform/lib/cli-version.ts create mode 100644 platform/tests/cli-version.test.ts create mode 100644 tests/test_auto_update.py diff --git a/iris/cli.py b/iris/cli.py index 7c92a85..033b14f 100644 --- a/iris/cli.py +++ b/iris/cli.py @@ -1220,6 +1220,13 @@ def _push_after_analysis( maybe_flush_quietly(server, token, cli_version=VERSION) + # Same moment is the right one to self-update: the server told us the latest + # version in its response, and we're already off the commit's critical path. + # Detached, opt-out, managed-installs-only — see iris/update/auto.py. + from iris.update.auto import maybe_auto_update + + maybe_auto_update(result.get("latest_version"), server, VERSION) + def _run_hook(argv: list[str]) -> None: """Handle `iris hook install|uninstall|status` subcommands.""" @@ -1320,6 +1327,59 @@ def _maybe_init_agent_telemetry(raw_argv: list[str]) -> bool: return False +# Management/meta commands that shouldn't carry the auto-update disclosure or +# silently flip the default on. `upgrade` manages the preference itself. +_AUTO_UPDATE_INIT_SKIP = { + "agent", "hook", "login", "auth", "upgrade", "uninstall", + "--version", "-V", "-h", "--help", +} + + +def _print_auto_update_disclosure() -> None: + """One-time notice shown when silent auto-update auto-enables on first run.""" + print( + "\n" + "Iris will keep itself up to date automatically.\n" + " When a newer version is published, Iris quietly re-runs the same\n" + " installer you used (install.sh) in the background after a push —\n" + " it never blocks your commits and only touches its own install.\n" + " Turn it off anytime: iris upgrade --disable-auto\n", + file=sys.stderr, + ) + + +def _maybe_init_auto_update(raw_argv: list[str]) -> bool: + """First-run: default silent auto-update ON, once, with a disclosure the + user can actually see and an easy opt-out (`iris upgrade --disable-auto`). + + Only fires on an interactive TTY so the notice is never swallowed by the + background push hook (whose stderr goes to /dev/null), and only for installs + `install.sh` can manage — others can't self-update, so there's nothing to + disclose. Honors any prior choice and never raises into a normal run. + Returns True iff it auto-enabled on this call. + """ + if not raw_argv or raw_argv[0] in _AUTO_UPDATE_INIT_SKIP: + return False + try: + from iris.platform.config import load_config + from iris.update import auto + + if load_config().get(auto.CONFIG_INITIALIZED): + return False + # Don't enable where the user won't see the disclosure (background hook). + if not sys.stderr.isatty(): + return False + # Only default-on where a silent upgrade can actually run. + if not auto._managed_install(): + return False + + auto.enable() # sets enabled + initialized + _print_auto_update_disclosure() + return True + except Exception: + return False + + def _run_agent(argv: list[str]) -> None: """Handle `iris agent enable|disable|status|record` subcommands. @@ -1504,14 +1564,35 @@ def _run_uninstall() -> None: print("") -def _run_upgrade() -> None: +def _run_upgrade(argv: list[str] | None = None) -> None: """Upgrade Iris CLI by delegating to the same install.sh the user already used. install.sh is the single source of truth for resolving the latest version (GitHub Releases API), detecting pipx vs pip, and doing the correct uninstall-then-install dance on pipx — duplicating that logic - here drifts immediately.""" + here drifts immediately. + + The ``--disable-auto`` / ``--enable-auto`` / ``--auto-status`` flags manage + the silent auto-update preference instead of running an upgrade now.""" import subprocess + from iris.update import auto + + argv = argv or [] + if "--disable-auto" in argv: + auto.disable() + print("Silent auto-update disabled. Re-enable: iris upgrade --enable-auto") + return + if "--enable-auto" in argv: + auto.enable() + print("Silent auto-update enabled.") + return + if "--auto-status" in argv: + state = "on" if auto.is_enabled() else "off" + managed = "yes" if auto._managed_install() else "no (manual upgrades only)" + print(f"Silent auto-update: {state}") + print(f"Self-manageable install: {managed}") + return + # Resolve which deployment served the install. Priority: env override, # then ~/.iris/config.json (written by install.sh at install time), # then localhost as a last-resort default. @@ -1545,11 +1626,12 @@ def main(argv: list[str] | None = None) -> None: # Intercept subcommands before argparse (they use different arg structures) raw_argv = argv if argv is not None else sys.argv[1:] _maybe_init_agent_telemetry(raw_argv) + _maybe_init_auto_update(raw_argv) if raw_argv and raw_argv[0] in ("--version", "-V"): print(f"Iris {VERSION}") return if raw_argv and raw_argv[0] == "upgrade": - _run_upgrade() + _run_upgrade(raw_argv[1:]) return if raw_argv and raw_argv[0] == "uninstall": _run_uninstall() diff --git a/iris/update/__init__.py b/iris/update/__init__.py new file mode 100644 index 0000000..fb9491b --- /dev/null +++ b/iris/update/__init__.py @@ -0,0 +1,7 @@ +"""Silent CLI self-update (opt-out). + +Mirrors the agent-telemetry consent pattern: default-on once, with a clear +first-run disclosure and an easy opt-out. The actual upgrade reuses the same +``install.sh`` the user already trusted at install time — so this adds no new +trust surface, it just repeats that step automatically. +""" diff --git a/iris/update/auto.py b/iris/update/auto.py new file mode 100644 index 0000000..86d3062 --- /dev/null +++ b/iris/update/auto.py @@ -0,0 +1,195 @@ +"""Default-on, opt-out silent self-update for the Iris CLI. + +The update *action* already exists (``iris upgrade`` → ``curl /install.sh +| sh``). This module adds the missing half: knowing a newer version exists and +firing the upgrade quietly, without ever blocking a commit or push. + +Consent model mirrors ``iris/agent/settings_hook.py``: +- First run records a decision once (``auto_update_initialized``) and defaults + the flag ON, after printing a disclosure the user can actually see. +- Opt-out is persistent (``iris upgrade --disable-auto`` → flag False) or + transient (``IRIS_NO_AUTO_UPDATE=1``, for CI/runners). + +Safety guards specific to self-update (vs. read-only telemetry): +- Only fires from the background push path, fully detached, never interactive. +- Only auto-manages installs that ``install.sh`` owns (pipx / ``~/.iris/venv``); + system pip or Homebrew installs are skipped silently rather than clobbered. +- At most one attempt per day; failures are logged, never raised. +""" + +import os +import subprocess +import sys +from datetime import date, datetime + +from iris.platform.config import CONFIG_DIR, load_config, save_config + +CONFIG_FLAG = "auto_update_enabled" +# Set once a decision exists (auto first-run OR explicit enable/disable). While +# absent, first-run may auto-enable; once set, the stored choice is respected. +CONFIG_INITIALIZED = "auto_update_initialized" + +ENV_OPT_OUT = "IRIS_NO_AUTO_UPDATE" + +STAMP_FILE = os.path.join(CONFIG_DIR, ".last_auto_update") +LOG_FILE = os.path.join(CONFIG_DIR, "auto_update.log") + + +# --- preference toggles ----------------------------------------------------- + + +def enable() -> dict: + """Persist auto-update ON and mark the decision made.""" + config = load_config() + config[CONFIG_FLAG] = True + config[CONFIG_INITIALIZED] = True + save_config(config) + return {"enabled": True} + + +def disable() -> dict: + """Persist auto-update OFF and mark the decision made.""" + config = load_config() + config[CONFIG_FLAG] = False + config[CONFIG_INITIALIZED] = True + save_config(config) + return {"enabled": False} + + +def is_enabled() -> bool: + """Effective state: the transient env opt-out overrides the stored flag.""" + if os.environ.get(ENV_OPT_OUT): + return False + return bool(load_config().get(CONFIG_FLAG)) + + +# --- version comparison ----------------------------------------------------- + + +def _parse_version(v: str) -> tuple[int, int, int]: + """Parse a ``v1.4.4`` / ``1.4.4`` tag into a comparable ``(major, minor, + patch)`` tuple. Leading ``v`` and any pre-release suffix are ignored; a + component that can't be read falls back to 0.""" + parts: list[int] = [] + for chunk in v.strip().lstrip("vV").split(".")[:3]: + digits = "" + for ch in chunk: + if ch.isdigit(): + digits += ch + else: + break + parts.append(int(digits) if digits else 0) + while len(parts) < 3: + parts.append(0) + return parts[0], parts[1], parts[2] + + +def version_is_newer(latest: str | None, current: str) -> bool: + """True iff ``latest`` is strictly newer than ``current``.""" + if not latest: + return False + try: + return _parse_version(latest) > _parse_version(current) + except Exception: + return False + + +# --- install-type guard ----------------------------------------------------- + + +def _managed_install() -> bool: + """Whether the running install is one ``install.sh`` knows how to upgrade + (pipx venv or ``~/.iris/venv``). Anything else (system pip, Homebrew) is + left alone — piping the installer over it would create a conflicting copy. + """ + prefix = os.path.realpath(sys.prefix) + iris_dir = os.path.realpath(os.path.expanduser("~/.iris")) + if prefix == iris_dir or prefix.startswith(iris_dir + os.sep): + return True + # pipx venvs live at /venvs/iris (default ~/.local/pipx). + pipx_home = os.environ.get("PIPX_HOME") or os.path.expanduser("~/.local/pipx") + pipx_iris = os.path.realpath(os.path.join(pipx_home, "venvs", "iris")) + if prefix == pipx_iris or prefix.startswith(pipx_iris + os.sep): + return True + # Fallback heuristic for relocated pipx homes. + return (os.sep + "pipx" + os.sep) in prefix and prefix.rstrip(os.sep).endswith( + os.sep + "iris" + ) + + +# --- daily stamp ------------------------------------------------------------ + + +def _attempted_today() -> bool: + try: + with open(STAMP_FILE, encoding="utf-8") as f: + return f.read().strip() == date.today().isoformat() + except OSError: + return False + + +def _mark_attempt() -> None: + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(STAMP_FILE, "w", encoding="utf-8") as f: + f.write(date.today().isoformat() + "\n") + except OSError: + pass + + +def _log(message: str) -> None: + try: + os.makedirs(CONFIG_DIR, exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as f: + f.write(f"{datetime.now().isoformat(timespec='seconds')} {message}\n") + except OSError: + pass + + +# --- the silent update ------------------------------------------------------ + + +def maybe_auto_update( + latest_version: str | None, + server_url: str, + current_version: str, +) -> bool: + """Best-effort silent upgrade, piggybacking on the daily background push. + + Fires a detached ``curl /install.sh | sh`` and returns immediately; + the running process is never blocked and the spawned upgrade outlives it. + Returns True iff an upgrade was launched. Swallows every error — a failed + self-update must never disrupt the push that triggered it. + """ + try: + if not is_enabled(): + return False + if not version_is_newer(latest_version, current_version): + return False + if not _managed_install(): + _log( + f"skip: {latest_version} available but install at {sys.prefix} " + "is not pipx/~/.iris — upgrade manually with: iris upgrade" + ) + return False + if _attempted_today(): + return False + + # Stamp before launching: a broken release should retry tomorrow, not on + # every push for the rest of the day. + _mark_attempt() + + install_url = f"{server_url.rstrip('/')}/install.sh" + _log(f"launching upgrade {current_version} -> {latest_version} via {install_url}") + + log_fh = open(LOG_FILE, "a", encoding="utf-8") # noqa: SIM115 — held by child + subprocess.Popen( + ["sh", "-c", f"curl -fsSL '{install_url}' | sh"], + stdin=subprocess.DEVNULL, + stdout=log_fh, + stderr=subprocess.STDOUT, + start_new_session=True, # detach: survives the parent push exiting + ) + return True + except Exception: + return False diff --git a/platform/lib/cli-version.ts b/platform/lib/cli-version.ts new file mode 100644 index 0000000..67f1a1e --- /dev/null +++ b/platform/lib/cli-version.ts @@ -0,0 +1,85 @@ +/** + * Resolve the latest published Iris CLI version from GitHub Releases. + * + * The CLI sends its own version on every ingest; we hand back the latest tag so + * an opted-in CLI can self-update silently. install.sh resolves "latest" the + * same way (RocketBus/iris releases) — this is the server-side mirror of that + * lookup, cached so a burst of pushes can't hammer the GitHub API. + */ + +const RELEASES_LATEST_URL = + "https://api.github.com/repos/RocketBus/iris/releases/latest"; + +// GitHub's unauthenticated rate limit is 60 req/h per IP; one fetch per hour is +// plenty since releases are cut rarely. Cached in module scope — Fluid Compute +// reuses function instances, so this survives across many invocations. +const CACHE_TTL_MS = 60 * 60 * 1000; + +let cached: { value: string | null; at: number } | null = null; + +function isFresh(entry: { at: number } | null, now: number): boolean { + return entry !== null && now - entry.at < CACHE_TTL_MS; +} + +/** + * Latest CLI tag (e.g. "v1.4.4"), or null if it can't be resolved. Never + * throws — a failed lookup must not break ingestion, just omits the hint. + */ +export async function getLatestCliVersion( + now = Date.now(), +): Promise { + if (isFresh(cached, now)) { + return cached!.value; + } + + try { + const res = await fetch(RELEASES_LATEST_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "iris-platform", + }, + // Short timeout: the ingest response shouldn't wait on GitHub. + signal: AbortSignal.timeout(3000), + }); + if (!res.ok) { + // Cache the miss briefly so we don't retry on every push. + cached = { value: cached?.value ?? null, at: now }; + return cached.value; + } + const data = (await res.json()) as { tag_name?: unknown }; + const tag = typeof data.tag_name === "string" ? data.tag_name : null; + cached = { value: tag, at: now }; + return tag; + } catch { + cached = { value: cached?.value ?? null, at: now }; + return cached.value; + } +} + +/** Parse "v1.4.4" / "1.4.4" into a comparable [major, minor, patch] tuple. */ +function parseVersion(v: string): [number, number, number] { + const parts = v + .trim() + .replace(/^v/i, "") + .split(".") + .slice(0, 3) + .map((chunk) => { + const m = chunk.match(/^\d+/); + return m ? parseInt(m[0], 10) : 0; + }); + while (parts.length < 3) parts.push(0); + return [parts[0], parts[1], parts[2]]; +} + +/** True iff `latest` is strictly newer than `current`. */ +export function isUpdateAvailable( + latest: string | null, + current: string | null, +): boolean { + if (!latest || !current) return false; + const [aM, aMi, aP] = parseVersion(latest); + const [bM, bMi, bP] = parseVersion(current); + if (aM !== bM) return aM > bM; + if (aMi !== bMi) return aMi > bMi; + return aP > bP; +} diff --git a/platform/src/app/api/ingest/route.ts b/platform/src/app/api/ingest/route.ts index b14e88b..d413e32 100644 --- a/platform/src/app/api/ingest/route.ts +++ b/platform/src/app/api/ingest/route.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { getLatestCliVersion, isUpdateAvailable } from "@/lib/cli-version"; import { supabaseAdmin } from "@/lib/supabase"; import { withSpan, recordError } from "@/lib/telemetry"; import { validateToken } from "@/lib/tokens"; @@ -224,8 +225,17 @@ export async function POST(request: Request) { ...(cli_version ? { "iris.cli_version": cli_version } : {}), }); + // Hand back the latest published CLI version so an opted-in CLI can + // self-update silently. Best-effort: a failed lookup just omits the hint. + const latest_version = await getLatestCliVersion(); + return Response.json( - { run_id: run.id, repository_id: repositoryId }, + { + run_id: run.id, + repository_id: repositoryId, + latest_version, + update_available: isUpdateAvailable(latest_version, cli_version), + }, { status: 201 }, ); }, diff --git a/platform/tests/cli-version.test.ts b/platform/tests/cli-version.test.ts new file mode 100644 index 0000000..a5d8e62 --- /dev/null +++ b/platform/tests/cli-version.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// The module caches in module scope, so each test imports a fresh copy. +async function freshModule() { + vi.resetModules(); + return import("@/lib/cli-version"); +} + +function okRelease(tag: string) { + return { ok: true, json: async () => ({ tag_name: tag }) }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("isUpdateAvailable", () => { + it("is true only when latest is strictly newer", async () => { + const { isUpdateAvailable } = await freshModule(); + expect(isUpdateAvailable("v1.4.5", "v1.4.4")).toBe(true); + expect(isUpdateAvailable("v2.0.0", "v1.9.9")).toBe(true); + expect(isUpdateAvailable("1.5.0", "v1.4.9")).toBe(true); + expect(isUpdateAvailable("v1.4.4", "v1.4.4")).toBe(false); + expect(isUpdateAvailable("v1.4.3", "v1.4.4")).toBe(false); + }); + + it("is false when either version is missing", async () => { + const { isUpdateAvailable } = await freshModule(); + expect(isUpdateAvailable(null, "v1.4.4")).toBe(false); + expect(isUpdateAvailable("v1.4.5", null)).toBe(false); + }); +}); + +describe("getLatestCliVersion", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns the tag_name from the releases API", async () => { + const fetchMock = vi.fn(async () => okRelease("v1.4.5")); + vi.stubGlobal("fetch", fetchMock); + const { getLatestCliVersion } = await freshModule(); + expect(await getLatestCliVersion(0)).toBe("v1.4.5"); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it("caches within the TTL and refetches after it", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(okRelease("v1.4.5")) + .mockResolvedValueOnce(okRelease("v1.5.0")); + vi.stubGlobal("fetch", fetchMock); + const { getLatestCliVersion } = await freshModule(); + + expect(await getLatestCliVersion(0)).toBe("v1.4.5"); + // Same hour window: served from cache, no second fetch. + expect(await getLatestCliVersion(60_000)).toBe("v1.4.5"); + expect(fetchMock).toHaveBeenCalledOnce(); + + // Past the 1h TTL: refetch. + expect(await getLatestCliVersion(2 * 60 * 60 * 1000)).toBe("v1.5.0"); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("returns null on a non-ok response without throwing", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, json: async () => ({}) })), + ); + const { getLatestCliVersion } = await freshModule(); + expect(await getLatestCliVersion(0)).toBeNull(); + }); + + it("returns null when fetch throws (network/timeout)", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("timeout"); + }), + ); + const { getLatestCliVersion } = await freshModule(); + expect(await getLatestCliVersion(0)).toBeNull(); + }); +}); diff --git a/tests/test_auto_update.py b/tests/test_auto_update.py new file mode 100644 index 0000000..9c4d163 --- /dev/null +++ b/tests/test_auto_update.py @@ -0,0 +1,198 @@ +"""Tests for silent, opt-out CLI self-update (issue #102). + +Mirrors the agent-telemetry consent tests: `_maybe_init_auto_update` +auto-enables once (interactive + self-manageable installs only), and +`maybe_auto_update` fires a detached upgrade only when enabled, newer, managed, +and not yet attempted today. All filesystem and process effects are redirected +into a tmp HOME / fakes. +""" + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import iris.cli as cli +import iris.platform.config as config +import iris.update.auto as auto + + +def _isolate(tmp_path, monkeypatch): + iris_dir = tmp_path / ".iris" + monkeypatch.setattr(config, "CONFIG_DIR", str(iris_dir)) + monkeypatch.setattr(config, "CONFIG_FILE", str(iris_dir / "config.json")) + # auto.py bound these at import time from config.CONFIG_DIR — repoint them. + monkeypatch.setattr(auto, "CONFIG_DIR", str(iris_dir)) + monkeypatch.setattr(auto, "STAMP_FILE", str(iris_dir / ".last_auto_update")) + monkeypatch.setattr(auto, "LOG_FILE", str(iris_dir / "auto_update.log")) + monkeypatch.delenv(auto.ENV_OPT_OUT, raising=False) + return iris_dir + + +def _cfg(tmp_path): + f = tmp_path / ".iris" / "config.json" + return json.loads(f.read_text()) if f.exists() else {} + + +# --- version comparison ----------------------------------------------------- + + +def test_version_is_newer(): + assert auto.version_is_newer("v1.4.5", "v1.4.4") is True + assert auto.version_is_newer("v2.0.0", "v1.9.9") is True + assert auto.version_is_newer("1.5.0", "v1.4.9") is True + assert auto.version_is_newer("v1.4.4", "v1.4.4") is False + assert auto.version_is_newer("v1.4.3", "v1.4.4") is False + assert auto.version_is_newer(None, "v1.4.4") is False + # Pre-release suffixes don't break parsing. + assert auto.version_is_newer("1.4.4-rc1", "1.4.3") is True + + +# --- opt-out toggles -------------------------------------------------------- + + +def test_enable_disable_persist(tmp_path, monkeypatch): + _isolate(tmp_path, monkeypatch) + auto.enable() + assert _cfg(tmp_path)["auto_update_enabled"] is True + assert _cfg(tmp_path)["auto_update_initialized"] is True + auto.disable() + assert _cfg(tmp_path)["auto_update_enabled"] is False + assert _cfg(tmp_path)["auto_update_initialized"] is True + + +def test_env_opt_out_overrides_flag(tmp_path, monkeypatch): + _isolate(tmp_path, monkeypatch) + auto.enable() + assert auto.is_enabled() is True + monkeypatch.setenv(auto.ENV_OPT_OUT, "1") + assert auto.is_enabled() is False + + +# --- first-run init --------------------------------------------------------- + + +def test_first_run_auto_enables_interactive_managed(tmp_path, monkeypatch, capsys): + _isolate(tmp_path, monkeypatch) + monkeypatch.setattr(auto, "_managed_install", lambda: True) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False) + + assert cli._maybe_init_auto_update(["analyze", "."]) is True + cfg = _cfg(tmp_path) + assert cfg["auto_update_enabled"] is True + assert cfg["auto_update_initialized"] is True + + err = capsys.readouterr().err + assert "keep itself up to date" in err + assert "iris upgrade --disable-auto" in err + + +def test_first_run_is_one_time(tmp_path, monkeypatch): + _isolate(tmp_path, monkeypatch) + monkeypatch.setattr(auto, "_managed_install", lambda: True) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False) + assert cli._maybe_init_auto_update(["push"]) is True + assert cli._maybe_init_auto_update(["push"]) is False # already decided + + +def test_first_run_skips_non_tty(tmp_path, monkeypatch): + _isolate(tmp_path, monkeypatch) + monkeypatch.setattr(auto, "_managed_install", lambda: True) + monkeypatch.setattr(sys.stderr, "isatty", lambda: False, raising=False) + # Background hook path: disclosure would be swallowed, so don't enable. + assert cli._maybe_init_auto_update(["analyze", "."]) is False + assert "auto_update_initialized" not in _cfg(tmp_path) + + +def test_first_run_skips_unmanaged_install(tmp_path, monkeypatch): + _isolate(tmp_path, monkeypatch) + monkeypatch.setattr(auto, "_managed_install", lambda: False) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False) + assert cli._maybe_init_auto_update(["analyze", "."]) is False + assert "auto_update_initialized" not in _cfg(tmp_path) + + +def test_first_run_skips_meta_commands(tmp_path, monkeypatch): + _isolate(tmp_path, monkeypatch) + monkeypatch.setattr(auto, "_managed_install", lambda: True) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False) + for argv in (["upgrade"], ["agent", "status"], ["--version"], ["login"], []): + assert cli._maybe_init_auto_update(argv) is False + assert "auto_update_initialized" not in _cfg(tmp_path) + + +def test_first_run_respects_prior_disable(tmp_path, monkeypatch): + _isolate(tmp_path, monkeypatch) + monkeypatch.setattr(auto, "_managed_install", lambda: True) + monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False) + auto.disable() # explicit opt-out before first auto-run + assert cli._maybe_init_auto_update(["analyze", "."]) is False + assert _cfg(tmp_path)["auto_update_enabled"] is False + + +# --- the silent update ------------------------------------------------------ + + +class _FakePopen: + calls: list = [] + + def __init__(self, args, **kwargs): + _FakePopen.calls.append((args, kwargs)) + + +def _arm(tmp_path, monkeypatch, *, managed=True): + _isolate(tmp_path, monkeypatch) + auto.enable() + monkeypatch.setattr(auto, "_managed_install", lambda: managed) + _FakePopen.calls = [] + monkeypatch.setattr(auto.subprocess, "Popen", _FakePopen) + + +def test_auto_update_launches_when_newer(tmp_path, monkeypatch): + _arm(tmp_path, monkeypatch) + assert auto.maybe_auto_update("v1.4.5", "https://app.example.com", "v1.4.4") is True + assert len(_FakePopen.calls) == 1 + args, kwargs = _FakePopen.calls[0] + assert "https://app.example.com/install.sh" in args[2] + assert kwargs.get("start_new_session") is True + # Stamp written so it won't relaunch on the next push today. + assert (tmp_path / ".iris" / ".last_auto_update").exists() + + +def test_auto_update_skips_when_disabled(tmp_path, monkeypatch): + _arm(tmp_path, monkeypatch) + auto.disable() + assert auto.maybe_auto_update("v1.4.5", "https://app.example.com", "v1.4.4") is False + assert _FakePopen.calls == [] + + +def test_auto_update_skips_when_not_newer(tmp_path, monkeypatch): + _arm(tmp_path, monkeypatch) + assert auto.maybe_auto_update("v1.4.4", "https://app.example.com", "v1.4.4") is False + assert auto.maybe_auto_update(None, "https://app.example.com", "v1.4.4") is False + assert _FakePopen.calls == [] + + +def test_auto_update_skips_unmanaged_install(tmp_path, monkeypatch): + _arm(tmp_path, monkeypatch, managed=False) + assert auto.maybe_auto_update("v1.4.5", "https://app.example.com", "v1.4.4") is False + assert _FakePopen.calls == [] + # The skip is logged so a manual upgrade path is discoverable. + log = (tmp_path / ".iris" / "auto_update.log").read_text() + assert "iris upgrade" in log + + +def test_auto_update_one_attempt_per_day(tmp_path, monkeypatch): + _arm(tmp_path, monkeypatch) + assert auto.maybe_auto_update("v1.4.5", "https://app.example.com", "v1.4.4") is True + # Second call same day is suppressed by the stamp. + assert auto.maybe_auto_update("v1.4.5", "https://app.example.com", "v1.4.4") is False + assert len(_FakePopen.calls) == 1 + + +def test_auto_update_env_opt_out(tmp_path, monkeypatch): + _arm(tmp_path, monkeypatch) + monkeypatch.setenv(auto.ENV_OPT_OUT, "1") + assert auto.maybe_auto_update("v1.4.5", "https://app.example.com", "v1.4.4") is False + assert _FakePopen.calls == []