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:
Jeroen Schweitzer
2026-02-01 22:51:36 +01:00
co-authored by Claude Opus 4.5
parent 8b1a84e7e2
commit aff03a2e9b
11 changed files with 1032 additions and 10 deletions
+126
View File
@@ -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)
+1
View File
@@ -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
+36 -10
View File
@@ -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
View File
@@ -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,
)
+51
View File
@@ -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)
+359
View File
@@ -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"
+11
View File
@@ -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
+11
View File
@@ -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>
+85
View File
@@ -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 "========================================"
+101
View File
@@ -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 "========================================"
+102
View File
@@ -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 "========================================"