Compare commits
4 Commits
v0.1.7
..
8984e0ebb7
| Author | SHA1 | Date | |
|---|---|---|---|
| 8984e0ebb7 | |||
| 9540520dc4 | |||
| 300ed387f5 | |||
| dea81de5b5 |
@@ -34,6 +34,9 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Generate build variables
|
||||||
|
run: python scripts/generate_build_vars.py
|
||||||
|
|
||||||
- name: Load config from Makefile
|
- name: Load config from Makefile
|
||||||
id: config
|
id: config
|
||||||
run: make -s _ci-dump-config >> $GITHUB_OUTPUT
|
run: make -s _ci-dump-config >> $GITHUB_OUTPUT
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
# --- Profiles management ---
|
# --- Profiles management ---
|
||||||
# Usage: make up p=rag,meili
|
# Usage: make up p=rag,meili
|
||||||
p ?= core
|
p ?= full
|
||||||
PROFILES_PARAM := COMPOSE_PROFILES=$(p)
|
PROFILES_PARAM := COMPOSE_PROFILES=$(p)
|
||||||
|
|
||||||
# --- Commands ---
|
# --- Commands ---
|
||||||
@@ -16,8 +16,8 @@ DOCKER_BUILD := docker build --no-cache \
|
|||||||
--build-arg RUNNER=$(RUNNER)
|
--build-arg RUNNER=$(RUNNER)
|
||||||
|
|
||||||
# --- Phony ---
|
# --- Phony ---
|
||||||
.PHONY: .env up down restart logs ps shell build build-test install update \
|
.PHONY: .env bootstrap up down restart logs ps shell build build-test install \
|
||||||
install-hooks test coverage lint format clean major minor patch help
|
update install-hooks test coverage lint format clean major minor patch help
|
||||||
|
|
||||||
# --- Setup ---
|
# --- Setup ---
|
||||||
.env .env.make:
|
.env .env.make:
|
||||||
@@ -30,14 +30,14 @@ bootstrap: .env .env.make
|
|||||||
|
|
||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
up: .env
|
up: .env
|
||||||
@echo "Starting containers with profiles: [$(p)]..."
|
@echo "Starting containers with profiles: [full]..."
|
||||||
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) up -d --remove-orphans \
|
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) up -d --remove-orphans \
|
||||||
&& echo "✓ Containers started" \
|
&& echo "✓ Containers started" \
|
||||||
|| (echo "✗ Failed to start containers" && exit 1)
|
|| (echo "✗ Failed to start containers" && exit 1)
|
||||||
|
|
||||||
down:
|
down:
|
||||||
@echo "Stopping containers..."
|
@echo "Stopping containers..."
|
||||||
@$(DOCKER_COMPOSE) down \
|
@$(PROFILES_PARAM) $(DOCKER_COMPOSE) down \
|
||||||
&& echo "✓ Containers stopped" \
|
&& echo "✓ Containers stopped" \
|
||||||
|| (echo "✗ Failed to stop containers" && exit 1)
|
|| (echo "✗ Failed to stop containers" && exit 1)
|
||||||
|
|
||||||
|
|||||||
@@ -1,231 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import os
|
|
||||||
import secrets
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import StrEnum
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import NoReturn
|
|
||||||
|
|
||||||
REQUIRED_VARS = ["DEEPSEEK_API_KEY", "TMDB_API_KEY", "QBITTORRENT_URL"]
|
|
||||||
|
|
||||||
# Size in bytes
|
|
||||||
KEYS_TO_GENERATE = {
|
|
||||||
"JWT_SECRET": 32,
|
|
||||||
"JWT_REFRESH_SECRET": 32,
|
|
||||||
"CREDS_KEY": 32,
|
|
||||||
"CREDS_IV": 16,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Style(StrEnum):
|
|
||||||
"""ANSI codes for styling output.
|
|
||||||
Usage: f"{Style.RED}Error{Style.RESET}"
|
|
||||||
"""
|
|
||||||
|
|
||||||
RESET = "\033[0m"
|
|
||||||
BOLD = "\033[1m"
|
|
||||||
RED = "\033[31m"
|
|
||||||
GREEN = "\033[32m"
|
|
||||||
YELLOW = "\033[33m"
|
|
||||||
CYAN = "\033[36m"
|
|
||||||
DIM = "\033[2m"
|
|
||||||
|
|
||||||
|
|
||||||
# Only for terminals and if not specified otherwise
|
|
||||||
USE_COLORS = sys.stdout.isatty() and "NO_COLOR" not in os.environ
|
|
||||||
|
|
||||||
|
|
||||||
def styled(text: str, color_code: str) -> str:
|
|
||||||
"""Apply color only if supported by the terminal."""
|
|
||||||
if USE_COLORS:
|
|
||||||
return f"{color_code}{text}{Style.RESET}"
|
|
||||||
return text
|
|
||||||
|
|
||||||
|
|
||||||
def log(msg: str, color: str | None = None, prefix="") -> None:
|
|
||||||
"""Print a formatted message."""
|
|
||||||
formatted_msg = styled(msg, color) if color else msg
|
|
||||||
print(f"{prefix}{formatted_msg}")
|
|
||||||
|
|
||||||
|
|
||||||
def error_exit(msg: str) -> NoReturn:
|
|
||||||
"""Print an error message in red and exit."""
|
|
||||||
log(f"❌ {msg}", Style.RED)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def is_docker_running() -> bool:
|
|
||||||
""" "Check if Docker is available and responsive."""
|
|
||||||
if shutil.which("docker") is None:
|
|
||||||
error_exit("Docker is not installed.")
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "info"],
|
|
||||||
# Redirect stdout/stderr to keep output clean on success
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
# Prevent exception being raised
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return result.returncode == 0
|
|
||||||
|
|
||||||
|
|
||||||
def parse_env(content: str) -> dict[str, str]:
|
|
||||||
"""Parses existing keys and values into a dict (ignoring comments)."""
|
|
||||||
env_vars = {}
|
|
||||||
for raw_line in content.splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
if line and not line.startswith("#") and "=" in line:
|
|
||||||
key, value = line.split("=", 1)
|
|
||||||
env_vars[key.strip()] = value.strip()
|
|
||||||
|
|
||||||
return env_vars
|
|
||||||
|
|
||||||
|
|
||||||
def dump_env(content: str, data: dict[str, str]) -> str:
|
|
||||||
new_content: list[str] = []
|
|
||||||
processed_keys = set()
|
|
||||||
|
|
||||||
for raw_line in content.splitlines():
|
|
||||||
line = raw_line.strip()
|
|
||||||
# Fast line (empty, comment or not an assignation)
|
|
||||||
if len(line) == 0 or line.startswith("#") or "=" not in line:
|
|
||||||
new_content.append(raw_line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Slow line (inline comment to be kept)
|
|
||||||
key_chunk, value_chunk = raw_line.split("=", 1)
|
|
||||||
key = key_chunk.strip()
|
|
||||||
|
|
||||||
# Not in the update list
|
|
||||||
if key not in data:
|
|
||||||
new_content.append(raw_line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
processed_keys.add(key)
|
|
||||||
new_value = data[key]
|
|
||||||
|
|
||||||
if " #" not in value_chunk:
|
|
||||||
new_line = f"{key_chunk}={new_value}"
|
|
||||||
else:
|
|
||||||
_, comment = value_chunk.split(" #", 1)
|
|
||||||
new_line = f"{key_chunk}={new_value} #{comment}"
|
|
||||||
|
|
||||||
new_content.append(new_line)
|
|
||||||
|
|
||||||
for key, value in data.items():
|
|
||||||
if key not in processed_keys:
|
|
||||||
new_content.append(f"{key}={value}")
|
|
||||||
|
|
||||||
return "\n".join(new_content) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_env() -> None:
|
|
||||||
"""Manage .env lifecycle: creation, secret generation, prompts."""
|
|
||||||
env_path = Path(".env")
|
|
||||||
env_example_path = Path(".env.example")
|
|
||||||
updated: bool = False
|
|
||||||
|
|
||||||
# Read .env if exists
|
|
||||||
if env_path.exists():
|
|
||||||
content: str = env_path.read_text(encoding="utf-8")
|
|
||||||
else:
|
|
||||||
content: str = env_example_path.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
existing_vars: dict[str, str] = parse_env(content)
|
|
||||||
|
|
||||||
# Generate missing secrets
|
|
||||||
for key, length in KEYS_TO_GENERATE.items():
|
|
||||||
if key not in existing_vars or not existing_vars[key]:
|
|
||||||
log(f"Generating {key}...", Style.GREEN, prefix=" ")
|
|
||||||
existing_vars[key] = secrets.token_hex(length)
|
|
||||||
updated = True
|
|
||||||
log("Done", Style.GREEN, prefix=" ")
|
|
||||||
|
|
||||||
# Prompt for missing mandatory keys
|
|
||||||
color = Style.YELLOW if USE_COLORS else ""
|
|
||||||
reset = Style.RESET if USE_COLORS else ""
|
|
||||||
for key in REQUIRED_VARS:
|
|
||||||
if key not in existing_vars or not existing_vars[key]:
|
|
||||||
try:
|
|
||||||
existing_vars[key] = input(
|
|
||||||
f" {color}Enter value for {key}: {reset}"
|
|
||||||
).strip()
|
|
||||||
updated = True
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print()
|
|
||||||
error_exit("Aborted by user.")
|
|
||||||
|
|
||||||
# Write to disk
|
|
||||||
if updated:
|
|
||||||
# But backup original first
|
|
||||||
if env_path.exists():
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
backup_path = Path(f"{env_path}.{timestamp}.bak")
|
|
||||||
shutil.copy(env_path, backup_path)
|
|
||||||
log(f"Backup created: {backup_path}", Style.DIM)
|
|
||||||
|
|
||||||
new_content = dump_env(content, existing_vars)
|
|
||||||
env_path.write_text(new_content, encoding="utf-8")
|
|
||||||
log(".env updated successfully.", Style.GREEN)
|
|
||||||
else:
|
|
||||||
log("Configuration is up to date.", Style.GREEN)
|
|
||||||
|
|
||||||
|
|
||||||
def setup() -> None:
|
|
||||||
"""Orchestrate initialization."""
|
|
||||||
is_docker_running()
|
|
||||||
ensure_env()
|
|
||||||
|
|
||||||
|
|
||||||
def status() -> None:
|
|
||||||
"""Display simple dashboard."""
|
|
||||||
# Hardcoded bold style for title if colors are enabled
|
|
||||||
title_style = Style.BOLD if USE_COLORS else ""
|
|
||||||
reset_style = Style.RESET if USE_COLORS else ""
|
|
||||||
|
|
||||||
print(f"\n{title_style}ALFRED STATUS{reset_style}")
|
|
||||||
print(f"{title_style}==============={reset_style}\n")
|
|
||||||
|
|
||||||
# Docker Check
|
|
||||||
if is_docker_running():
|
|
||||||
print(f" Docker: {styled('✓ running', Style.GREEN)}")
|
|
||||||
else:
|
|
||||||
print(f" Docker: {styled('✗ stopped', Style.RED)}")
|
|
||||||
|
|
||||||
# Env Check
|
|
||||||
if Path(".env").exists():
|
|
||||||
print(f" .env: {styled('✓ present', Style.GREEN)}")
|
|
||||||
else:
|
|
||||||
print(f" .env: {styled('✗ missing', Style.RED)}")
|
|
||||||
|
|
||||||
print("")
|
|
||||||
|
|
||||||
|
|
||||||
def check() -> None:
|
|
||||||
"""Silent check for prerequisites (used by 'make up')."""
|
|
||||||
setup()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Usage: python cli.py [setup|check|status]")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
cmd = sys.argv[1]
|
|
||||||
|
|
||||||
if cmd == "setup":
|
|
||||||
setup()
|
|
||||||
elif cmd == "check":
|
|
||||||
check()
|
|
||||||
elif cmd == "status":
|
|
||||||
status()
|
|
||||||
else:
|
|
||||||
error_exit(f"Unknown command: {cmd}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+10
-16
@@ -1,8 +1,8 @@
|
|||||||
import re
|
|
||||||
import secrets
|
import secrets
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import tomllib
|
import tomllib
|
||||||
|
from config_loader import load_build_config, write_env_make
|
||||||
|
|
||||||
|
|
||||||
def generate_secret(rule: str) -> str:
|
def generate_secret(rule: str) -> str:
|
||||||
@@ -31,6 +31,8 @@ def extract_python_version(version_string: str) -> tuple[str, str]:
|
|||||||
"~3.14.2" -> ("3.14.2", "3.14")
|
"~3.14.2" -> ("3.14.2", "3.14")
|
||||||
"3.14.2" -> ("3.14.2", "3.14")
|
"3.14.2" -> ("3.14.2", "3.14")
|
||||||
"""
|
"""
|
||||||
|
import re # noqa: PLC0415
|
||||||
|
|
||||||
# Remove poetry version operators (==, ^, ~, >=, etc.)
|
# Remove poetry version operators (==, ^, ~, >=, etc.)
|
||||||
clean_version = re.sub(r"^[=^~><]+", "", version_string.strip())
|
clean_version = re.sub(r"^[=^~><]+", "", version_string.strip())
|
||||||
|
|
||||||
@@ -148,7 +150,9 @@ def bootstrap(): # noqa: PLR0912, PLR0915
|
|||||||
elif key == "ALFRED_VERSION":
|
elif key == "ALFRED_VERSION":
|
||||||
if existing_env.get(key) != alfred_version:
|
if existing_env.get(key) != alfred_version:
|
||||||
new_lines.append(f"{key}={alfred_version}\n")
|
new_lines.append(f"{key}={alfred_version}\n")
|
||||||
print(f" ↻ Updated Alfred version: {existing_env.get(key, 'N/A')} → {alfred_version}")
|
print(
|
||||||
|
f" ↻ Updated Alfred version: {existing_env.get(key, 'N/A')} → {alfred_version}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
new_lines.append(f"{key}={alfred_version}\n")
|
new_lines.append(f"{key}={alfred_version}\n")
|
||||||
print(f" ↻ Kept Alfred version: {alfred_version}")
|
print(f" ↻ Kept Alfred version: {alfred_version}")
|
||||||
@@ -224,20 +228,10 @@ def bootstrap(): # noqa: PLR0912, PLR0915
|
|||||||
f.writelines(new_lines)
|
f.writelines(new_lines)
|
||||||
print(f"\n✅ {env_path.name} generated successfully.")
|
print(f"\n✅ {env_path.name} generated successfully.")
|
||||||
|
|
||||||
# Generate .env.make for Makefile
|
# Generate .env.make for Makefile using shared config loader
|
||||||
env_make_path = base_dir / ".env.make"
|
config = load_build_config(base_dir)
|
||||||
with open(env_make_path, "w", encoding="utf-8") as f:
|
write_env_make(config, base_dir)
|
||||||
f.write("# Auto-generated from pyproject.toml by bootstrap.py\n")
|
print("✅ .env.make generated for Makefile.")
|
||||||
f.write(f"export ALFRED_VERSION={alfred_version}\n")
|
|
||||||
f.write(f"export PYTHON_VERSION={python_version_full}\n")
|
|
||||||
f.write(f"export PYTHON_VERSION_SHORT={python_version_short}\n")
|
|
||||||
f.write(f"export RUNNER={settings_keys['runner']}\n")
|
|
||||||
f.write(f"export IMAGE_NAME={settings_keys['image_name']}\n")
|
|
||||||
f.write(f"export SERVICE_NAME={settings_keys['service_name']}\n")
|
|
||||||
f.write(f"export LIBRECHAT_VERSION={settings_keys['librechat_version']}\n")
|
|
||||||
f.write(f"export RAG_VERSION={settings_keys['rag_version']}\n")
|
|
||||||
|
|
||||||
print(f"✅ {env_make_path.name} generated for Makefile.")
|
|
||||||
print("\n⚠️ Reminder: Please manually add your API keys to the .env file.")
|
print("\n⚠️ Reminder: Please manually add your API keys to the .env file.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Shared configuration loader for bootstrap and CI."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
class BuildConfig(NamedTuple):
|
||||||
|
"""Build configuration extracted from pyproject.toml."""
|
||||||
|
|
||||||
|
alfred_version: str
|
||||||
|
python_version: str
|
||||||
|
python_version_short: str
|
||||||
|
runner: str
|
||||||
|
image_name: str
|
||||||
|
service_name: str
|
||||||
|
librechat_version: str
|
||||||
|
rag_version: str
|
||||||
|
|
||||||
|
|
||||||
|
def extract_python_version(version_string: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Extract Python version from poetry dependency string.
|
||||||
|
Examples:
|
||||||
|
"==3.14.2" -> ("3.14.2", "3.14")
|
||||||
|
"^3.14.2" -> ("3.14.2", "3.14")
|
||||||
|
"~3.14.2" -> ("3.14.2", "3.14")
|
||||||
|
"3.14.2" -> ("3.14.2", "3.14")
|
||||||
|
"""
|
||||||
|
clean_version = re.sub(r"^[=^~><]+", "", version_string.strip())
|
||||||
|
parts = clean_version.split(".")
|
||||||
|
|
||||||
|
if len(parts) >= 2:
|
||||||
|
full_version = clean_version
|
||||||
|
short_version = f"{parts[0]}.{parts[1]}"
|
||||||
|
return full_version, short_version
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Invalid Python version format: {version_string}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_build_config(base_dir: Path | None = None) -> BuildConfig:
|
||||||
|
"""Load build configuration from pyproject.toml."""
|
||||||
|
if base_dir is None:
|
||||||
|
base_dir = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
toml_path = base_dir / "pyproject.toml"
|
||||||
|
if not toml_path.exists():
|
||||||
|
raise FileNotFoundError(f"pyproject.toml not found: {toml_path}")
|
||||||
|
|
||||||
|
with open(toml_path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
settings_keys = data["tool"]["alfred"]["settings"]
|
||||||
|
dependencies = data["tool"]["poetry"]["dependencies"]
|
||||||
|
alfred_version = data["tool"]["poetry"]["version"]
|
||||||
|
|
||||||
|
python_version_full, python_version_short = extract_python_version(
|
||||||
|
dependencies["python"]
|
||||||
|
)
|
||||||
|
|
||||||
|
return BuildConfig(
|
||||||
|
alfred_version=alfred_version,
|
||||||
|
python_version=python_version_full,
|
||||||
|
python_version_short=python_version_short,
|
||||||
|
runner=settings_keys["runner"],
|
||||||
|
image_name=settings_keys["image_name"],
|
||||||
|
service_name=settings_keys["service_name"],
|
||||||
|
librechat_version=settings_keys["librechat_version"],
|
||||||
|
rag_version=settings_keys["rag_version"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env_make(config: BuildConfig, base_dir: Path | None = None) -> None:
|
||||||
|
"""Write .env.make file for Makefile."""
|
||||||
|
if base_dir is None:
|
||||||
|
base_dir = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
env_make_path = base_dir / ".env.make"
|
||||||
|
with open(env_make_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write("# Auto-generated from pyproject.toml\n")
|
||||||
|
f.write(f"export ALFRED_VERSION={config.alfred_version}\n")
|
||||||
|
f.write(f"export PYTHON_VERSION={config.python_version}\n")
|
||||||
|
f.write(f"export PYTHON_VERSION_SHORT={config.python_version_short}\n")
|
||||||
|
f.write(f"export RUNNER={config.runner}\n")
|
||||||
|
f.write(f"export IMAGE_NAME={config.image_name}\n")
|
||||||
|
f.write(f"export SERVICE_NAME={config.service_name}\n")
|
||||||
|
f.write(f"export LIBRECHAT_VERSION={config.librechat_version}\n")
|
||||||
|
f.write(f"export RAG_VERSION={config.rag_version}\n")
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate .env.make for CI/CD without generating secrets."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from config_loader import load_build_config, write_env_make
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Generate .env.make from pyproject.toml."""
|
||||||
|
try:
|
||||||
|
config = load_build_config()
|
||||||
|
write_env_make(config)
|
||||||
|
print("✅ .env.make generated successfully.")
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Failed to generate .env.make: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user