mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
Auto Update (#358)
This commit is contained in:
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user