Auto Update (#358)

This commit is contained in:
Tanvir Bhathal
2026-05-19 11:57:10 -07:00
committed by GitHub
parent af21bc18ea
commit a58d6b6c48
9 changed files with 560 additions and 65 deletions
+86
View File
@@ -0,0 +1,86 @@
name: Auto-tag on main push
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
actions: write
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Compute dev version
id: version
run: |
set -euo pipefail
# Base version is the next patch above whatever is in pyproject.toml.
# Any future manual `X.Y.Z` release will outrank every `X.Y.Z.devN`
# autotag — PEP 440 sorts dev releases strictly below the final.
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
if [[ -z "$BASE" ]]; then
echo "::error::Could not parse version from pyproject.toml"
exit 1
fi
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
NEXT_PATCH=$((PATCH + 1))
BUILD=$(git rev-list --count HEAD)
VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}.dev${BUILD}"
TAG="v${VERSION}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "Computed ${TAG} (base=${BASE})"
- name: Create and push tag
id: tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
if git rev-parse "$TAG" >/dev/null 2>&1; then
EXISTING_SHA=$(git rev-parse "$TAG")
HEAD_SHA=$(git rev-parse HEAD)
if [[ "$EXISTING_SHA" != "$HEAD_SHA" ]]; then
echo "::error::Tag $TAG already exists at $EXISTING_SHA but HEAD is $HEAD_SHA"
exit 1
fi
echo "Tag $TAG already exists at HEAD, skipping creation"
echo "created=false" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG"
git push origin "$TAG"
echo "Created and pushed $TAG"
echo "created=true" >> "$GITHUB_OUTPUT"
# Tag pushes made with the default GITHUB_TOKEN do NOT trigger other
# workflows (recursion prevention). workflow_dispatch is the documented
# exception, so we explicitly dispatch the downstream CD workflows here.
# See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication
- name: Dispatch downstream workflows
if: steps.tag.outputs.created == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
echo "Dispatching pypi-publish.yml @ ${TAG}"
gh workflow run pypi-publish.yml \
--ref "${TAG}" \
-f tag="${TAG}"
echo "Dispatching desktop.yml @ ${TAG}"
gh workflow run desktop.yml \
--ref "${TAG}" \
-f tag="${TAG}"
+30 -6
View File
@@ -2,11 +2,8 @@ name: Desktop Build & Release
on:
push:
branches: [main]
paths:
- 'frontend/**'
- '.github/workflows/desktop.yml'
tags:
- 'v*'
- 'desktop-v*'
pull_request:
branches: [main]
@@ -14,9 +11,14 @@ on:
- 'frontend/**'
- '.github/workflows/desktop.yml'
workflow_dispatch:
inputs:
tag:
description: 'Tag to build (e.g. v1.0.2.dev500). If set, autotag dispatches use this. github.ref still controls the checkout.'
required: false
type: string
concurrency:
group: desktop-${{ github.ref }}
group: desktop-${{ inputs.tag || github.ref }}
cancel-in-progress: true
permissions:
@@ -156,14 +158,36 @@ jobs:
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/desktop-v* ]]; then
# Explicit stable desktop release tag
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#desktop-v}"
echo "tag=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "name=Desktop ${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
echo "prerelease=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
# Auto-tagged rolling build from autotag.yml — use the same
# version as the CLI/PyPI release so all surfaces stay in sync.
VERSION="${{ github.ref_name }}"
VERSION="${VERSION#v}"
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
# workflow_dispatch fallback (manual UI dispatch without --ref).
# Derive a PEP 440 dev version aligned with autotag.yml so we
# don't burn the X.Y.Z release-version namespace.
BASE=$(grep -E '^version = "' pyproject.toml | head -1 | sed -E 's/^version = "([^"]+)"/\1/')
MAJOR=$(echo "$BASE" | cut -d. -f1)
MINOR=$(echo "$BASE" | cut -d. -f2)
PATCH=$(echo "$BASE" | cut -d. -f3 | sed -E 's/[^0-9].*$//')
NEXT_PATCH=$((PATCH + 1))
BUILD=$(git rev-list --count HEAD)
VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}.dev${BUILD}"
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Configure Apple signing
if: runner.os == 'macOS'
@@ -199,7 +223,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
TAURI_CONFIG: '{"bundle":{"externalBin":["binaries/ollama"]}}'
TAURI_CONFIG: '{"version":"${{ steps.release-info.outputs.version }}","bundle":{"externalBin":["binaries/ollama"]}}'
with:
projectPath: frontend
tauriScript: npx tauri
+67
View File
@@ -7,6 +7,11 @@ on:
tags:
- "v*"
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (e.g. v1.0.2.dev500). Overrides github.ref.'
required: false
type: string
permissions:
contents: read
@@ -17,11 +22,73 @@ jobs:
runs-on: ubuntu-latest
environment: pypi
steps:
- name: Resolve target ref
id: ref
env:
INPUT_TAG: ${{ inputs.tag }}
DEFAULT_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -n "$INPUT_TAG" ]]; then
echo "ref=${INPUT_TAG}" >> "$GITHUB_OUTPUT"
else
echo "ref=${DEFAULT_REF}" >> "$GITHUB_OUTPUT"
fi
- uses: actions/checkout@v6
with:
ref: ${{ steps.ref.outputs.ref }}
- name: Install uv
uses: astral-sh/setup-uv@v8.0.0
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build frontend and bundle into package
run: |
set -euo pipefail
cd frontend
npm ci
npm run build
# Fail loudly if Vite produced an empty/partial bundle — otherwise
# we'd silently ship a wheel with a broken web UI.
test -s dist/index.html || {
echo "::error::frontend/dist/index.html missing or empty after build"
exit 1
}
STATIC=../src/openjarvis/server/static
mkdir -p "$STATIC"
rm -rf "$STATIC"/*
cp -r dist/. "$STATIC/"
test -s "$STATIC/index.html" || {
echo "::error::static/index.html missing after copy"
exit 1
}
- name: Set version from tag
env:
REF: ${{ steps.ref.outputs.ref }}
run: |
set -euo pipefail
# Strip leading "v" if present (e.g. v1.0.2.dev500 -> 1.0.2.dev500)
VERSION="${REF#v}"
if [[ -z "$VERSION" ]]; then
echo "::error::Could not resolve version from ref '$REF'"
exit 1
fi
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" pyproject.toml
# Sanity check the substitution actually took
grep -q "^version = \"${VERSION}\"" pyproject.toml || {
echo "::error::sed failed to update pyproject.toml version"
exit 1
}
echo "Building version $VERSION"
- name: Build package
run: uv build
+1 -1
View File
@@ -1604,7 +1604,7 @@ pub fn run() {
MacosLauncher::LaunchAgent,
Some(vec!["--hidden"]),
))
// .plugin(tauri_plugin_updater::Builder::new().build()) // disabled for local dev
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
+2 -28
View File
@@ -14,6 +14,7 @@ import { Toaster } from './components/ui/sonner';
import { useAppStore } from './lib/store';
import { fetchModels, fetchServerInfo, fetchSavings, submitSavings, isTauri } from './lib/api';
import { OptInModal } from './components/OptInModal';
import { UpdateChecker } from './components/Desktop/UpdateChecker';
import { track, hashId } from './lib/analytics';
export default function App() {
@@ -169,34 +170,6 @@ export default function App() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [commandPaletteOpen, setCommandPaletteOpen, toggleSystemPanel]);
// Desktop auto-update check — disabled during local development.
// Re-enable for production releases by uncommenting below.
// const updateChecked = useRef(false);
// useEffect(() => {
// if (!isTauri() || updateChecked.current) return;
// updateChecked.current = true;
// (async () => {
// try {
// const { check } = await import('@tauri-apps/plugin-updater');
// const update = await check();
// if (update) {
// await update.downloadAndInstall();
// const { toast } = await import('sonner');
// toast.info('Update ready', {
// description: 'A new version has been downloaded. Restart to apply.',
// duration: Infinity,
// action: {
// label: 'Restart Now',
// onClick: async () => {
// const { relaunch } = await import('@tauri-apps/plugin-process');
// await relaunch();
// },
// },
// });
// }
// } catch {}
// })();
// }, []);
if (!setupDone) {
return <SetupScreen onReady={handleSetupReady} />;
@@ -204,6 +177,7 @@ export default function App() {
return (
<>
<UpdateChecker />
<Routes>
<Route element={<Layout />}>
<Route index element={<ChatPage />} />
@@ -3,6 +3,25 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
type UpdateState = 'idle' | 'available' | 'downloading' | 'ready' | 'error';
const CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
const DISABLED_KEY = 'oj-auto-update-disabled';
export function isAutoUpdateDisabled(): boolean {
try {
return localStorage.getItem(DISABLED_KEY) === '1';
} catch {
return false;
}
}
export function setAutoUpdateDisabled(disabled: boolean): void {
try {
if (disabled) {
localStorage.setItem(DISABLED_KEY, '1');
} else {
localStorage.removeItem(DISABLED_KEY);
}
} catch {}
}
export function UpdateChecker() {
const [state, setState] = useState<UpdateState>('idle');
@@ -13,6 +32,7 @@ export function UpdateChecker() {
const updateRef = useRef<any>(null);
const checkForUpdate = useCallback(async () => {
if (isAutoUpdateDisabled()) return;
try {
const { check } = await import('@tauri-apps/plugin-updater');
const update = await check();
@@ -28,10 +48,10 @@ export function UpdateChecker() {
}, []);
useEffect(() => {
// Check if we're in a Tauri environment
if (typeof window === 'undefined' || !(window as any).__TAURI_INTERNALS__) {
return;
}
if (isAutoUpdateDisabled()) return;
// Local dev escape hatch: skip the auto-update poll if explicitly
// disabled. Vite exposes any ``VITE_``-prefixed env var on
@@ -60,9 +80,7 @@ export function UpdateChecker() {
const contentLength = update.contentLength ?? 0;
await update.downloadAndInstall((event: any) => {
if (event.event === 'Started' && event.data?.contentLength) {
// Content length received
} else if (event.event === 'Progress') {
if (event.event === 'Progress') {
downloaded += event.data?.chunkLength ?? 0;
if (contentLength > 0) {
setProgress(Math.min(100, Math.round((downloaded / contentLength) * 100)));
@@ -85,13 +103,18 @@ export function UpdateChecker() {
const { relaunch } = await import('@tauri-apps/plugin-process');
await relaunch();
} catch {
// Fallback: inform user to restart manually
setErrorMsg('Please restart the application manually');
setState('error');
setTimeout(() => setState('idle'), 5000);
}
}, []);
const handleDisable = useCallback(() => {
setAutoUpdateDisabled(true);
setState('idle');
setDismissed(false);
}, []);
if (state === 'idle' || dismissed) return null;
return (
@@ -101,7 +124,8 @@ export function UpdateChecker() {
<span>Update available: <strong>v{version}</strong></span>
<div style={styles.actions}>
<button style={styles.primaryBtn} onClick={handleDownload}>Download</button>
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Dismiss</button>
<button style={styles.secondaryBtn} onClick={() => setDismissed(true)}>Later</button>
<button style={styles.muteBtn} onClick={handleDisable}>Disable auto-updates</button>
</div>
</div>
)}
@@ -150,6 +174,7 @@ const styles: Record<string, React.CSSProperties> = {
actions: {
display: 'flex',
gap: '8px',
alignItems: 'center',
},
primaryBtn: {
padding: '4px 14px',
@@ -180,6 +205,15 @@ const styles: Record<string, React.CSSProperties> = {
fontSize: '12px',
cursor: 'pointer',
},
muteBtn: {
padding: '0',
border: 'none',
backgroundColor: 'transparent',
color: '#585b70',
fontSize: '11px',
cursor: 'pointer',
textDecoration: 'underline',
},
progressBar: {
flex: 1,
maxWidth: '300px',
+57 -1
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import {
Palette,
Globe,
@@ -16,9 +16,11 @@ import {
Key,
Search,
Brain,
RefreshCw,
} from 'lucide-react';
import { useAppStore, type ThemeMode } from '../lib/store';
import { checkHealth, fetchSpeechHealth, getMemoryStats } from '../lib/api';
import { isAutoUpdateDisabled, setAutoUpdateDisabled } from '../components/Desktop/UpdateChecker';
function OllamaModelList() {
const [models, setModels] = useState<Array<{ name: string; size: number }>>([]);
@@ -122,6 +124,27 @@ export function SettingsPage() {
const [speechBackendAvailable, setSpeechBackendAvailable] = useState<boolean | null>(null);
const [saved, setSaved] = useState(false);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(() => !isAutoUpdateDisabled());
const [updateCheckState, setUpdateCheckState] = useState<'idle' | 'checking' | 'available' | 'latest'>('idle');
const handleAutoUpdateToggle = useCallback((enabled: boolean) => {
setAutoUpdateEnabled(enabled);
setAutoUpdateDisabled(!enabled);
}, []);
const handleCheckNow = useCallback(async () => {
if (!(window as any).__TAURI_INTERNALS__) return;
setUpdateCheckState('checking');
try {
const { check } = await import('@tauri-apps/plugin-updater');
const update = await check();
setUpdateCheckState(update ? 'available' : 'latest');
setTimeout(() => setUpdateCheckState('idle'), 4000);
} catch {
setUpdateCheckState('idle');
}
}, []);
const [memoryStats, setMemoryStats] = useState<{ entries: number; backend: string } | null>(null);
const [memoryEnabled, setMemoryEnabled] = useState(() => {
try { return localStorage.getItem('openjarvis-memory-enabled') !== 'false'; } catch { return true; }
@@ -551,6 +574,39 @@ export function SettingsPage() {
</SettingRow>
</Section>
{/* Updates */}
<Section title="Updates">
<SettingRow label="Auto-update" description="Check for new desktop builds automatically every 30 minutes">
<button
onClick={() => handleAutoUpdateToggle(!autoUpdateEnabled)}
className="relative inline-flex h-5 w-9 items-center rounded-full transition-colors"
style={{ background: autoUpdateEnabled ? 'var(--color-accent)' : 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)' }}
>
<span
className="inline-block h-3.5 w-3.5 rounded-full transition-transform"
style={{
background: 'white',
transform: autoUpdateEnabled ? 'translateX(18px)' : 'translateX(2px)',
}}
/>
</button>
</SettingRow>
<SettingRow label="Check for updates" description="Manually check for a new version right now">
<button
onClick={handleCheckNow}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{ background: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text)', cursor: 'pointer' }}
disabled={updateCheckState === 'checking'}
>
<RefreshCw size={12} className={updateCheckState === 'checking' ? 'animate-spin' : ''} />
{updateCheckState === 'checking' && 'Checking...'}
{updateCheckState === 'available' && 'Update available — see banner above'}
{updateCheckState === 'latest' && 'Already up to date'}
{updateCheckState === 'idle' && 'Check now'}
</button>
</SettingRow>
</Section>
{/* About */}
<Section title="About">
<div className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>
+92 -20
View File
@@ -1,4 +1,4 @@
"""Check for newer OpenJarvis releases on GitHub."""
"""Check for newer OpenJarvis releases on PyPI."""
from __future__ import annotations
@@ -13,7 +13,16 @@ logger = logging.getLogger(__name__)
_CACHE_PATH = Path("~/.openjarvis/version-check.json").expanduser()
_CACHE_TTL = 86400 # 24 hours
_GITHUB_API = "https://api.github.com/repos/open-jarvis/OpenJarvis/releases/latest"
_PYPI_API = "https://pypi.org/pypi/openjarvis/json"
def _config_path() -> Path:
"""Resolve the config path, honoring ``OPENJARVIS_CONFIG`` like core.config."""
override = os.environ.get("OPENJARVIS_CONFIG")
if override:
return Path(override).expanduser()
return Path("~/.openjarvis/config.toml").expanduser()
# Commands that surface the "new version available" nudge. We deliberately
# cast a wide net for interactive commands (anything a human runs at a
@@ -54,7 +63,37 @@ def _check_disabled() -> bool:
# ``OPENJARVIS_NO_UPDATE_CHECK=0`` if they want the nudge anyway.
if os.environ.get("CI", "").strip().lower() in ("1", "true", "yes", "on"):
return True
return False
return _config_disabled()
def _config_disabled() -> bool:
"""Return True if config.toml has ``[updates] auto_update = false``.
On a malformed config we conservatively return ``True`` — if the user
tried to express an opt-out and the file has a typo, we should not
silently flip back to auto-checking against their intent.
"""
path = _config_path()
if not path.exists():
return False
try:
import tomllib
except ImportError: # Python 3.10
try:
import tomli as tomllib # type: ignore[no-redef]
except ImportError:
logger.debug("tomli not available, skipping config opt-out check")
return False
try:
with open(path, "rb") as f:
config = tomllib.load(f)
except OSError as exc:
logger.debug("config read failed: %s", exc)
return False
except tomllib.TOMLDecodeError as exc:
logger.debug("config malformed at %s: %s — treating as opt-out", path, exc)
return True
return not config.get("updates", {}).get("auto_update", True)
def check_for_updates(command_name: str) -> None:
@@ -62,8 +101,7 @@ def check_for_updates(command_name: str) -> None:
Honors ``OPENJARVIS_NO_UPDATE_CHECK=1`` and ``CI=true`` — any
truthy value (``1``, ``true``, ``yes``, ``on``) disables both the
GitHub poll and the banner. See ``_check_disabled`` for the full
list.
PyPI poll and the banner. See ``_check_disabled`` for the full list.
"""
if command_name not in _CHECK_COMMANDS:
return
@@ -92,7 +130,7 @@ def _do_check() -> None:
cmd = detect_install().upgrade_command
sys.stderr.write(
f"\033[33mA new version of OpenJarvis is available "
f"(v{current} \u2192 v{latest})\n"
f"(v{current} v{latest})\n"
f"Update: {cmd}\n"
f"Or run: jarvis self-update\033[0m\n\n"
)
@@ -101,28 +139,25 @@ def _do_check() -> None:
def _get_latest_version(current: str) -> str | None:
"""Return latest version string from cache or GitHub API."""
"""Return the latest non-prerelease version string from cache or PyPI.
Returns ``None`` on network/parse failures rather than caching a stale
or empty result. Dev/pre-release versions (``.devN``, ``aN``, ``bN``,
``rcN``) are filtered out so users on a stable release are not nudged
to a rolling autotag build — they can still opt in via ``--pre``.
"""
try:
if _CACHE_PATH.exists():
data = json.loads(_CACHE_PATH.read_text())
last_check = data.get("last_check", 0)
if time.time() - last_check < _CACHE_TTL:
return data.get("latest_version")
cached = data.get("latest_version")
return cached or None
except Exception:
pass
try:
import urllib.request
req = urllib.request.Request(
_GITHUB_API,
headers={"Accept": "application/vnd.github.v3+json"},
)
with urllib.request.urlopen(req, timeout=3) as resp:
data = json.loads(resp.read())
tag = data.get("tag_name", "")
latest = tag.lstrip("v")
except Exception:
latest = _fetch_latest_stable()
if not latest:
return None
try:
@@ -140,3 +175,40 @@ def _get_latest_version(current: str) -> str | None:
pass
return latest
def _fetch_latest_stable() -> str | None:
"""Query PyPI and return the highest non-prerelease version, or ``None``."""
try:
import urllib.request
with urllib.request.urlopen(_PYPI_API, timeout=3) as resp:
data = json.loads(resp.read())
except Exception as exc:
logger.debug("PyPI poll failed: %s", exc)
return None
try:
from packaging.version import InvalidVersion, Version
except ImportError:
# Fall back to the raw info.version if packaging isn't installed.
return data.get("info", {}).get("version") or None
releases = data.get("releases", {})
stable: list[Version] = []
for raw in releases.keys():
try:
v = Version(raw)
except InvalidVersion:
continue
if v.is_prerelease or v.is_devrelease:
continue
stable.append(v)
if stable:
return str(max(stable))
# No stable releases yet — fall back to info.version (handles brand-new
# projects that have only published dev releases).
info_version = data.get("info", {}).get("version")
return info_version or None
+185 -3
View File
@@ -2,17 +2,58 @@
from __future__ import annotations
import io
import json
import time
from unittest.mock import patch
import pytest
from openjarvis.cli._version_check import _check_disabled, check_for_updates
from openjarvis.cli import _version_check
from openjarvis.cli._version_check import (
_check_disabled,
_config_disabled,
_fetch_latest_stable,
_get_latest_version,
check_for_updates,
)
@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
for v in ("OPENJARVIS_NO_UPDATE_CHECK", "CI"):
def _clean_env(monkeypatch, tmp_path):
for v in ("OPENJARVIS_NO_UPDATE_CHECK", "CI", "OPENJARVIS_CONFIG"):
monkeypatch.delenv(v, raising=False)
# Point config + cache at empty tmp paths so tests don't see the
# developer's real ~/.openjarvis state.
monkeypatch.setenv("OPENJARVIS_CONFIG", str(tmp_path / "no-config.toml"))
monkeypatch.setattr(_version_check, "_CACHE_PATH", tmp_path / "version-check.json")
def _pypi_response(
versions: dict[str, list] | None = None, info_version: str = ""
) -> io.BytesIO:
"""Build a minimal PyPI JSON payload."""
payload = {
"info": {"version": info_version},
"releases": versions if versions is not None else {},
}
return io.BytesIO(json.dumps(payload).encode())
class _FakeResponse:
"""Context-manager-able stand-in for urllib's response."""
def __init__(self, body: bytes) -> None:
self._body = body
def __enter__(self) -> "_FakeResponse":
return self
def __exit__(self, *exc) -> None:
pass
def read(self) -> bytes:
return self._body
class TestCheckDisabled:
@@ -38,6 +79,147 @@ class TestCheckDisabled:
assert _check_disabled() is False
class TestConfigDisabled:
def test_missing_file_not_disabled(self):
assert _config_disabled() is False
def test_auto_update_false_disables(self, monkeypatch, tmp_path):
cfg = tmp_path / "config.toml"
cfg.write_text("[updates]\nauto_update = false\n")
monkeypatch.setenv("OPENJARVIS_CONFIG", str(cfg))
assert _config_disabled() is True
def test_auto_update_true_does_not_disable(self, monkeypatch, tmp_path):
cfg = tmp_path / "config.toml"
cfg.write_text("[updates]\nauto_update = true\n")
monkeypatch.setenv("OPENJARVIS_CONFIG", str(cfg))
assert _config_disabled() is False
def test_updates_section_absent_does_not_disable(self, monkeypatch, tmp_path):
cfg = tmp_path / "config.toml"
cfg.write_text("[other]\nkey = 1\n")
monkeypatch.setenv("OPENJARVIS_CONFIG", str(cfg))
assert _config_disabled() is False
def test_malformed_toml_treated_as_optout(self, monkeypatch, tmp_path):
"""A typo in the user's config must not silently re-enable updates.
Conservative: if the user touched the file at all, assume they meant
to opt out and would rather see no nudge than the wrong behavior.
"""
cfg = tmp_path / "config.toml"
cfg.write_text("[updates\nauto_update = false\n") # missing ]
monkeypatch.setenv("OPENJARVIS_CONFIG", str(cfg))
assert _config_disabled() is True
def test_openjarvis_config_env_override(self, monkeypatch, tmp_path):
"""OPENJARVIS_CONFIG should redirect the lookup, matching core.config."""
cfg = tmp_path / "alt.toml"
cfg.write_text("[updates]\nauto_update = false\n")
monkeypatch.setenv("OPENJARVIS_CONFIG", str(cfg))
assert _check_disabled() is True
class TestFetchLatestStable:
def test_picks_highest_non_dev_release(self):
body = _pypi_response(
versions={
"1.0.0": [{}],
"1.0.1": [{}],
"1.0.2.dev500": [{}],
"1.0.2.dev499": [{}],
},
info_version="1.0.2.dev500", # PyPI's "latest upload" may be a dev
)
with patch(
"urllib.request.urlopen", return_value=_FakeResponse(body.getvalue())
):
assert _fetch_latest_stable() == "1.0.1"
def test_returns_info_version_when_no_stable(self):
body = _pypi_response(
versions={"1.0.0.dev1": [{}]},
info_version="1.0.0.dev1",
)
with patch(
"urllib.request.urlopen", return_value=_FakeResponse(body.getvalue())
):
# No stable release yet — fall back to info.version so we still
# report *something* rather than silently returning None.
assert _fetch_latest_stable() == "1.0.0.dev1"
def test_skips_invalid_version_strings(self):
body = _pypi_response(
versions={
"1.0.0": [{}],
"garbage-version": [{}],
"1.1.0": [{}],
},
info_version="1.1.0",
)
with patch(
"urllib.request.urlopen", return_value=_FakeResponse(body.getvalue())
):
assert _fetch_latest_stable() == "1.1.0"
def test_network_error_returns_none(self):
with patch("urllib.request.urlopen", side_effect=OSError("offline")):
assert _fetch_latest_stable() is None
def test_filters_prereleases(self):
body = _pypi_response(
versions={"1.0.0": [{}], "1.1.0rc1": [{}], "1.1.0b2": [{}]},
info_version="1.1.0rc1",
)
with patch(
"urllib.request.urlopen", return_value=_FakeResponse(body.getvalue())
):
assert _fetch_latest_stable() == "1.0.0"
class TestGetLatestVersion:
def test_fresh_cache_short_circuits_network(self, tmp_path):
cache = _version_check._CACHE_PATH
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(
json.dumps({"last_check": time.time(), "latest_version": "9.9.9"})
)
with patch("urllib.request.urlopen") as mock_open:
assert _get_latest_version("1.0.0") == "9.9.9"
mock_open.assert_not_called()
def test_stale_cache_refetches(self, tmp_path):
cache = _version_check._CACHE_PATH
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(
json.dumps({"last_check": time.time() - 999_999, "latest_version": "0.0.1"})
)
body = _pypi_response(versions={"1.2.3": [{}]}, info_version="1.2.3")
with patch(
"urllib.request.urlopen", return_value=_FakeResponse(body.getvalue())
):
assert _get_latest_version("1.0.0") == "1.2.3"
def test_empty_version_is_not_cached(self, tmp_path):
"""An empty PyPI ``info.version`` must not poison the cache for 24h."""
cache = _version_check._CACHE_PATH
body = _pypi_response(versions={}, info_version="")
with patch(
"urllib.request.urlopen", return_value=_FakeResponse(body.getvalue())
):
assert _get_latest_version("1.0.0") is None
assert not cache.exists(), "empty version must not be written to cache"
def test_cached_empty_string_returns_none(self, tmp_path):
"""A previously-cached empty string from older builds must not crash."""
cache = _version_check._CACHE_PATH
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps({"last_check": time.time(), "latest_version": ""}))
with patch("urllib.request.urlopen") as mock_open:
assert _get_latest_version("1.0.0") is None
mock_open.assert_not_called()
class TestCheckForUpdates:
@patch("openjarvis.cli._version_check._do_check")
def test_runs_for_ask_command(self, mock_do):