From a9a1615061b557052050dfae64b488797e8063b1 Mon Sep 17 00:00:00 2001 From: pc-style Date: Sun, 9 Nov 2025 13:24:54 +0100 Subject: [PATCH] Add unified cross-platform build pipeline and GitHub Actions CI for binaries --- .github/workflows/build-binaries.yml | 129 +++++++ .kilocodemodes | 14 + Makefile | 32 +- docs/BUILD.md | 205 +++++++++++ lifeline/cli.py | 29 ++ lifeline/paths.py | 205 +++++++++++ lifeline/web_server.py | 87 +++++ pyproject.toml | 3 +- scripts/build.py | 489 +++++++++++++++++++++++++++ scripts/build_linux.sh | 143 +------- scripts/build_macos.sh | 20 ++ scripts/build_windows.ps1 | 106 +----- 12 files changed, 1237 insertions(+), 225 deletions(-) create mode 100644 .github/workflows/build-binaries.yml create mode 100644 .kilocodemodes create mode 100644 docs/BUILD.md create mode 100644 lifeline/cli.py create mode 100644 lifeline/paths.py create mode 100644 lifeline/web_server.py create mode 100644 scripts/build.py create mode 100644 scripts/build_macos.sh diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml new file mode 100644 index 0000000..e76c742 --- /dev/null +++ b/.github/workflows/build-binaries.yml @@ -0,0 +1,129 @@ +name: Build Lifeline Binaries + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + build-linux: + name: Build Linux binaries + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build (Linux) + run: | + python3 -m pip install --upgrade pip + python3 -m scripts.build --component all --target linux + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 + with: + name: lifeline-linux + path: | + build/linux/ + build/stage/linux/** + + build-macos: + name: Build macOS binaries + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build (macOS) + run: | + python3 -m pip install --upgrade pip + python3 -m scripts.build --component all --target macos + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: lifeline-macos + path: | + build/LifeLine.app/** + build/stage/macos/** + + build-windows: + name: Build Windows binaries + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build (Windows) + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m scripts.build --component all --target windows + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: lifeline-windows + path: | + build/windows/** + build/stage/windows/** + + release: + name: Create GitHub Release + needs: + - build-linux + # macOS and Windows builds may require additional signing or installer steps; + # keep them separate to allow failing fast on Linux. + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + steps: + - name: Download Linux artifacts + uses: actions/download-artifact@v4 + with: + name: lifeline-linux + path: artifacts/linux + + - name: Download macOS artifacts + uses: actions/download-artifact@v4 + with: + name: lifeline-macos + path: artifacts/macos + continue-on-error: true + + - name: Download Windows artifacts + uses: actions/download-artifact@v4 + with: + name: lifeline-windows + path: artifacts/windows + continue-on-error: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/linux/** + artifacts/macos/** + artifacts/windows/** + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.kilocodemodes b/.kilocodemodes new file mode 100644 index 0000000..09c2e55 --- /dev/null +++ b/.kilocodemodes @@ -0,0 +1,14 @@ +customModes: + - slug: test-engineer + name: Test Engineer + roleDefinition: | + You are a QA engineer and testing specialist focused on writing comprehensive tests, debugging failures, and improving code coverage. + groups: + - read + - command + - - edit + - fileRegex: \.(test|spec)\.(js|ts|jsx|tsx)$ + description: Test files only + customInstructions: | + Prioritize test readability, comprehensive edge cases, and clear assertion messages. Always consider both happy path and error scenarios. + source: project diff --git a/Makefile b/Makefile index 312e94a..ca2799d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: help install run web example export test format lint type-check clean clean-all +.PHONY: help install run web example export test format lint type-check clean clean-all build-cli build-web build-frontend build-all build-macos build-windows build-linux + +PYTHON ?= python3 help: @echo "LifeLine - Available Commands" @@ -40,6 +42,34 @@ format: lint: uv run ruff check lifeline/ main.py examples/ +.PHONY: build-cli +build-cli: + $(PYTHON) -m scripts.build --component cli + +.PHONY: build-web +build-web: + $(PYTHON) -m scripts.build --component web + +.PHONY: build-frontend +build-frontend: + $(PYTHON) -m scripts.build --component frontend + +.PHONY: build-all +build-all: + $(PYTHON) -m scripts.build --component all + +.PHONY: build-macos +build-macos: + $(PYTHON) -m scripts.build --target macos --component all + +.PHONY: build-windows +build-windows: + $(PYTHON) -m scripts.build --target windows --component all + +.PHONY: build-linux +build-linux: + $(PYTHON) -m scripts.build --target linux --component all + type-check: uv run mypy lifeline/ diff --git a/docs/BUILD.md b/docs/BUILD.md new file mode 100644 index 0000000..e422ccb --- /dev/null +++ b/docs/BUILD.md @@ -0,0 +1,205 @@ +# LifeLine Build & Packaging Guide + +This document describes the cross-platform build pipeline implemented for LifeLine. + +## Overview + +Key components: + +- Python package: `lifeline` +- CLI entrypoint: `lifeline` - [`lifeline.cli:main`](lifeline/cli.py:1) +- Web backend entrypoint: `lifeline-web` - [`lifeline.web_server:main`](lifeline/web_server.py:1) +- Next.js frontend: [`web-ui/`](web-ui/README.md) +- Unified build orchestrator: [`scripts.build`](scripts/build.py:1) +- PyInstaller specs: + - CLI: [`scripts/pyinstaller-cli.spec`](scripts/pyinstaller-cli.spec:1) + - Web: [`scripts/pyinstaller-web.spec`](scripts/pyinstaller-web.spec:1) +- Thin wrappers: + - macOS: [`scripts/build_macos.sh`](scripts/build_macos.sh:1) + - Linux: [`scripts/build_linux.sh`](scripts/build_linux.sh:1) + - Windows: [`scripts/build_windows.ps1`](scripts/build_windows.ps1:1) + +All platform-specific scripts delegate to the Python orchestrator instead of duplicating logic. + +## Requirements + +- Python 3.10+ (matching project requirements) +- Node.js + pnpm or npm for the frontend +- PyInstaller (resolved automatically via: + - `uvx pyinstaller` if `uv` is available + - else `pyinstaller` on PATH + - else `python -m PyInstaller`) + +## Standardized Entry Points + +Configured in [`pyproject.toml`](pyproject.toml:1) via `[project.scripts]`: + +- `lifeline` → [`lifeline.cli:main`](lifeline/cli.py:1) +- `lifeline-web` → [`lifeline.web_server:main`](lifeline/web_server.py:1) +- `lifeline-mcp` → [`lifeline.mcp_server:main`](lifeline/mcp_server.py:1) + +These are the single sources of truth for running from source and for PyInstaller. + +## Data and Config Paths + +Centralized in [`lifeline.paths`](lifeline/paths.py:1): + +- Env overrides: + - `LIFELINE_DATA_DIR` + - `LIFELINE_CONFIG_DIR` +- Defaults: + - macOS: `~/Library/Application Support/LifeLine` + - Windows: `%APPDATA%\LifeLine` + - Linux: `${XDG_DATA_HOME:-~/.local/share}/lifeline` +- Optional `.env` loading via `load_dotenv_from_config()` (no override of existing env vars). + +No secrets are embedded; API keys (e.g. `OPENAI_API_KEY`) come from env or optional user config. + +## Frontend Integration + +[`scripts.build`](scripts/build.py:1): + +- Detects package manager: + - `pnpm` if `pnpm-lock.yaml` and `pnpm` available + - otherwise `npm` +- Builds Next.js app: + - `pnpm install --frozen-lockfile && pnpm build` + - or `npm ci`/`npm install` + `npm run build` +- Stages output into: + - `build/frontend/` + +[`lifeline.web_server`](lifeline/web_server.py:1): + +- Reuses `app` from [`web.py`](web.py:1). +- On startup: + - Locates bundled frontend via `get_frontend_dir()`. + - If found, mounts it at `/` using `StaticFiles`. + - Existing `/api` + WebSocket endpoints remain unchanged. +- When no frontend is found: + - Behaves as before for dev/source usage. + +## PyInstaller Builds + +Spec files: + +- CLI: [`scripts/pyinstaller-cli.spec`](scripts/pyinstaller-cli.spec:1) + - Entry: `-m lifeline.cli` + - Produces `lifeline` binary. +- Web: [`scripts/pyinstaller-web.spec`](scripts/pyinstaller-web.spec:1) + - Entry: `-m lifeline.web_server` + - Produces `lifeline-web` binary. +- Both include `lifeline` package via `collect_submodules("lifeline")`. + +Invoked by orchestrator with: + +- Custom `--distpath`: + - `build/stage/{os}/cli` + - `build/stage/{os}/web` +- Custom `--workpath`: + - `build/pyi-work/{os}/{component}` + +## Unified Build Orchestrator + +[`scripts.build`](scripts/build.py:1) (run as `python -m scripts.build`): + +Arguments: + +- `--component`: + - `cli`, `web`, `frontend`, `all` (default: `all`) +- `--target`: + - `macos`, `windows`, `linux` + - If omitted, auto-detects from `sys.platform`. + +Behavior: + +1. Loads metadata from [`pyproject.toml`](pyproject.toml:1) for consistent naming. +2. Builds frontend when `component` is `frontend` or `all`. +3. Builds PyInstaller binaries for `cli` and/or `web`. +4. For `--component all`, assembles OS-specific layouts: + +### macOS + +- Stages into: + - `build/stage/macos/cli` + - `build/stage/macos/web` +- Assembles: + - `build/LifeLine.app`: + - `Contents/MacOS/LifeLine` launcher script. + - `Contents/Resources/lifeline-web` backend binary. + - `Contents/Resources/frontend/` (bundled assets). + - `Contents/Resources/LifeLine.icns` (from [`assets/icons/LifeLine.icns`](assets/icons/LifeLine.icns:1)). + - `Contents/Info.plist` using project name/version. +- `.dmg` creation is intentionally left to wrappers/CI tooling. + +### Windows + +- Stages PyInstaller outputs: + - `build/stage/windows/cli` + - `build/stage/windows/web` +- Produces layout: + - `build/windows/lifeline.exe` + - `build/windows/lifeline-web.exe` + - `build/windows/LifeLine.cmd` launcher (runs `lifeline-web.exe`) + - `build/windows/frontend/` (if built) + - `build/windows/lifeline.ico` (from [`assets/icons/lifeline.ico`](assets/icons/lifeline.ico:1) if present) +- Installer (NSIS/Inno/etc.) is expected to consume this layout; not bundled here. + +### Linux + +- Stages PyInstaller outputs: + - `build/stage/linux/cli` + - `build/stage/linux/web` +- Produces layout: + - `build/linux/lifeline` + - `build/linux/lifeline-web` + - `build/linux/frontend/` (if built) + - `build/linux/lifeline.png` (from `lifeline.png` or fallback [`icon.png`](icon.png:1)) + - `build/linux/lifeline.desktop` referencing `lifeline-web` +- Consumers can tarball this or wrap into an AppImage externally. + +## OS-specific Thin Wrappers + +All wrappers are non-interactive and delegate to the orchestrator. + +- macOS: + - [`scripts/build_macos.sh`](scripts/build_macos.sh:1) + - Runs: + - `python3 -m scripts.build --target macos --component all` +- Linux: + - [`scripts/build_linux.sh`](scripts/build_linux.sh:1) + - Runs: + - `python3 -m scripts.build --target linux --component all` +- Windows: + - [`scripts/build_windows.ps1`](scripts/build_windows.ps1:1) + - Runs: + - `python -m scripts.build --target windows --component all` + +## Makefile Integration (expected) + +Make targets should be thin wrappers (examples): + +- `build-cli` → `python -m scripts.build --component cli` +- `build-web` → `python -m scripts.build --component web` +- `build-frontend` → `python -m scripts.build --component frontend` +- `build-macos` → `python -m scripts.build --target macos --component all` +- `build-windows` → `python -m scripts.build --target windows --component all` +- `build-linux` → `python -m scripts.build --target linux --component all` +- `build-all` → run for all supported targets. + +(If not yet wired, these targets can be added without changing orchestrator logic.) + +## CI Usage + +Non-interactive commands suitable for CI: + +- macOS: + - `scripts/build_macos.sh` + - or `python -m scripts.build --target macos --component all` +- Windows: + - `powershell -File scripts/build_windows.ps1` + - or `python -m scripts.build --target windows --component all` +- Linux: + - `scripts/build_linux.sh` + - or `python -m scripts.build --target linux --component all` + +All outputs are under `build/` in predictable subdirectories for upload/signing/notarization. \ No newline at end of file diff --git a/lifeline/cli.py b/lifeline/cli.py new file mode 100644 index 0000000..a179135 --- /dev/null +++ b/lifeline/cli.py @@ -0,0 +1,29 @@ +# ... existing code ... +""" +LifeLine CLI entrypoint. + +Thin wrapper around the interactive CLI implemented in main.py. +This module exists so that the `lifeline` console_script entrypoint can be +stable and PyInstaller-friendly. +""" + +from __future__ import annotations + +import asyncio +import sys +from typing import NoReturn + +from main import main as _async_main + + +def main() -> NoReturn: + """ + Synchronous entrypoint for the LifeLine CLI. + + Runs the async main() defined in main.py and exits with its status code. + """ + try: + asyncio.run(_async_main()) + except KeyboardInterrupt: + # Match existing behavior from main.py when interrupted. + sys.exit(0) \ No newline at end of file diff --git a/lifeline/paths.py b/lifeline/paths.py new file mode 100644 index 0000000..85748fc --- /dev/null +++ b/lifeline/paths.py @@ -0,0 +1,205 @@ +""" +Cross-platform paths and configuration helpers for LifeLine. + +This module centralizes: +- Application name/slug +- User data/config directories +- Optional .env loading from a predictable location + +Design goals: +- No secrets baked into binaries. +- Environment variables remain the primary configuration mechanism. +- Consistent behavior for CLI, web backend, and packaged apps. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Optional + +APP_NAME = "LifeLine" +APP_SLUG = "lifeline" + + +def _is_frozen() -> bool: + """Return True when running from a PyInstaller bundle.""" + return bool(getattr(sys, "frozen", False)) + + +def get_base_dir() -> Path: + """ + Get the base directory for resolving bundled resources. + + For PyInstaller: + - sys._MEIPASS points to the temporary extraction dir. + - In onefile mode, resources should be placed next to the executable. + + For normal execution: + - Use the repository root-ish location (directory containing this file, up 1). + """ + if _is_frozen(): + # Executable directory (onefile) is usually where bundled extras live. + exe_dir = Path(sys.executable).resolve().parent + return exe_dir + + # When running from source, assume project root is two levels up from this file: lifeline/paths.py + return Path(__file__).resolve().parent.parent + + +def get_frontend_dir( + override: Optional[str] = None, + env_var: str = "LIFELINE_FRONTEND_DIR", +) -> Optional[Path]: + """ + Detect the directory containing built frontend assets. + + Resolution order: + 1. Explicit override argument. + 2. Environment variable (LIFELINE_FRONTEND_DIR). + 3. For frozen/bundled builds, ./frontend relative to the executable/base_dir. + 4. For dev/source layout, ../web-ui/.next/standalone or ../web-ui/.next (if present). + + Returns: + Path if found, else None. + """ + if override: + p = Path(override).expanduser() + return p if p.exists() else None + + env_val = os.getenv(env_var) + if env_val: + p = Path(env_val).expanduser() + if p.exists(): + return p + + base_dir = get_base_dir() + + # Bundled: expect frontend shipped under base_dir / "frontend" + candidate = base_dir / "frontend" + if candidate.exists(): + return candidate + + # Dev mode: look for Next.js output + repo_root = base_dir + # If running from lifeline package inside repo, adjust (handles editable installs). + if (repo_root / "web-ui").exists(): + web_ui_dir = repo_root / "web-ui" + elif (repo_root.parent / "web-ui").exists(): + web_ui_dir = repo_root.parent / "web-ui" + else: + web_ui_dir = None + + if web_ui_dir: + standalone = web_ui_dir / ".next" / "standalone" + if standalone.exists(): + return standalone + next_dir = web_ui_dir / ".next" + if next_dir.exists(): + return next_dir + + return None + + +def get_data_dir() -> Path: + """ + Get the user-writable data directory for LifeLine. + + Strategy: + - macOS: ~/Library/Application Support/LifeLine + - Windows: %APPDATA%\\LifeLine + - Linux: ${XDG_DATA_HOME:-~/.local/share}/lifeline + + For backward compatibility, if LIFELINE_DATA_DIR is set, it wins. + """ + # Explicit override + override = os.getenv("LIFELINE_DATA_DIR") + if override: + path = Path(override).expanduser() + path.mkdir(parents=True, exist_ok=True) + return path + + if sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + data_dir = base / APP_NAME + elif os.name == "nt" or sys.platform.startswith("win"): + appdata = os.getenv("APPDATA") or str(Path.home() / "AppData" / "Roaming") + data_dir = Path(appdata) / APP_NAME + else: + xdg = os.getenv("XDG_DATA_HOME") + if xdg: + data_dir = Path(xdg) / APP_SLUG + else: + data_dir = Path.home() / ".local" / "share" / APP_SLUG + + data_dir.mkdir(parents=True, exist_ok=True) + return data_dir + + +def get_config_dir() -> Path: + """ + Get the configuration directory for LifeLine. + + Mirrors get_data_dir() but uses config-oriented locations. + This is where we might keep a .env or config files. + + Strategy: + - LIFELINE_CONFIG_DIR overrides default. + - macOS: ~/Library/Application Support/LifeLine (same as data for simplicity) + - Windows: %APPDATA%\\LifeLine + - Linux: ${XDG_CONFIG_HOME:-~/.config}/lifeline + """ + override = os.getenv("LIFELINE_CONFIG_DIR") + if override: + path = Path(override).expanduser() + path.mkdir(parents=True, exist_ok=True) + return path + + if sys.platform == "darwin": + base = Path.home() / "Library" / "Application Support" + config_dir = base / APP_NAME + elif os.name == "nt" or sys.platform.startswith("win"): + appdata = os.getenv("APPDATA") or str(Path.home() / "AppData" / "Roaming") + config_dir = Path(appdata) / APP_NAME + else: + xdg = os.getenv("XDG_CONFIG_HOME") + if xdg: + config_dir = Path(xdg) / APP_SLUG + else: + config_dir = Path.home() / ".config" / APP_SLUG + + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir + + +def load_dotenv_from_config(filename: str = ".env") -> None: + """ + Load a dotenv-style file from the config directory if present. + + - Does nothing if the file is missing. + - Does not override already-set environment variables. + - Minimal parser; avoids external dependencies. + + Intended for user-level configuration, not for shipping secrets. + """ + config_dir = get_config_dir() + env_path = config_dir / filename + if not env_path.exists(): + return + + try: + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + except Exception: + # Fail silently; configuration is best-effort. + return \ No newline at end of file diff --git a/lifeline/web_server.py b/lifeline/web_server.py new file mode 100644 index 0000000..ad4f655 --- /dev/null +++ b/lifeline/web_server.py @@ -0,0 +1,87 @@ +""" +Web backend entrypoint for LifeLine. + +This module provides: +- A stable importable FastAPI `app` for ASGI servers. +- A `main()` function used by the `lifeline-web` console script and PyInstaller. +- Static frontend serving behavior for bundled/packaged builds. + +Behavior: +- Reuses the existing FastAPI app defined in web.py. +- On startup, attempts to locate built frontend assets: + - If a frontend directory is found (see lifeline.paths.get_frontend_dir), + mount it at "/" to serve the UI. + - APIs remain under "/api" (as defined in web.py). +- In dev mode (no bundled frontend found), behavior is backward compatible. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +import uvicorn +from fastapi.staticfiles import StaticFiles + +from lifeline.paths import get_frontend_dir +import web as legacy_web # existing FastAPI app and routes + +# Reuse the existing FastAPI app from web.py +app = legacy_web.app + + +def _mount_frontend_if_available() -> None: + """ + Detect and mount the built frontend if available. + + Mount points: + - "/" -> static frontend app (index.html, assets, etc.) + APIs and websockets defined in web.py (e.g. `/api`, `/ws/chat`) remain unchanged. + + This is idempotent and safe to call multiple times. + """ + # If already mounted under "/", do nothing. + for route in app.routes: + # StaticFiles mounts appear as routes with `path` and an `app` attribute. + if getattr(route, "path", None) == "/" and getattr(route, "app", None): + return + + frontend_dir: Optional[Path] = get_frontend_dir() + if not frontend_dir or not frontend_dir.exists(): + return + + app.mount( + "/", + StaticFiles(directory=str(frontend_dir), html=True), + name="frontend", + ) + + +def main() -> None: + """ + Run the LifeLine web backend with optional bundled frontend. + + This is the target for: + - `lifeline-web` console script (configured in pyproject.toml) + - PyInstaller web backend binary. + """ + _mount_frontend_if_available() + + host = os.getenv("LIFELINE_WEB_HOST", "0.0.0.0") + port_str = os.getenv("LIFELINE_WEB_PORT", "8000") + try: + port = int(port_str) + except ValueError: + port = 8000 + + uvicorn.run( + app, + host=host, + port=port, + log_level=os.getenv("LIFELINE_WEB_LOG_LEVEL", "info"), + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d49b5ac..a0c2aea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,8 +51,9 @@ dev = [ ] [project.scripts] -lifeline = "main:main" +lifeline = "lifeline.cli:main" lifeline-mcp = "lifeline.mcp_server:main" +lifeline-web = "lifeline.web_server:main" [project.urls] Homepage = "https://github.com/pc-style/lifeline" diff --git a/scripts/build.py b/scripts/build.py new file mode 100644 index 0000000..44e91e3 --- /dev/null +++ b/scripts/build.py @@ -0,0 +1,489 @@ +""" +Unified build orchestrator for LifeLine. + +Responsibilities: +- Single source of truth for: + - Frontend build (Next.js web-ui) + - PyInstaller binaries (CLI and web) + - OS-specific bundles/layouts for macOS, Windows, Linux +- Designed to be driven by: + - `make` targets + - Thin OS-specific wrappers: + - scripts/build_macos.sh + - scripts/build_linux.sh + - scripts/build_windows.ps1 + +Key behaviors: +- Uses uv if available (preferred) for Python tooling where needed. +- Uses pnpm if available and lockfile suggests, otherwise falls back to npm. +- Reads project metadata (name, version, description) from pyproject.toml. +- Produces predictable staging layout: + build/ + frontend/... + stage/{os}/{component}/... + +Usage examples: +- python -m scripts.build --component frontend --target macos +- python -m scripts.build --component cli --target linux +- python -m scripts.build --component all --target windows +""" + +from __future__ import annotations + +import argparse +import os +import platform +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Tuple + +try: + import tomllib # Python 3.11+ +except ModuleNotFoundError: # pragma: no cover + import tomli as tomllib # type: ignore[assignment] + + +ROOT = Path(__file__).resolve().parents[1] +BUILD_DIR = ROOT / "build" +FRONTEND_BUILD_DIR = BUILD_DIR / "frontend" +STAGE_DIR = BUILD_DIR / "stage" + +PYPROJECT_PATH = ROOT / "pyproject.toml" +PYINSTALLER_CLI_SPEC = ROOT / "scripts" / "pyinstaller-cli.spec" +PYINSTALLER_WEB_SPEC = ROOT / "scripts" / "pyinstaller-web.spec" + + +@dataclass +class ProjectMeta: + name: str + version: str + description: str + + +def load_project_meta() -> ProjectMeta: + data = tomllib.loads(PYPROJECT_PATH.read_text(encoding="utf-8")) + proj = data.get("project", {}) + name = proj.get("name", "lifeline") + version = proj.get("version", "0.0.0") + description = proj.get("description", "") + return ProjectMeta(name=name, version=version, description=description) + + +def which(cmd: str) -> Optional[str]: + return shutil.which(cmd) + + +def run(cmd: List[str], cwd: Optional[Path] = None, env: Optional[dict] = None) -> None: + """Run a command, raising on failure, with simple logging.""" + print(f"[build] Running: {' '.join(cmd)} (cwd={cwd or ROOT})") + subprocess.run( + cmd, + cwd=str(cwd or ROOT), + env=env or os.environ.copy(), + check=True, + ) + + +def ensure_dir(path: Path, clean: bool = False) -> Path: + if clean and path.exists(): + shutil.rmtree(path) + path.mkdir(parents=True, exist_ok=True) + return path + + +def detect_node_pm(web_ui_dir: Path) -> Tuple[str, List[str]]: + """ + Detect package manager for web-ui. + + Priority: + - pnpm if pnpm-lock.yaml exists and pnpm is installed. + - npm (fallback). + """ + if (web_ui_dir / "pnpm-lock.yaml").exists() and which("pnpm"): + return "pnpm", ["pnpm"] + if which("npm"): + return "npm", ["npm"] + raise RuntimeError("No supported package manager found (pnpm or npm).") + + +def build_frontend() -> None: + """ + Build the Next.js frontend and copy artifacts to build/frontend. + + Strategy: + - Run `pnpm install` / `npm install` if needed. + - Run `pnpm build` / `npm run build`. + - Copy the relevant output into FRONTEND_BUILD_DIR. + For now, copy the `.next` directory and `public` assets as a bundle. + This directory is then consumed by lifeline.paths.get_frontend_dir() + and bundled by PyInstaller for lifeline-web. + """ + web_ui_dir = ROOT / "web-ui" + if not web_ui_dir.exists(): + print("[build] web-ui directory not found; skipping frontend build.") + return + + pm_name, pm = detect_node_pm(web_ui_dir) + print(f"[build] Using package manager: {pm_name} for web-ui") + + # Install dependencies (non-interactive; rely on lockfiles) + # Use `install --frozen-lockfile` for pnpm, `ci` for npm if lockfile present. + if pm_name == "pnpm": + run(pm + ["install", "--frozen-lockfile"], cwd=web_ui_dir) + run(pm + ["build"], cwd=web_ui_dir) + else: # npm + lock = web_ui_dir / "package-lock.json" + if lock.exists(): + run(pm + ["ci"], cwd=web_ui_dir) + else: + run(pm + ["install"], cwd=web_ui_dir) + run(pm + ["run", "build"], cwd=web_ui_dir) + + # Copy artifacts + ensure_dir(FRONTEND_BUILD_DIR, clean=True) + + # Prefer Next.js standalone if present + standalone = web_ui_dir / ".next" / "standalone" + if standalone.exists(): + shutil.copytree(standalone, FRONTEND_BUILD_DIR, dirs_exist_ok=True) + else: + # Fallback: copy .next and public + next_dir = web_ui_dir / ".next" + if next_dir.exists(): + shutil.copytree(next_dir, FRONTEND_BUILD_DIR / ".next", dirs_exist_ok=True) + public_dir = web_ui_dir / "public" + if public_dir.exists(): + shutil.copytree(public_dir, FRONTEND_BUILD_DIR / "public", dirs_exist_ok=True) + + print(f"[build] Frontend assets staged at {FRONTEND_BUILD_DIR}") + + +def get_pyinstaller_invoker() -> List[str]: + """ + Return the command prefix to invoke PyInstaller. + + Prefer: + - `uvx pyinstaller` if uv is available. + Fallback: + - `pyinstaller` from PATH + - `python -m PyInstaller` + """ + if which("uv"): + return ["uvx", "pyinstaller"] + + if which("pyinstaller"): + return ["pyinstaller"] + + # Fallback to python -m if PyInstaller is installed in the current env. + return [sys.executable, "-m", "PyInstaller"] + + +def build_cli_binary(target_os: str) -> Path: + """ + Build lifeline CLI binary via PyInstaller spec. + + Output: + build/stage/{os}/cli/ + """ + ensure_dir(STAGE_DIR / target_os / "cli", clean=False) + invoker = get_pyinstaller_invoker() + + cmd = invoker + [ + str(PYINSTALLER_CLI_SPEC), + "--distpath", + str(STAGE_DIR / target_os / "cli"), + "--workpath", + str(BUILD_DIR / "pyi-work" / target_os / "cli"), + ] + run(cmd, cwd=ROOT) + + print(f"[build] CLI binary staged under {STAGE_DIR / target_os / 'cli'}") + return STAGE_DIR / target_os / "cli" + + +def build_web_binary(target_os: str) -> Path: + """ + Build lifeline-web backend binary via PyInstaller spec. + + Output: + build/stage/{os}/web/ + """ + ensure_dir(STAGE_DIR / target_os / "web", clean=False) + invoker = get_pyinstaller_invoker() + + cmd = invoker + [ + str(PYINSTALLER_WEB_SPEC), + "--distpath", + str(STAGE_DIR / target_os / "web"), + "--workpath", + str(BUILD_DIR / "pyi-work" / target_os / "web"), + ] + run(cmd, cwd=ROOT) + + print(f"[build] Web binary staged under {STAGE_DIR / target_os / 'web'}") + return STAGE_DIR / target_os / "web" + + +def build_macos_bundle(meta: ProjectMeta) -> None: + """ + Assemble macOS .app and .dmg layout from staged binaries and frontend. + + Layout (inside .app): + LifeLine.app/ + Contents/ + MacOS/ + LifeLine (launcher, can be lifeline-web or wrapper) + Resources/ + lifeline-web (backend binary) + frontend/ (copied from build/frontend) + LifeLine.icns + + The thin wrapper scripts/build_macos.sh will typically call: + python -m scripts.build --target macos --component all + """ + target_os = "macos" + app_name = "LifeLine" + app_dir = BUILD_DIR / f"{app_name}.app" + contents_dir = app_dir / "Contents" + macos_dir = contents_dir / "MacOS" + resources_dir = contents_dir / "Resources" + + # Ensure binaries exist + cli_dir = STAGE_DIR / target_os / "cli" + web_dir = STAGE_DIR / target_os / "web" + if not cli_dir.exists() or not web_dir.exists(): + print("[build] Missing staged CLI/WEB binaries for macOS; skipping .app assembly.") + return + + ensure_dir(macos_dir, clean=True) + ensure_dir(resources_dir, clean=False) + + # Backend binary: assume 'lifeline-web' from PyInstaller + lifeline_web_bin_candidates = list(web_dir.glob("lifeline-web*")) + if not lifeline_web_bin_candidates: + print("[build] lifeline-web binary not found; cannot assemble macOS app.") + return + lifeline_web_bin = lifeline_web_bin_candidates[0] + shutil.copy2(lifeline_web_bin, resources_dir / "lifeline-web") + + # Frontend assets + if FRONTEND_BUILD_DIR.exists(): + shutil.copytree(FRONTEND_BUILD_DIR, resources_dir / "frontend", dirs_exist_ok=True) + + # CLI binary (optionally bundle for convenience) + lifeline_cli_candidates = list(cli_dir.glob("lifeline*")) + if lifeline_cli_candidates: + shutil.copy2(lifeline_cli_candidates[0], resources_dir / "lifeline") + + # Launcher script/binary (here: small bash wrapper) + launcher = macos_dir / app_name + launcher.write_text( + "#!/bin/bash\n" + 'APP_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" &>/dev/null && pwd)\"\n' + 'RES_DIR=\"$APP_DIR/Resources\"\n' + '\"$RES_DIR/lifeline-web\"\n', + encoding="utf-8", + ) + launcher.chmod(0o755) + + # Info.plist + info_plist = contents_dir / "Info.plist" + info_plist.write_text( + f""" + + + + CFBundleName + {app_name} + CFBundleDisplayName + {app_name} + CFBundleIdentifier + com.example.{meta.name} + CFBundleVersion + {meta.version} + CFBundleShortVersionString + {meta.version} + CFBundlePackageType + APPL + CFBundleIconFile + LifeLine.icns + + +""", + encoding="utf-8", + ) + + # Icon + icns_src = ROOT / "assets" / "icons" / "LifeLine.icns" + if icns_src.exists(): + shutil.copy2(icns_src, resources_dir / "LifeLine.icns") + + print(f"[build] macOS app assembled at {app_dir}") + # .dmg creation left to wrapper or later automation. + + +def build_windows_layout(meta: ProjectMeta) -> None: + """ + Prepare Windows layout with: + - lifeline.exe (CLI) + - lifeline-web.exe (backend) + - LifeLine.exe (launcher stub or copy of lifeline-web) + + The wrapper scripts/build_windows.ps1 can consume this layout + and feed it to an installer system (NSIS/Inno Setup/etc.). + """ + target_os = "windows" + out_dir = BUILD_DIR / "windows" + ensure_dir(out_dir, clean=True) + + cli_dir = STAGE_DIR / target_os / "cli" + web_dir = STAGE_DIR / target_os / "web" + + lifeline_cli = next(cli_dir.glob("lifeline*.exe"), None) if cli_dir.exists() else None + lifeline_web = next(web_dir.glob("lifeline-web*.exe"), None) if web_dir.exists() else None + + if lifeline_cli: + shutil.copy2(lifeline_cli, out_dir / "lifeline.exe") + if lifeline_web: + shutil.copy2(lifeline_web, out_dir / "lifeline-web.exe") + + # Launcher (simple wrapper: run lifeline-web.exe) + launcher = out_dir / "LifeLine.cmd" + launcher.write_text( + "@echo off\r\n" + "setlocal\r\n" + "set SCRIPT_DIR=%~dp0\r\n" + "\"%SCRIPT_DIR%lifeline-web.exe\"\r\n", + encoding="utf-8", + ) + + # Copy frontend if available + if FRONTEND_BUILD_DIR.exists(): + shutil.copytree(FRONTEND_BUILD_DIR, out_dir / "frontend", dirs_exist_ok=True) + + # Icon wiring for installer is left to external tooling; we only stage assets. + ico_src = ROOT / "assets" / "icons" / "lifeline.ico" + if ico_src.exists(): + shutil.copy2(ico_src, out_dir / "lifeline.ico") + + print(f"[build] Windows layout staged at {out_dir}") + + +def build_linux_layout(meta: ProjectMeta) -> None: + """ + Prepare Linux layout: + - lifeline (CLI binary) + - lifeline-web (backend binary) + - frontend/ assets + - .desktop file + - icon + + Consumers can package this as tarball or AppImage. + """ + target_os = "linux" + out_dir = BUILD_DIR / "linux" + ensure_dir(out_dir, clean=True) + + cli_dir = STAGE_DIR / target_os / "cli" + web_dir = STAGE_DIR / target_os / "web" + + lifeline_cli = next((p for p in cli_dir.glob("lifeline*") if p.is_file()), None) if cli_dir.exists() else None + lifeline_web = next((p for p in web_dir.glob("lifeline-web*") if p.is_file()), None) if web_dir.exists() else None + + if lifeline_cli: + shutil.copy2(lifeline_cli, out_dir / "lifeline") + if lifeline_web: + shutil.copy2(lifeline_web, out_dir / "lifeline-web") + + if FRONTEND_BUILD_DIR.exists(): + shutil.copytree(FRONTEND_BUILD_DIR, out_dir / "frontend", dirs_exist_ok=True) + + # Icon + icon_src = ROOT / "lifeline.png" + if not icon_src.exists(): + icon_src = ROOT / "icon.png" + if icon_src.exists(): + shutil.copy2(icon_src, out_dir / "lifeline.png") + + # .desktop file + desktop = out_dir / "lifeline.desktop" + desktop.write_text( + f"""[Desktop Entry] +Type=Application +Name=LifeLine +Comment={meta.description} +Exec={out_dir}/lifeline-web +Icon={out_dir}/lifeline.png +Terminal=false +Categories=Utility; +""", + encoding="utf-8", + ) + + print(f"[build] Linux layout staged at {out_dir}") + + +def parse_args(argv: Optional[list] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="LifeLine build orchestrator") + parser.add_argument( + "--component", + choices=["cli", "web", "frontend", "all"], + default="all", + help="Component to build", + ) + parser.add_argument( + "--target", + choices=["macos", "windows", "linux"], + default=None, + help="Target OS; defaults to current platform", + ) + return parser.parse_args(argv) + + +def detect_target_os(explicit: Optional[str]) -> str: + if explicit: + return explicit + sysplat = sys.platform + if sysplat == "darwin": + return "macos" + if sysplat.startswith("win"): + return "windows" + return "linux" + + +def main(argv: Optional[list] = None) -> None: + args = parse_args(argv) + target_os = detect_target_os(args.target) + meta = load_project_meta() + + print(f"[build] Target OS: {target_os}") + print(f"[build] Component: {args.component}") + print(f"[build] Project: {meta.name} {meta.version}") + + # Frontend + if args.component in ("frontend", "all"): + build_frontend() + + # CLI + if args.component in ("cli", "all"): + build_cli_binary(target_os) + + # Web + if args.component in ("web", "all"): + build_web_binary(target_os) + + # OS-specific bundle/layout + if args.component == "all": + if target_os == "macos": + build_macos_bundle(meta) + elif target_os == "windows": + build_windows_layout(meta) + elif target_os == "linux": + build_linux_layout(meta) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/build_linux.sh b/scripts/build_linux.sh index d5ef829..4cb3119 100755 --- a/scripts/build_linux.sh +++ b/scripts/build_linux.sh @@ -1,141 +1,20 @@ #!/usr/bin/env bash - -# LifeLine Linux build script -# builds executable with pyinstaller, packages for distribution - set -euo pipefail -project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$project_root" - -draw_banner() { - printf "============================================\n" - printf " LifeLine :: Linux build kitchen\n" - printf "============================================\n" -} - -die() { - echo "fatal: $1" >&2 - exit 1 -} - -need_cmd() { - local cmd="$1" - local hint="${2:-}" - if ! command -v "$cmd" >/dev/null 2>&1; then - die "missing $cmd $hint" - fi -} - -ensure_uv() { - if command -v uv >/dev/null 2>&1; then - return - fi - printf "installing uv (bo tak szybciej)\n" - curl -LsSf https://astral.sh/uv/install.sh | sh - export PATH="$HOME/.local/bin:$PATH" - if [ -d "$HOME/.cargo/bin" ]; then - export PATH="$HOME/.cargo/bin:$PATH" - fi -} - -build_binary() { - mkdir -p build - printf "\n> checking pyinstaller...\n" - if ! uv run python -c "import PyInstaller" 2>/dev/null; then - printf "installing pyinstaller...\n" - uv pip install pyinstaller || die "pyinstaller install failed" - fi - - printf "\n> bundling CLI with pyinstaller (może chwilę zająć)\n" - uv run pyinstaller \ - --clean \ - --noconfirm \ - --onefile \ - --name lifeline-cli \ - --add-data "lifeline:lifeline" \ - main.py || die "CLI build failed" -} - -stage_payload() { - local stage_dir="build/linux-stage" - local app_dir="$stage_dir/LifeLine" - - rm -rf "$stage_dir" - mkdir -p "$app_dir" - - # copy executable - cp dist/lifeline-cli "$app_dir/LifeLine" - chmod +x "$app_dir/LifeLine" - - # create README - cat > "$app_dir/READ_ME_FIRST.txt" <<'EOF' -LifeLine CLI (Linux) -===================== - -QUICK START: -1. chmod +x LifeLine -2. ./LifeLine - -SETUP: -First launch będzie narzekał jeśli nie ustawisz OPENAI_API_KEY. -Set it like: - export OPENAI_API_KEY="sk-..." - -Or create .env file in this directory: - echo "OPENAI_API_KEY=sk-..." > .env - -Tip: timeline data ląduje w ./data (auto tworzone). - -Have fun, ugh. -EOF - - # create launcher script - cat > "$app_dir/lifeline" <<'LAUNCHER' -#!/usr/bin/env bash -# LifeLine CLI launcher - -APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$APP_DIR" - -# source .env if it exists -if [ -f "$APP_DIR/.env" ]; then - set -a - source "$APP_DIR/.env" - set +a -fi - -exec "$APP_DIR/LifeLine" "$@" -LAUNCHER - - chmod +x "$app_dir/lifeline" -} - -make_tarball() { - local stage_dir="build/linux-stage" - local tarball_name="LifeLine-Linux.tar.gz" - local out="dist/$tarball_name" - - rm -f "$out" - mkdir -p dist +# Thin wrapper for Linux build. +# Delegates all logic to the unified Python orchestrator: +# python -m scripts.build --target linux --component all - cd "$stage_dir" - tar -czf "../../$out" LifeLine - cd "$project_root" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." &>/dev/null && pwd)" - printf "\nTarball ready -> %s\n" "$out" -} +cd "$REPO_ROOT" -main() { - draw_banner - ensure_uv - need_cmd "python3" +PYTHON_BIN="${PYTHON_BIN:-python3}" - build_binary - stage_payload - make_tarball +echo "[build-linux] Using Python: $PYTHON_BIN" +echo "[build-linux] Invoking orchestrator for Linux (all components)" - printf "\nDone. Upload to GitHub releases, whatever.\n" -} +"$PYTHON_BIN" -m scripts.build --target linux --component all -main "$@" +echo "[build-linux] Completed. Artifacts are under ./build" \ No newline at end of file diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh new file mode 100644 index 0000000..36b46b5 --- /dev/null +++ b/scripts/build_macos.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Thin wrapper for macOS build. +# Delegates all logic to the unified Python orchestrator: +# python -m scripts.build --target macos --component all + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." &>/dev/null && pwd)" + +cd "$REPO_ROOT" + +PYTHON_BIN="${PYTHON_BIN:-python3}" + +echo "[build-macos] Using Python: $PYTHON_BIN" +echo "[build-macos] Invoking orchestrator for macOS (all components)" + +"$PYTHON_BIN" -m scripts.build --target macos --component all + +echo "[build-macos] Completed. Artifacts are under ./build" \ No newline at end of file diff --git a/scripts/build_windows.ps1 b/scripts/build_windows.ps1 index 8525460..e1829d1 100644 --- a/scripts/build_windows.ps1 +++ b/scripts/build_windows.ps1 @@ -1,101 +1,25 @@ -# LifeLine Windows build script -# builds exe with pyinstaller, packages for distribution +Param( + [string]$PythonBin = "python" +) $ErrorActionPreference = "Stop" -$projectRoot = Split-Path -Parent $PSScriptRoot -Set-Location $projectRoot +# Thin wrapper for Windows build. +# Delegates all logic to the unified Python orchestrator: +# python -m scripts.build --target windows --component all -Write-Host "=============================================" -Write-Host " LifeLine :: Windows build kitchen" -Write-Host "=============================================" +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$RepoRoot = Resolve-Path (Join-Path $ScriptDir "..") -# check python -if (-not (Get-Command python -ErrorAction SilentlyContinue)) { - Write-Host "fatal: python not found, install from python.org" - exit 1 -} - -$pythonVersion = python --version -Write-Host "python -> $pythonVersion" +Write-Host "[build-windows] Using Python: $PythonBin" +Write-Host "[build-windows] Invoking orchestrator for Windows (all components)" -# ensure uv -if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { - Write-Host "installing uv (bo tak szybciej)" - $env:PATH = "$env:USERPROFILE\.local\bin;$env:PATH" - if (-not (Test-Path "$env:USERPROFILE\.local\bin\uv.exe")) { - Invoke-WebRequest -Uri "https://astral.sh/uv/install.ps1" -UseBasicParsing | Invoke-Expression - } -} - -# ensure pyinstaller -Write-Host "`n> checking pyinstaller..." -$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH" +Push-Location $RepoRoot try { - uv run python -c "import PyInstaller" 2>$null - if ($LASTEXITCODE -ne 0) { throw } -} catch { - Write-Host "installing pyinstaller..." - uv pip install pyinstaller - if ($LASTEXITCODE -ne 0) { - Write-Host "fatal: pyinstaller install failed" - exit 1 - } + & $PythonBin -m scripts.build --target windows --component all } - -# build binary -Write-Host "`n> bundling CLI with pyinstaller (może chwilę zająć)..." -New-Item -ItemType Directory -Force -Path "build" | Out-Null - -uv run pyinstaller ` - --clean ` - --noconfirm ` - --onefile ` - --name lifeline-cli ` - --add-data "lifeline;lifeline" ` - main.py - -if ($LASTEXITCODE -ne 0) { - Write-Host "fatal: pyinstaller build failed" - exit 1 +finally { + Pop-Location } -# stage payload -$stageDir = "build\windows-stage" -$appDir = "$stageDir\LifeLine" -Remove-Item -Recurse -Force $stageDir -ErrorAction SilentlyContinue -New-Item -ItemType Directory -Force -Path $appDir | Out-Null - -Copy-Item "dist\lifeline-cli.exe" "$appDir\LifeLine.exe" - -@" -LifeLine CLI (Windows) -====================== - -1. Open Command Prompt or PowerShell -2. cd to wherever you extracted this folder -3. .\LifeLine.exe - -First launch will prompt you for your OpenAI API key if not set. -You can also set it manually: - set OPENAI_API_KEY=sk-... - # or in PowerShell: - `$env:OPENAI_API_KEY='sk-...' - # or create .env file in this directory with: - # OPENAI_API_KEY=sk-... - -Tip: timeline data ląduje w .\data (auto tworzone). - -Have fun, ugh. -"@ | Out-File -FilePath "$appDir\READ_ME_FIRST.txt" -Encoding UTF8 - -# create zip -$zipName = "LifeLine-Windows.zip" -$zipPath = "dist\$zipName" -Remove-Item $zipPath -ErrorAction SilentlyContinue - -Write-Host "`n> creating zip archive..." -Compress-Archive -Path "$appDir\*" -DestinationPath $zipPath -Force - -Write-Host "`nDone. Windows build ready -> $zipPath" - +Write-Host "[build-windows] Completed. Artifacts are under .\build" \ No newline at end of file