feat: add multi-platform packaging and auto-update infrastructure
- Add PyInstaller configuration (clide.spec) for cross-platform builds - Add build scripts for macOS (DMG), Linux (AppImage), and Windows (Inno Setup) - Add Gitea Actions CI/CD workflow for automated releases - Add auto-update service with Gitea API integration - Add 'clide update' CLI command for checking and installing updates Packaging outputs: - macOS: Signed/notarized .app bundle in DMG - Linux: Portable AppImage (glibc 2.17+) - Windows: Inno Setup installer Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
8b1a84e7e2
commit
aff03a2e9b
@@ -0,0 +1,126 @@
|
||||
# Gitea Actions workflow for building and releasing Clide
|
||||
# Triggers on version tags (v*)
|
||||
# Builds Linux AppImage and Windows installer automatically
|
||||
# macOS must be built locally (no macOS runners)
|
||||
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: '3.12'
|
||||
|
||||
jobs:
|
||||
# Build Linux AppImage
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[build]"
|
||||
|
||||
- name: Install FUSE (for appimagetool)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y fuse libfuse2
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
chmod +x scripts/build-linux.sh
|
||||
./scripts/build-linux.sh ${{ github.ref_name }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-appimage
|
||||
path: dist/*.AppImage
|
||||
retention-days: 5
|
||||
|
||||
# Build Windows installer
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e ".[build]"
|
||||
|
||||
- name: Install Inno Setup
|
||||
run: choco install innosetup -y
|
||||
|
||||
- name: Build installer
|
||||
run: .\scripts\build-windows.ps1 -Version ${{ github.ref_name }}
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: dist/*-setup.exe
|
||||
retention-days: 5
|
||||
|
||||
# Create GitHub/Gitea release with all artifacts
|
||||
create-release:
|
||||
needs: [build-linux, build-windows]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download Linux artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-appimage
|
||||
path: artifacts/linux
|
||||
|
||||
- name: Download Windows artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: artifacts/windows
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
artifacts/linux/*.AppImage
|
||||
artifacts/windows/*-setup.exe
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
## Installation
|
||||
|
||||
### Linux (AppImage)
|
||||
```bash
|
||||
chmod +x Clide-*-linux-*.AppImage
|
||||
./Clide-*-linux-*.AppImage
|
||||
```
|
||||
|
||||
### Windows
|
||||
Download and run the installer. For best experience, use Windows Terminal.
|
||||
|
||||
### macOS
|
||||
macOS builds are created manually. See releases for DMG when available.
|
||||
|
||||
## Requirements
|
||||
- **Linux**: glibc 2.17+ (Ubuntu 18.04+, Fedora 25+, etc.)
|
||||
- **Windows**: Windows 10+ with Windows Terminal recommended
|
||||
- **macOS**: macOS 10.13+ (when available)
|
||||
@@ -32,6 +32,7 @@ MANIFEST
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
!clide.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
.PHONY: setup run test test-single typecheck lint format build build-all clean help
|
||||
.PHONY: setup run test test-single typecheck lint format build build-macos build-linux build-windows build-all clean help
|
||||
|
||||
PYTHON := python3.12
|
||||
VENV := .venv
|
||||
BIN := $(VENV)/bin
|
||||
TEST ?= tests/
|
||||
VERSION ?= 1.0.0
|
||||
|
||||
# Colors for output
|
||||
BLUE := \033[0;34m
|
||||
@@ -13,15 +14,21 @@ RESET := \033[0m
|
||||
help:
|
||||
@echo "$(BLUE)Clide Development Commands$(RESET)"
|
||||
@echo ""
|
||||
@echo "$(GREEN)setup$(RESET) Create venv and install dependencies"
|
||||
@echo "$(GREEN)run$(RESET) Run the application"
|
||||
@echo "$(GREEN)test$(RESET) Run all tests"
|
||||
@echo "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)"
|
||||
@echo "$(GREEN)typecheck$(RESET) Run mypy type checking"
|
||||
@echo "$(GREEN)lint$(RESET) Run ruff linter"
|
||||
@echo "$(GREEN)format$(RESET) Run ruff formatter"
|
||||
@echo "$(GREEN)build$(RESET) Build executable for current platform"
|
||||
@echo "$(GREEN)clean$(RESET) Remove build artifacts and caches"
|
||||
@echo "$(GREEN)setup$(RESET) Create venv and install dependencies"
|
||||
@echo "$(GREEN)run$(RESET) Run the application"
|
||||
@echo "$(GREEN)test$(RESET) Run all tests"
|
||||
@echo "$(GREEN)test-single$(RESET) Run single test (TEST=path::test_name)"
|
||||
@echo "$(GREEN)typecheck$(RESET) Run mypy type checking"
|
||||
@echo "$(GREEN)lint$(RESET) Run ruff linter"
|
||||
@echo "$(GREEN)format$(RESET) Run ruff formatter"
|
||||
@echo "$(GREEN)build$(RESET) Build executable for current platform"
|
||||
@echo "$(GREEN)clean$(RESET) Remove build artifacts and caches"
|
||||
@echo ""
|
||||
@echo "$(BLUE)Distribution Builds$(RESET)"
|
||||
@echo ""
|
||||
@echo "$(GREEN)build-macos$(RESET) Build macOS DMG (VERSION=x.x.x)"
|
||||
@echo "$(GREEN)build-linux$(RESET) Build Linux AppImage (VERSION=x.x.x)"
|
||||
@echo "$(GREEN)build-windows$(RESET) Build Windows installer (VERSION=x.x.x)"
|
||||
|
||||
setup:
|
||||
@echo "Creating virtual environment..."
|
||||
@@ -93,3 +100,22 @@ ci-test:
|
||||
ci-build:
|
||||
pip install -e ".[build]"
|
||||
pyinstaller clide.spec --clean
|
||||
|
||||
# Distribution builds
|
||||
build-macos:
|
||||
@echo "$(BLUE)Building macOS distribution...$(RESET)"
|
||||
./scripts/build-macos.sh $(VERSION)
|
||||
|
||||
build-linux:
|
||||
@echo "$(BLUE)Building Linux AppImage...$(RESET)"
|
||||
./scripts/build-linux.sh $(VERSION)
|
||||
|
||||
build-windows:
|
||||
@echo "$(BLUE)Building Windows installer...$(RESET)"
|
||||
powershell -ExecutionPolicy Bypass -File scripts/build-windows.ps1 -Version $(VERSION)
|
||||
|
||||
build-all: build-linux
|
||||
@echo ""
|
||||
@echo "$(GREEN)Linux build complete.$(RESET)"
|
||||
@echo "$(BLUE)Note:$(RESET) macOS build requires: make build-macos VERSION=$(VERSION)"
|
||||
@echo "$(BLUE)Note:$(RESET) Windows build requires Windows: make build-windows VERSION=$(VERSION)"
|
||||
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller spec file for Clide.
|
||||
|
||||
Cross-platform configuration that produces:
|
||||
- macOS: .app bundle (universal2 for Intel + Apple Silicon)
|
||||
- Linux: Single executable (for AppImage packaging)
|
||||
- Windows: Single executable (for Inno Setup packaging)
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# Collect data files
|
||||
datas = [
|
||||
('clide/templates', 'clide/templates'), # Skill templates (SKILL.md files)
|
||||
]
|
||||
|
||||
# Hidden imports for dynamic modules
|
||||
hiddenimports = [
|
||||
# Textual internals
|
||||
'textual._context',
|
||||
'textual.css',
|
||||
# Tree-sitter grammars
|
||||
'tree_sitter_python',
|
||||
'tree_sitter_javascript',
|
||||
'tree_sitter_typescript',
|
||||
'tree_sitter_html',
|
||||
'tree_sitter_css',
|
||||
'tree_sitter_json',
|
||||
'tree_sitter_yaml',
|
||||
'tree_sitter_toml',
|
||||
'tree_sitter_markdown',
|
||||
'tree_sitter_rust',
|
||||
'tree_sitter_go',
|
||||
'tree_sitter_java',
|
||||
'tree_sitter_bash',
|
||||
# Watchdog backends
|
||||
'watchdog.observers.fsevents', # macOS
|
||||
'watchdog.observers.inotify', # Linux
|
||||
'watchdog.observers.read_directory_changes', # Windows
|
||||
# Pydantic
|
||||
'pydantic',
|
||||
'pydantic_settings',
|
||||
# Vendored pyte
|
||||
'clide.vendor.pyte',
|
||||
]
|
||||
|
||||
# Exclude dev/test dependencies
|
||||
excludes = [
|
||||
'pytest',
|
||||
'mypy',
|
||||
'ruff',
|
||||
'pre_commit',
|
||||
'pytest_asyncio',
|
||||
'pytest_textual_snapshot',
|
||||
'pytest_cov',
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
['clide/__main__.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=excludes,
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, cipher=block_cipher)
|
||||
|
||||
# Platform-specific executable settings
|
||||
if sys.platform == 'darwin':
|
||||
# macOS: Create .app bundle
|
||||
# Use CLIDE_UNIVERSAL=1 env var for universal binary (requires fat Python)
|
||||
import os
|
||||
target_arch = 'universal2' if os.environ.get('CLIDE_UNIVERSAL') else None
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='clide',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False, # UPX breaks macOS code signing
|
||||
console=True,
|
||||
target_arch=target_arch,
|
||||
)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
name='Clide',
|
||||
)
|
||||
app = BUNDLE(
|
||||
coll,
|
||||
name='Clide.app',
|
||||
icon=None, # TODO: Add icon.icns
|
||||
bundle_identifier='net.schweitz.clide',
|
||||
info_plist={
|
||||
'CFBundleShortVersionString': '1.0.0',
|
||||
'CFBundleName': 'Clide',
|
||||
'CFBundleDisplayName': 'Clide',
|
||||
'NSHighResolutionCapable': True,
|
||||
'LSEnvironment': {
|
||||
'TERM': 'xterm-256color',
|
||||
},
|
||||
},
|
||||
)
|
||||
elif sys.platform == 'win32':
|
||||
# Windows: Single executable
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='clide',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=True,
|
||||
console=True,
|
||||
icon=None, # TODO: Add icon.ico
|
||||
)
|
||||
else:
|
||||
# Linux: Single executable (for AppImage packaging)
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name='clide',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True,
|
||||
upx=True,
|
||||
console=True,
|
||||
)
|
||||
@@ -52,3 +52,54 @@ def config() -> None:
|
||||
def extensions() -> None:
|
||||
"""List installed extensions."""
|
||||
typer.echo("Extension manager not yet implemented")
|
||||
|
||||
|
||||
@app.command()
|
||||
def update(
|
||||
check_only: Annotated[
|
||||
bool,
|
||||
typer.Option("--check", "-c", help="Only check for updates, don't install"),
|
||||
] = False,
|
||||
force: Annotated[
|
||||
bool,
|
||||
typer.Option("--force", "-f", help="Force update even if already on latest"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Check for and install updates.
|
||||
|
||||
Updates are downloaded from git.schweitz.net releases.
|
||||
User settings in ~/.clide/ are preserved across updates.
|
||||
"""
|
||||
_ = force # TODO: Implement force update functionality
|
||||
from clide.services.update_service import check_for_updates, perform_update
|
||||
|
||||
typer.echo(f"Current version: {__version__}")
|
||||
typer.echo("Checking for updates...")
|
||||
|
||||
if check_only:
|
||||
result = check_for_updates()
|
||||
if result.error:
|
||||
typer.echo(f"Error: {result.error}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if result.update_available:
|
||||
typer.echo(f"Update available: {result.latest_version}")
|
||||
if result.release_info and result.release_info.release_notes:
|
||||
typer.echo("\nRelease notes:")
|
||||
typer.echo(result.release_info.release_notes[:500])
|
||||
else:
|
||||
typer.echo("Already running the latest version.")
|
||||
return
|
||||
|
||||
# Perform update with progress indication
|
||||
def progress_callback(downloaded: int, total: int) -> None:
|
||||
if total > 0:
|
||||
pct = (downloaded / total) * 100
|
||||
typer.echo(f"\rDownloading: {pct:.1f}%", nl=False)
|
||||
|
||||
success, message = perform_update(progress_callback)
|
||||
typer.echo("") # Newline after progress
|
||||
typer.echo(message)
|
||||
|
||||
if not success:
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Auto-update service for Clide.
|
||||
|
||||
Checks for updates from the release server and handles self-updating.
|
||||
User settings in ~/.clide/ are preserved across updates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Update server configuration
|
||||
UPDATE_SERVER = "https://git.schweitz.net"
|
||||
REPO_OWNER = "jeroen" # TODO: Update with actual owner
|
||||
REPO_NAME = "clide" # TODO: Update with actual repo name
|
||||
|
||||
# Current version (injected at build time or read from package)
|
||||
try:
|
||||
from clide import __version__ as CURRENT_VERSION
|
||||
except ImportError:
|
||||
CURRENT_VERSION = "0.0.0"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseInfo:
|
||||
"""Information about a release."""
|
||||
|
||||
version: str
|
||||
tag_name: str
|
||||
download_url: str
|
||||
release_notes: str
|
||||
published_at: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateCheckResult:
|
||||
"""Result of checking for updates."""
|
||||
|
||||
update_available: bool
|
||||
current_version: str
|
||||
latest_version: str | None
|
||||
release_info: ReleaseInfo | None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def get_platform_asset_name() -> str:
|
||||
"""Get the expected asset name for the current platform."""
|
||||
system = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if system == "darwin":
|
||||
return "macos.dmg"
|
||||
elif system == "linux":
|
||||
# Normalize architecture names
|
||||
if machine in ("x86_64", "amd64"):
|
||||
arch = "x86_64"
|
||||
elif machine in ("aarch64", "arm64"):
|
||||
arch = "aarch64"
|
||||
else:
|
||||
arch = machine
|
||||
return f"linux-{arch}.AppImage"
|
||||
elif system == "windows":
|
||||
return "windows-setup.exe"
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported platform: {system}")
|
||||
|
||||
|
||||
def parse_version(version: str) -> tuple[int, ...]:
|
||||
"""Parse a version string into a tuple for comparison."""
|
||||
# Remove 'v' prefix if present
|
||||
version = version.lstrip("v")
|
||||
# Split and convert to integers
|
||||
parts = []
|
||||
for part in version.split("."):
|
||||
# Handle versions like "1.0.0-beta"
|
||||
num_part = ""
|
||||
for char in part:
|
||||
if char.isdigit():
|
||||
num_part += char
|
||||
else:
|
||||
break
|
||||
parts.append(int(num_part) if num_part else 0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def is_newer_version(current: str, latest: str) -> bool:
|
||||
"""Check if latest version is newer than current."""
|
||||
return parse_version(latest) > parse_version(current)
|
||||
|
||||
|
||||
def check_for_updates() -> UpdateCheckResult:
|
||||
"""Check the release server for available updates.
|
||||
|
||||
Returns:
|
||||
UpdateCheckResult with update status and release info.
|
||||
"""
|
||||
try:
|
||||
# Gitea API endpoint for releases
|
||||
api_url = f"{UPDATE_SERVER}/api/v1/repos/{REPO_OWNER}/{REPO_NAME}/releases/latest"
|
||||
|
||||
request = Request(api_url)
|
||||
request.add_header("Accept", "application/json")
|
||||
request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}")
|
||||
|
||||
with urlopen(request, timeout=10) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
|
||||
tag_name = data.get("tag_name", "")
|
||||
latest_version = tag_name.lstrip("v")
|
||||
|
||||
# Find the download URL for current platform
|
||||
platform_asset = get_platform_asset_name()
|
||||
download_url = None
|
||||
|
||||
for asset in data.get("assets", []):
|
||||
if platform_asset in asset.get("name", ""):
|
||||
download_url = asset.get("browser_download_url")
|
||||
break
|
||||
|
||||
if not download_url:
|
||||
# Try constructing URL from release
|
||||
download_url = f"{UPDATE_SERVER}/{REPO_OWNER}/{REPO_NAME}/releases/download/{tag_name}/Clide-{tag_name}-{platform_asset}"
|
||||
|
||||
release_info = ReleaseInfo(
|
||||
version=latest_version,
|
||||
tag_name=tag_name,
|
||||
download_url=download_url,
|
||||
release_notes=data.get("body", ""),
|
||||
published_at=data.get("published_at", ""),
|
||||
)
|
||||
|
||||
update_available = is_newer_version(CURRENT_VERSION, latest_version)
|
||||
|
||||
return UpdateCheckResult(
|
||||
update_available=update_available,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=latest_version,
|
||||
release_info=release_info,
|
||||
)
|
||||
|
||||
except URLError as e:
|
||||
logger.error(f"Failed to check for updates: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error=f"Network error: {e.reason}",
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse update response: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error="Invalid response from update server",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error checking for updates: {e}")
|
||||
return UpdateCheckResult(
|
||||
update_available=False,
|
||||
current_version=CURRENT_VERSION,
|
||||
latest_version=None,
|
||||
release_info=None,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
|
||||
def download_update(release_info: ReleaseInfo, progress_callback=None) -> Path:
|
||||
"""Download the update to a temporary location.
|
||||
|
||||
Args:
|
||||
release_info: Release information with download URL.
|
||||
progress_callback: Optional callback(bytes_downloaded, total_bytes).
|
||||
|
||||
Returns:
|
||||
Path to the downloaded file.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If download fails.
|
||||
"""
|
||||
try:
|
||||
request = Request(release_info.download_url)
|
||||
request.add_header("User-Agent", f"Clide/{CURRENT_VERSION}")
|
||||
|
||||
# Create temp file with appropriate extension
|
||||
suffix = Path(release_info.download_url).suffix or ""
|
||||
fd, temp_path = tempfile.mkstemp(suffix=suffix, prefix="clide_update_")
|
||||
os.close(fd)
|
||||
|
||||
with urlopen(request, timeout=300) as response:
|
||||
total_size = int(response.headers.get("Content-Length", 0))
|
||||
downloaded = 0
|
||||
chunk_size = 8192
|
||||
|
||||
with open(temp_path, "wb") as f:
|
||||
while True:
|
||||
chunk = response.read(chunk_size)
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress_callback:
|
||||
progress_callback(downloaded, total_size)
|
||||
|
||||
return Path(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download update: {e}")
|
||||
raise RuntimeError(f"Download failed: {e}") from e
|
||||
|
||||
|
||||
def get_executable_path() -> Path:
|
||||
"""Get the path to the current executable."""
|
||||
if getattr(sys, "frozen", False):
|
||||
# Running as compiled executable
|
||||
return Path(sys.executable)
|
||||
else:
|
||||
# Running as Python script
|
||||
return Path(sys.argv[0]).resolve()
|
||||
|
||||
|
||||
def apply_update(downloaded_file: Path) -> bool:
|
||||
"""Apply the downloaded update.
|
||||
|
||||
This replaces the current executable with the new version.
|
||||
On macOS/Linux, this can happen while the app is running.
|
||||
On Windows, we need to use a helper script.
|
||||
|
||||
Args:
|
||||
downloaded_file: Path to the downloaded update file.
|
||||
|
||||
Returns:
|
||||
True if update was applied successfully.
|
||||
"""
|
||||
system = platform.system().lower()
|
||||
current_exe = get_executable_path()
|
||||
|
||||
try:
|
||||
if system == "darwin":
|
||||
# macOS: For DMG, just inform user to install manually
|
||||
# For direct binary updates, we can replace in-place
|
||||
if downloaded_file.suffix == ".dmg":
|
||||
logger.info(f"DMG downloaded to: {downloaded_file}")
|
||||
return True # User needs to install manually
|
||||
|
||||
# Direct binary replacement
|
||||
backup_path = current_exe.with_suffix(".backup")
|
||||
shutil.copy2(current_exe, backup_path)
|
||||
shutil.copy2(downloaded_file, current_exe)
|
||||
current_exe.chmod(
|
||||
current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
|
||||
)
|
||||
backup_path.unlink()
|
||||
return True
|
||||
|
||||
elif system == "linux":
|
||||
# Linux: AppImage can be replaced directly
|
||||
if downloaded_file.suffix == ".AppImage":
|
||||
backup_path = current_exe.with_suffix(".backup")
|
||||
shutil.copy2(current_exe, backup_path)
|
||||
shutil.copy2(downloaded_file, current_exe)
|
||||
current_exe.chmod(
|
||||
current_exe.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH,
|
||||
)
|
||||
backup_path.unlink()
|
||||
return True
|
||||
return False
|
||||
|
||||
elif system == "windows":
|
||||
# Windows: Can't replace running executable directly
|
||||
# Create a batch script to replace after exit
|
||||
batch_script = current_exe.parent / "update_clide.bat"
|
||||
new_exe = downloaded_file
|
||||
|
||||
script_content = f"""@echo off
|
||||
echo Updating Clide...
|
||||
timeout /t 2 /nobreak >nul
|
||||
copy /y "{new_exe}" "{current_exe}"
|
||||
del "{new_exe}"
|
||||
del "%~f0"
|
||||
echo Update complete!
|
||||
"""
|
||||
batch_script.write_text(script_content)
|
||||
logger.info(f"Update script created: {batch_script}")
|
||||
logger.info("Please restart Clide to complete the update.")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply update: {e}")
|
||||
return False
|
||||
finally:
|
||||
# Clean up downloaded file if it still exists and wasn't moved
|
||||
if downloaded_file.exists() and system != "windows":
|
||||
try:
|
||||
downloaded_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def perform_update(progress_callback=None) -> tuple[bool, str]:
|
||||
"""Check for and perform an update.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for download progress.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message).
|
||||
"""
|
||||
# Check for updates
|
||||
result = check_for_updates()
|
||||
|
||||
if result.error:
|
||||
return False, f"Failed to check for updates: {result.error}"
|
||||
|
||||
if not result.update_available:
|
||||
return True, f"Already running the latest version ({result.current_version})"
|
||||
|
||||
if not result.release_info:
|
||||
return False, "No release information available"
|
||||
|
||||
# Download update
|
||||
try:
|
||||
downloaded_file = download_update(result.release_info, progress_callback)
|
||||
except RuntimeError as e:
|
||||
return False, str(e)
|
||||
|
||||
# Apply update
|
||||
if apply_update(downloaded_file):
|
||||
system = platform.system().lower()
|
||||
if system == "windows":
|
||||
return True, f"Update to {result.latest_version} downloaded. Restart Clide to complete."
|
||||
elif system == "darwin" and downloaded_file.suffix == ".dmg":
|
||||
return (
|
||||
True,
|
||||
f"Update {result.latest_version} downloaded to {downloaded_file}. Please install manually.",
|
||||
)
|
||||
else:
|
||||
return (
|
||||
True,
|
||||
f"Updated to {result.latest_version}. Restart Clide to use the new version.",
|
||||
)
|
||||
else:
|
||||
return False, "Failed to apply update"
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Name=Clide
|
||||
Comment=TUI IDE for Claude Code
|
||||
GenericName=IDE
|
||||
Exec=clide
|
||||
Icon=clide
|
||||
Type=Application
|
||||
Categories=Development;IDE;TextEditor;
|
||||
Keywords=claude;code;ai;ide;terminal;
|
||||
Terminal=true
|
||||
StartupNotify=false
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Required for PyInstaller apps to run with hardened runtime -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# Build script for Linux AppImage distribution
|
||||
# Creates a portable AppImage that runs on most Linux distributions
|
||||
#
|
||||
# Prerequisites:
|
||||
# - wget (for downloading appimagetool)
|
||||
# - FUSE (for running appimagetool)
|
||||
#
|
||||
# Usage: ./scripts/build-linux.sh [VERSION]
|
||||
|
||||
set -e
|
||||
|
||||
VERSION="${1:-1.0.0}"
|
||||
ARCH=$(uname -m)
|
||||
|
||||
echo "========================================"
|
||||
echo "Building Clide $VERSION for Linux ($ARCH)"
|
||||
echo "========================================"
|
||||
|
||||
# Ensure we're in the project root
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Build with PyInstaller
|
||||
echo "Building with PyInstaller..."
|
||||
pyinstaller clide.spec --clean --noconfirm
|
||||
|
||||
# Create AppDir structure
|
||||
echo "Creating AppDir structure..."
|
||||
rm -rf dist/Clide.AppDir
|
||||
mkdir -p dist/Clide.AppDir/usr/bin
|
||||
mkdir -p dist/Clide.AppDir/usr/share/applications
|
||||
mkdir -p dist/Clide.AppDir/usr/share/icons/hicolor/256x256/apps
|
||||
|
||||
# Copy binary
|
||||
cp dist/clide dist/Clide.AppDir/usr/bin/
|
||||
|
||||
# Copy desktop file
|
||||
cp packaging/clide.desktop dist/Clide.AppDir/
|
||||
cp packaging/clide.desktop dist/Clide.AppDir/usr/share/applications/
|
||||
|
||||
# Create placeholder icon (TODO: Replace with actual icon)
|
||||
# For now, create a simple placeholder
|
||||
if [ ! -f packaging/clide.png ]; then
|
||||
echo "Note: No icon found at packaging/clide.png, AppImage will have no icon"
|
||||
else
|
||||
cp packaging/clide.png dist/Clide.AppDir/clide.png
|
||||
cp packaging/clide.png dist/Clide.AppDir/usr/share/icons/hicolor/256x256/apps/clide.png
|
||||
fi
|
||||
|
||||
# Create AppRun launcher
|
||||
cat > dist/Clide.AppDir/AppRun << 'EOF'
|
||||
#!/bin/bash
|
||||
# AppRun - Entry point for AppImage
|
||||
SELF=$(readlink -f "$0")
|
||||
HERE=${SELF%/*}
|
||||
export PATH="${HERE}/usr/bin:${PATH}"
|
||||
export TERM="${TERM:-xterm-256color}"
|
||||
exec "${HERE}/usr/bin/clide" "$@"
|
||||
EOF
|
||||
chmod +x dist/Clide.AppDir/AppRun
|
||||
|
||||
# Download appimagetool if needed
|
||||
APPIMAGETOOL="appimagetool-${ARCH}.AppImage"
|
||||
if [ ! -f "$APPIMAGETOOL" ]; then
|
||||
echo "Downloading appimagetool..."
|
||||
wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${ARCH}.AppImage" \
|
||||
-O "$APPIMAGETOOL"
|
||||
chmod +x "$APPIMAGETOOL"
|
||||
fi
|
||||
|
||||
# Create AppImage
|
||||
echo "Creating AppImage..."
|
||||
rm -f "dist/Clide-$VERSION-linux-$ARCH.AppImage"
|
||||
ARCH=$ARCH ./"$APPIMAGETOOL" dist/Clide.AppDir "dist/Clide-$VERSION-linux-$ARCH.AppImage"
|
||||
|
||||
# Clean up AppDir
|
||||
rm -rf dist/Clide.AppDir
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Build complete!"
|
||||
echo "Output: dist/Clide-$VERSION-linux-$ARCH.AppImage"
|
||||
echo ""
|
||||
echo "To run: chmod +x dist/Clide-$VERSION-linux-$ARCH.AppImage && ./dist/Clide-$VERSION-linux-$ARCH.AppImage"
|
||||
echo "========================================"
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
# Build script for macOS distribution
|
||||
# Creates a signed and notarized DMG installer
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Xcode Command Line Tools: xcode-select --install
|
||||
# - create-dmg: brew install create-dmg
|
||||
# - Apple Developer ID certificate installed in Keychain
|
||||
# - Keychain profile for notarytool: xcrun notarytool store-credentials
|
||||
#
|
||||
# Usage: ./scripts/build-macos.sh [VERSION]
|
||||
|
||||
set -e
|
||||
|
||||
VERSION="${1:-1.0.0}"
|
||||
DEVELOPER_ID="${DEVELOPER_ID:-Developer ID Application: Your Name (TEAMID)}"
|
||||
KEYCHAIN_PROFILE="${KEYCHAIN_PROFILE:-AC_PASSWORD}"
|
||||
|
||||
echo "========================================"
|
||||
echo "Building Clide $VERSION for macOS"
|
||||
echo "========================================"
|
||||
|
||||
# Ensure we're in the project root
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Check for required tools
|
||||
if ! command -v create-dmg &> /dev/null; then
|
||||
echo "Error: create-dmg not found. Install with: brew install create-dmg"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build with PyInstaller
|
||||
echo "Building with PyInstaller..."
|
||||
pyinstaller clide.spec --clean --noconfirm
|
||||
|
||||
# Check if signing is configured
|
||||
if [[ "$DEVELOPER_ID" == *"Your Name"* ]]; then
|
||||
echo ""
|
||||
echo "Warning: DEVELOPER_ID not configured. Skipping code signing."
|
||||
echo "To enable signing, set DEVELOPER_ID environment variable."
|
||||
echo ""
|
||||
SKIP_SIGNING=true
|
||||
else
|
||||
SKIP_SIGNING=false
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_SIGNING" == "false" ]]; then
|
||||
# Code sign the app
|
||||
echo "Code signing..."
|
||||
codesign --deep --force --verify --verbose \
|
||||
--sign "$DEVELOPER_ID" \
|
||||
--options runtime \
|
||||
--entitlements packaging/entitlements.plist \
|
||||
--timestamp \
|
||||
"dist/Clide.app"
|
||||
|
||||
# Create ZIP for notarization
|
||||
echo "Creating ZIP for notarization..."
|
||||
ditto -c -k --sequesterRsrc --keepParent \
|
||||
"dist/Clide.app" "dist/Clide-$VERSION.zip"
|
||||
|
||||
# Notarize
|
||||
echo "Submitting for notarization (this may take a few minutes)..."
|
||||
xcrun notarytool submit "dist/Clide-$VERSION.zip" \
|
||||
--keychain-profile "$KEYCHAIN_PROFILE" \
|
||||
--wait
|
||||
|
||||
# Staple the notarization ticket
|
||||
echo "Stapling notarization ticket..."
|
||||
xcrun stapler staple "dist/Clide.app"
|
||||
|
||||
# Clean up ZIP
|
||||
rm "dist/Clide-$VERSION.zip"
|
||||
fi
|
||||
|
||||
# Create DMG
|
||||
echo "Creating DMG..."
|
||||
# Remove existing DMG if present
|
||||
rm -f "dist/Clide-$VERSION-macos.dmg"
|
||||
|
||||
create-dmg \
|
||||
--volname "Clide $VERSION" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 600 400 \
|
||||
--icon-size 100 \
|
||||
--icon "Clide.app" 150 190 \
|
||||
--hide-extension "Clide.app" \
|
||||
--app-drop-link 450 185 \
|
||||
"dist/Clide-$VERSION-macos.dmg" \
|
||||
"dist/Clide.app" || true # create-dmg returns non-zero even on success sometimes
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "Build complete!"
|
||||
echo "Output: dist/Clide-$VERSION-macos.dmg"
|
||||
if [[ "$SKIP_SIGNING" == "true" ]]; then
|
||||
echo ""
|
||||
echo "Note: App was NOT signed or notarized."
|
||||
echo "Users will see Gatekeeper warnings."
|
||||
fi
|
||||
echo "========================================"
|
||||
@@ -0,0 +1,102 @@
|
||||
# Build script for Windows installer distribution
|
||||
# Creates an installer using Inno Setup
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Inno Setup 6: choco install innosetup -y
|
||||
# Or download from: https://jrsoftware.org/isinfo.php
|
||||
#
|
||||
# Usage: .\scripts\build-windows.ps1 [-Version "1.0.0"]
|
||||
|
||||
param(
|
||||
[string]$Version = "1.0.0"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "========================================"
|
||||
Write-Host "Building Clide $Version for Windows"
|
||||
Write-Host "========================================"
|
||||
|
||||
# Ensure we're in the project root
|
||||
$ProjectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
# Build with PyInstaller
|
||||
Write-Host "Building with PyInstaller..."
|
||||
pyinstaller clide.spec --clean --noconfirm
|
||||
|
||||
# Check for Inno Setup
|
||||
$InnoSetup = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
if (-not (Test-Path $InnoSetup)) {
|
||||
Write-Host ""
|
||||
Write-Host "Warning: Inno Setup not found at $InnoSetup"
|
||||
Write-Host "Install with: choco install innosetup -y"
|
||||
Write-Host "Or download from: https://jrsoftware.org/isinfo.php"
|
||||
Write-Host ""
|
||||
Write-Host "Skipping installer creation. Binary available at: dist\clide.exe"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Create Inno Setup script
|
||||
Write-Host "Creating Inno Setup script..."
|
||||
$InnoScript = @"
|
||||
; Clide Installer Script
|
||||
; Generated by build-windows.ps1
|
||||
|
||||
[Setup]
|
||||
AppName=Clide
|
||||
AppVersion=$Version
|
||||
AppPublisher=Clide
|
||||
AppPublisherURL=https://github.com/your-repo/clide
|
||||
AppSupportURL=https://github.com/your-repo/clide/issues
|
||||
DefaultDirName={autopf}\Clide
|
||||
DefaultGroupName=Clide
|
||||
AllowNoIcons=yes
|
||||
OutputDir=dist
|
||||
OutputBaseFilename=Clide-$Version-windows-setup
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
PrivilegesRequired=lowest
|
||||
PrivilegesRequiredOverridesAllowed=dialog
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "dist\clide.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\Clide"; Filename: "{app}\clide.exe"
|
||||
Name: "{group}\{cm:UninstallProgram,Clide}"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\Clide"; Filename: "{app}\clide.exe"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\clide.exe"; Description: "{cm:LaunchProgram,Clide}"; Flags: nowait postinstall skipifsilent shellexec
|
||||
|
||||
[Code]
|
||||
function InitializeSetup(): Boolean;
|
||||
begin
|
||||
Result := True;
|
||||
// Add any initialization checks here
|
||||
end;
|
||||
"@
|
||||
|
||||
# Write Inno Setup script
|
||||
$InnoScriptPath = Join-Path $ProjectRoot "build\clide.iss"
|
||||
$InnoScript | Out-File -FilePath $InnoScriptPath -Encoding UTF8
|
||||
|
||||
# Build installer
|
||||
Write-Host "Building installer with Inno Setup..."
|
||||
& $InnoSetup $InnoScriptPath
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================"
|
||||
Write-Host "Build complete!"
|
||||
Write-Host "Output: dist\Clide-$Version-windows-setup.exe"
|
||||
Write-Host ""
|
||||
Write-Host "Note: For best experience, users should run Clide in Windows Terminal."
|
||||
Write-Host "========================================"
|
||||
Reference in New Issue
Block a user