diff --git a/.env.example b/.env.example index 32db166..1f01495 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,6 @@ OPENAI_API_KEY=sk-your-openai-key # Default from config.yaml: /var/lib/foundrygate/foundrygate.db # Example for local non-root runs: # FOUNDRYGATE_DB_PATH=/home/you/.local/state/foundrygate/foundrygate.db + +# Optional explicit config override for wrappers, services, and packaged installs: +# FOUNDRYGATE_CONFIG_FILE=/home/you/.config/foundrygate/config.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index a38642a..4c8a3c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to FoundryGate should be documented here. The format is intentionally lightweight and human-readable. Group entries by release and focus on user-visible behavior, operational changes, and compatibility notes. +## Unreleased + +### Added + +- Added a workstation operations guide for Linux, macOS, and Windows runtime layouts +- Added a macOS `launchd` LaunchAgent example for local workstation installs +- Added Windows PowerShell and Task Scheduler starter examples for local workstation installs +- Added platform-aware runtime helper scripts so macOS can use the same `foundrygate-install` / `start` / `stop` / `status` flow style as Linux +- Added a project-owned Homebrew formula plus `brew services` guidance for packaged macOS workstation installs +- Added explicit `FOUNDRYGATE_CONFIG_FILE` config discovery and `foundrygate --config` / `--version` support so service wrappers and packaged installs can point to config outside the repo +- Added a helper-level onboarding smoke test for explicit config/env/python wiring + +### Changed + +- Updated the README quickstart so Linux, macOS, Windows, and Homebrew paths are visible earlier +- Replaced the weak PyPI workflow badge with clearer workstation and Homebrew badges + ## v1.1.0 - 2026-03-16 ### Added diff --git a/Formula/foundrygate.rb b/Formula/foundrygate.rb new file mode 100644 index 0000000..0c85377 --- /dev/null +++ b/Formula/foundrygate.rb @@ -0,0 +1,86 @@ +class Foundrygate < Formula + desc "Local OpenAI-compatible AI gateway for OpenClaw and other AI-native clients" + homepage "https://github.com/typelicious/FoundryGate" + url "https://github.com/typelicious/FoundryGate/archive/refs/tags/v1.1.0.tar.gz" + sha256 "08ee8e530f6ed4d631b85028557947ffebc7edf53bbe51fd58d279a547ede033" + license "Apache-2.0" + head "https://github.com/typelicious/FoundryGate.git", branch: "main" + + depends_on "python@3.13" + + def install + python = Formula["python@3.13"].opt_bin/"python3.13" + + system python, "-m", "venv", libexec + system libexec/"bin/pip", "install", "--upgrade", "pip", "setuptools", "wheel" + system libexec/"bin/pip", "install", buildpath + + pkgshare.install buildpath.children + + (bin/"foundrygate").write <<~SH + #!/bin/bash + set -euo pipefail + mkdir -p "#{etc}/foundrygate" "#{var}/lib/foundrygate" + export FOUNDRYGATE_CONFIG_FILE="${FOUNDRYGATE_CONFIG_FILE:-#{etc}/foundrygate/config.yaml}" + export FOUNDRYGATE_DB_PATH="${FOUNDRYGATE_DB_PATH:-#{var}/lib/foundrygate/foundrygate.db}" + cd "#{etc}/foundrygate" + exec "#{libexec}/bin/python" -m foundrygate.main "$@" + SH + + (bin/"foundrygate-stats").write <<~SH + #!/bin/bash + set -euo pipefail + export FOUNDRYGATE_CONFIG_FILE="${FOUNDRYGATE_CONFIG_FILE:-#{etc}/foundrygate/config.yaml}" + export FOUNDRYGATE_DB_PATH="${FOUNDRYGATE_DB_PATH:-#{var}/lib/foundrygate/foundrygate.db}" + cd "#{etc}/foundrygate" + exec "#{libexec}/bin/foundrygate-stats" "$@" + SH + + %w[ + foundrygate-doctor + foundrygate-health + foundrygate-onboarding-report + foundrygate-onboarding-validate + foundrygate-update-check + ].each do |helper| + (bin/helper).write <<~SH + #!/bin/bash + set -euo pipefail + mkdir -p "#{etc}/foundrygate" "#{var}/lib/foundrygate" + export FOUNDRYGATE_CONFIG_FILE="${FOUNDRYGATE_CONFIG_FILE:-#{etc}/foundrygate/config.yaml}" + export FOUNDRYGATE_ENV_FILE="${FOUNDRYGATE_ENV_FILE:-#{etc}/foundrygate/foundrygate.env}" + export FOUNDRYGATE_DB_PATH="${FOUNDRYGATE_DB_PATH:-#{var}/lib/foundrygate/foundrygate.db}" + export FOUNDRYGATE_PYTHON="#{libexec}/bin/python" + exec "#{pkgshare}/scripts/#{helper}" "$@" + SH + end + end + + def post_install + (etc/"foundrygate").mkpath + (var/"lib/foundrygate").mkpath + (var/"log/foundrygate").mkpath + + config_path = etc/"foundrygate/config.yaml" + env_path = etc/"foundrygate/foundrygate.env" + + config_path.write((pkgshare/"config.yaml").read) unless config_path.exist? + env_path.write((pkgshare/".env.example").read) unless env_path.exist? + end + + service do + run [opt_bin/"foundrygate"] + working_dir etc/"foundrygate" + environment_variables( + FOUNDRYGATE_CONFIG_FILE: etc/"foundrygate/config.yaml", + FOUNDRYGATE_DB_PATH: var/"lib/foundrygate/foundrygate.db", + ) + keep_alive true + log_path var/"log/foundrygate/output.log" + error_log_path var/"log/foundrygate/error.log" + end + + test do + assert_match version.to_s, shell_output("#{libexec}/bin/python -c 'import foundrygate; print(foundrygate.__version__)'") + end +end diff --git a/README.md b/README.md index 1d0f316..7fe4008 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,17 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](./LICENSE) [![OpenAI-compatible](https://img.shields.io/badge/OpenAI-compatible-0ea5e9.svg)](./docs/API.md) [![OpenClaw-friendly](https://img.shields.io/badge/OpenClaw-friendly-111827.svg)](https://openclaw.ai/) +[![Workstations](https://img.shields.io/badge/workstations-linux%20%7C%20macOS%20%7C%20windows-0f766e.svg)](./docs/WORKSTATIONS.md) +[![Homebrew](https://img.shields.io/badge/homebrew-formula-fbbf24?logo=homebrew&logoColor=black)](./Formula/foundrygate.rb) [![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](./Dockerfile) -[![PyPI](https://img.shields.io/badge/pypi-workflow%20ready-3775A9?logo=pypi&logoColor=white)](./RELEASES.md) [![Python](https://img.shields.io/badge/python-3.10%2B-blue.svg)](./pyproject.toml) Local OpenAI-compatible AI gateway for 🦞 [OpenClaw](https://openclaw.ai/) and other AI-native clients. FoundryGate gives OpenClaw, n8n, CLI tools, and custom apps one local endpoint and routes each request to the best configured provider or local worker. It keeps routing, fallback, onboarding, and operator visibility under your control instead of scattering provider logic across every client. +Runs locally on Linux, macOS, and Windows, with first-class workstation guidance for `systemd`, `launchd`, Task Scheduler, and Homebrew-driven macOS installs. + ## Quick Navigation - [Quickstart](#quickstart) @@ -39,6 +42,12 @@ FoundryGate gives OpenClaw, n8n, CLI tools, and custom apps one local endpoint a The fastest local path is the helper-driven bootstrap. +Platform quick starts: + +- Linux or generic source checkout: use the helper/bootstrap flow below, then `systemd` if you want a long-running service. +- macOS workstation: use the helper flow below or jump to [Homebrew](./docs/WORKSTATIONS.md#homebrew-on-macos) for `brew services`. +- Windows workstation: use the source checkout flow below, then the PowerShell and Task Scheduler examples in [docs/WORKSTATIONS.md](./docs/WORKSTATIONS.md). + ```bash git clone https://github.com/typelicious/FoundryGate.git foundrygate cd foundrygate @@ -68,6 +77,14 @@ Then use the onboarding helpers to move from “the server starts” to “real If you prefer a packaged or service-driven install, jump to [Deployment](#deployment) or the fuller [Operations guide](./docs/OPERATIONS.md). +Minimal Homebrew flow on macOS: + +```bash +brew tap typelicious/foundrygate https://github.com/typelicious/FoundryGate +brew install typelicious/foundrygate/foundrygate +brew services start typelicious/foundrygate/foundrygate +``` + ## How It Works ```text @@ -151,6 +168,8 @@ FoundryGate can stay small in development and still scale into a more repeatable - Local Python run: quickest path for development and testing. - `systemd` on Linux: recommended for long-running generic host installs. +- Workstation runtimes: macOS `launchd`, Linux `systemd`, and Windows task-scheduler style installs are documented separately. +- Homebrew path: a project-owned tap formula now lives under [`Formula/foundrygate.rb`](./Formula/foundrygate.rb) for macOS-oriented installs and `brew services`. - Docker and GHCR path: tagged releases build container artifacts through the release workflow. - Python package path: release workflows build `sdist` and `wheel`. - Separate npm CLI package: `packages/foundrygate-cli` gives CLI-facing environments a small Node entry point without changing the Python service runtime. @@ -159,6 +178,7 @@ Start here for the deeper deployment details: - [Configuration reference](./docs/CONFIGURATION.md) - [Operations guide](./docs/OPERATIONS.md) +- [Workstations guide](./docs/WORKSTATIONS.md) - [Publishing and release flow](./docs/PUBLISHING.md) ## More Resources @@ -168,9 +188,12 @@ Start here for the deeper deployment details: - [API reference](./docs/API.md) - [Configuration reference](./docs/CONFIGURATION.md) - [Operations guide](./docs/OPERATIONS.md) +- [Workstations guide](./docs/WORKSTATIONS.md) +- [Homebrew formula](./Formula/foundrygate.rb) - [Integrations](./docs/INTEGRATIONS.md) - [Onboarding](./docs/ONBOARDING.md) - [Examples](./docs/examples) +- [macOS LaunchAgent example](./docs/examples/com.typelicious.foundrygate.plist) - [OpenClaw integration starter](./openclaw-integration.jsonc) - [Full OpenClaw example](./docs/examples/openclaw-foundrygate-full.jsonc) - [Multi-provider stack example](./docs/examples/foundrygate-multi-provider-stack.yaml) diff --git a/RELEASES.md b/RELEASES.md index ac457c0..297d94d 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -16,6 +16,7 @@ This repo does not require a heavy release process. Use lightweight tags plus Gi 8. Confirm that README plus the relevant docs pages still match the shipped runtime behavior. 9. If packaging or Docker changed shortly before the release, run the publish dry run first. 10. For hardening-heavy releases, keep the API functional tests green alongside unit and config coverage. +11. If the Homebrew formula changed, bump [`Formula/foundrygate.rb`](./Formula/foundrygate.rb) to the new release tag and update its `sha256`. ## Example @@ -73,6 +74,8 @@ The repo also includes [publish-dry-run](./.github/workflows/publish-dry-run.yml The npm package stays separate from the Python gateway core. It is meant for CLI-facing integrations, not for rewriting the service runtime. +`v1.2.0` also starts the project-owned Homebrew path through [`Formula/foundrygate.rb`](./Formula/foundrygate.rb), intended for a dedicated tap or direct tap-by-URL workflow on macOS. + ## Scheduled Deployment Examples FoundryGate now includes a conservative helper-driven update path for controlled environments. The recommended examples live in: diff --git a/docs/FOUNDRYGATE-ROADMAP.md b/docs/FOUNDRYGATE-ROADMAP.md index a5310c1..16743b9 100644 --- a/docs/FOUNDRYGATE-ROADMAP.md +++ b/docs/FOUNDRYGATE-ROADMAP.md @@ -19,7 +19,25 @@ The foundation that used to be the near-term buildout is largely in place: This roadmap now shifts from "rename and foundation" to "deepen the gateway plane without bloating it". -`v1.0.0` is now shipped. The next block should stay disciplined: deepen AI-native client coverage, improve client-facing observability, and refine routing policy without turning FoundryGate into a sprawling platform. +`v1.1.0` is now shipped. The next block should stay disciplined: improve workstation operations, keep adoption friction low across macOS and Windows, and extend runtime packaging guidance without turning FoundryGate into a sprawling platform. + +## `v1.2.0`: workstation operations baseline + +Primary goals: + +- add a dedicated workstation operations guide +- document macOS `launchd` as a first-class local-runtime path +- document Windows Task Scheduler / PowerShell as the baseline Windows path +- keep development checkouts and runtime installs clearly separated +- add a project-owned Homebrew packaging path for macOS workstations + +Recommended minimal slices: + +1. workstation baseline docs and path layout +2. macOS `launchd` example and instructions +3. Windows startup examples and documentation +4. optional lightweight install helpers only if the docs prove insufficient +5. Homebrew formula and `brew services` guidance for the packaged macOS path ## Post-1.0 direction diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 0281775..46d21a6 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -31,6 +31,32 @@ Recommended persistent state path: That path is wired through `FOUNDRYGATE_DB_PATH`. +### Workstation Runtime Installs + +For workstation usage, keep the runtime install separate from the development checkout. + +Recommended baseline: + +- Linux: `systemd` or `systemd --user` +- macOS: `launchd` via `~/Library/LaunchAgents` +- Windows: Task Scheduler plus direct venv Python invocation + +See [WORKSTATIONS.md](./WORKSTATIONS.md) for the path layout and OS-specific runtime guidance. + +### Homebrew On macOS + +For macOS workstations, FoundryGate now also ships a project-owned formula under [`Formula/foundrygate.rb`](../Formula/foundrygate.rb). + +Typical flow: + +```bash +brew tap typelicious/foundrygate https://github.com/typelicious/FoundryGate +brew install typelicious/foundrygate/foundrygate +brew services start typelicious/foundrygate/foundrygate +``` + +That path keeps config under `$(brew --prefix)/etc/foundrygate`, state under `$(brew --prefix)/var/lib/foundrygate`, and logs under `$(brew --prefix)/var/log/foundrygate`. + ### Docker / GHCR Tagged releases build container artifacts through the release workflow. For local validation you can build from the repo root: @@ -56,6 +82,12 @@ That package is intentionally separate from the Python gateway runtime. FoundryGate ships optional wrappers around `systemd`, `journalctl`, `curl`, onboarding checks, and release-update flows. +The runtime-control helpers now auto-detect Linux vs macOS: + +- on Linux they continue to use `systemd` +- on macOS they manage the shipped `launchd` LaunchAgent +- Windows remains documentation/example-driven for now + | Script | What it does | | --- | --- | | `foundrygate-install` | install service + helper links | @@ -110,6 +142,9 @@ The repo ships example schedules under [`docs/examples`](./examples): - `foundrygate-auto-update.service` - `foundrygate-auto-update.timer` - `foundrygate-auto-update.cron` +- `com.typelicious.foundrygate.plist` +- `foundrygate-start.ps1` +- `foundrygate-task-scheduler.xml` Use them only after the manual update path is already validated. diff --git a/docs/WORKSTATIONS.md b/docs/WORKSTATIONS.md new file mode 100644 index 0000000..52b9ac7 --- /dev/null +++ b/docs/WORKSTATIONS.md @@ -0,0 +1,198 @@ +# FoundryGate Workstations + +FoundryGate works best when the development checkout and the runtime install are kept separate. + +Recommended shape: + +- one checkout for active development +- one separate runtime install for OpenClaw, opencode, CLI tools, and local workflows +- one config directory outside the repo +- one writable state directory outside the repo + +This keeps local usage stable while `main` and feature branches continue to move. + +## General layout + +Use one of these patterns: + +### User-local runtime + +Good for a single workstation user: + +- checkout: `~/services/foundrygate` +- config: `~/.config/foundrygate` +- state: `~/.local/state/foundrygate` + +### System-style runtime + +Good for a more shared or host-like install: + +- checkout: `/opt/foundrygate` +- config: `/etc/foundrygate` +- state: `/var/lib/foundrygate` + +## Linux + +Recommended baseline: + +- runtime checkout under `~/services/foundrygate` or `/opt/foundrygate` +- config under `~/.config/foundrygate` or `/etc/foundrygate` +- DB under `~/.local/state/foundrygate/foundrygate.db` or `/var/lib/foundrygate/foundrygate.db` +- service manager: `systemd` + +Typical start command: + +```bash +FOUNDRYGATE_DB_PATH="$HOME/.local/state/foundrygate/foundrygate.db" \ +python -m foundrygate --config "$HOME/.config/foundrygate/config.yaml" +``` + +For long-running user sessions, prefer a `systemd --user` unit or a normal system service. + +## macOS + +Recommended baseline: + +- runtime checkout: `~/services/foundrygate` +- config: `~/Library/Application Support/FoundryGate` +- state: `~/Library/Application Support/FoundryGate/foundrygate.db` +- service manager: `launchd` via `~/Library/LaunchAgents` + +The repo now ships a starter plist: + +- [examples/com.typelicious.foundrygate.plist](./examples/com.typelicious.foundrygate.plist) +- [`Formula/foundrygate.rb`](../Formula/foundrygate.rb) + +The standard helper scripts now understand macOS directly: + +- `./scripts/foundrygate-install` +- `./scripts/foundrygate-start` +- `./scripts/foundrygate-stop` +- `./scripts/foundrygate-restart` +- `./scripts/foundrygate-status` +- `./scripts/foundrygate-logs` + +Suggested local layout: + +```text +~/services/foundrygate +~/Library/Application Support/FoundryGate/config.yaml +~/Library/Application Support/FoundryGate/foundrygate.env +~/Library/Application Support/FoundryGate/foundrygate.db +``` + +Install flow: + +```bash +mkdir -p "$HOME/Library/Application Support/FoundryGate" +cp docs/examples/com.typelicious.foundrygate.plist "$HOME/Library/LaunchAgents/" +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.typelicious.foundrygate.plist" +launchctl kickstart -k "gui/$(id -u)/com.typelicious.foundrygate" +``` + +Use `launchctl print "gui/$(id -u)/com.typelicious.foundrygate"` to inspect the loaded job. + +### Homebrew on macOS + +If you prefer a packaged macOS path, FoundryGate now ships a project-owned Homebrew formula: + +- [`Formula/foundrygate.rb`](../Formula/foundrygate.rb) + +Typical flow: + +```bash +brew tap typelicious/foundrygate https://github.com/typelicious/FoundryGate +brew install typelicious/foundrygate/foundrygate +$EDITOR "$(brew --prefix)/etc/foundrygate/config.yaml" +$EDITOR "$(brew --prefix)/etc/foundrygate/foundrygate.env" +brew services start typelicious/foundrygate/foundrygate +``` + +Useful paths for the formula-driven install: + +- config: `$(brew --prefix)/etc/foundrygate/config.yaml` +- env file: `$(brew --prefix)/etc/foundrygate/foundrygate.env` +- DB: `$(brew --prefix)/var/lib/foundrygate/foundrygate.db` +- logs: `$(brew --prefix)/var/log/foundrygate/` + +The formula is intentionally project-owned rather than targeted at `homebrew/core`. That keeps the Python-service packaging flexible and lets `brew services` manage the local `launchd` path cleanly. + +## Windows + +Recommended baseline: + +- runtime checkout: `%USERPROFILE%\\services\\foundrygate` +- config: `%APPDATA%\\FoundryGate` +- state: `%LOCALAPPDATA%\\FoundryGate\\foundrygate.db` +- process manager: Task Scheduler at logon, or a later service wrapper if needed + +Suggested local layout: + +```text +%USERPROFILE%\services\foundrygate +%APPDATA%\FoundryGate\config.yaml +%APPDATA%\FoundryGate\foundrygate.env +%LOCALAPPDATA%\FoundryGate\foundrygate.db +``` + +Use the venv Python directly instead of relying on shell activation: + +```powershell +$env:FOUNDRYGATE_DB_PATH="$env:LOCALAPPDATA\FoundryGate\foundrygate.db" +& "$env:USERPROFILE\services\foundrygate\.venv\Scripts\python.exe" -m foundrygate --config "$env:APPDATA\FoundryGate\config.yaml" +``` + +Task Scheduler is the recommended `v1.2.0`-targeted path. A native Windows service wrapper can come later if it proves necessary. + +The repo ships starter files for this path: + +- [examples/foundrygate-start.ps1](./examples/foundrygate-start.ps1) +- [examples/foundrygate-task-scheduler.xml](./examples/foundrygate-task-scheduler.xml) + +Suggested install flow: + +```powershell +New-Item -ItemType Directory -Force -Path "$env:APPDATA\FoundryGate" | Out-Null +Copy-Item ".\docs\examples\foundrygate-start.ps1" "$env:APPDATA\FoundryGate\foundrygate-start.ps1" +schtasks /Create /TN FoundryGate /XML ".\docs\examples\foundrygate-task-scheduler.xml" /F +``` + +If you want to avoid XML import, create one logon task that runs: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$env:APPDATA\FoundryGate\foundrygate-start.ps1" +``` + +## Config and state placement + +Keep these out of the repo checkout: + +- `.env` +- `config.yaml` for the live runtime +- the SQLite DB +- generated logs + +This keeps upgrades, worktrees, and branch switches from colliding with the running gateway. + +## Runtime vs development checkout + +Do not run day-to-day client traffic from the same checkout you are actively editing. + +Preferred workflow: + +1. develop in your main repo or Codex worktree +2. run FoundryGate for clients from a stable runtime checkout +3. upgrade that runtime checkout intentionally, ideally from tags or reviewed `main` + +## Suggested upgrade path + +For workstation installs: + +1. keep the runtime checkout pinned to a release tag or known-good `main` commit +2. store config and DB outside the checkout +3. stop the service +4. update the checkout +5. run one manual health check +6. start the service again + +This keeps local OpenClaw, opencode, and CLI tooling stable while development continues elsewhere. diff --git a/docs/examples/com.typelicious.foundrygate.plist b/docs/examples/com.typelicious.foundrygate.plist new file mode 100644 index 0000000..62c10ec --- /dev/null +++ b/docs/examples/com.typelicious.foundrygate.plist @@ -0,0 +1,43 @@ + + + + + Label + com.typelicious.foundrygate + + WorkingDirectory + /Users/REPLACE_ME/services/foundrygate + + ProgramArguments + + /Users/REPLACE_ME/services/foundrygate/.venv/bin/python + -m + foundrygate + --config + /Users/REPLACE_ME/Library/Application Support/FoundryGate/config.yaml + + + EnvironmentVariables + + FOUNDRYGATE_DB_PATH + /Users/REPLACE_ME/Library/Application Support/FoundryGate/foundrygate.db + PYTHONUNBUFFERED + 1 + + + RunAtLoad + + + KeepAlive + + SuccessfulExit + + + + StandardOutPath + /Users/REPLACE_ME/Library/Logs/FoundryGate/stdout.log + + StandardErrorPath + /Users/REPLACE_ME/Library/Logs/FoundryGate/stderr.log + + diff --git a/docs/examples/foundrygate-start.ps1 b/docs/examples/foundrygate-start.ps1 new file mode 100644 index 0000000..be4c165 --- /dev/null +++ b/docs/examples/foundrygate-start.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference = "Stop" + +$RepoRoot = Join-Path $env:USERPROFILE "services\foundrygate" +$ConfigDir = Join-Path $env:APPDATA "FoundryGate" +$StateDir = Join-Path $env:LOCALAPPDATA "FoundryGate" +$PythonExe = Join-Path $RepoRoot ".venv\Scripts\python.exe" +$ConfigPath = Join-Path $ConfigDir "config.yaml" +$EnvPath = Join-Path $ConfigDir "foundrygate.env" +$DbPath = Join-Path $StateDir "foundrygate.db" + +New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null +New-Item -ItemType Directory -Force -Path $StateDir | Out-Null + +if (Test-Path $EnvPath) { + Get-Content $EnvPath | ForEach-Object { + if ($_ -match '^\s*#' -or $_ -match '^\s*$') { + return + } + $parts = $_ -split '=', 2 + if ($parts.Length -eq 2) { + [System.Environment]::SetEnvironmentVariable($parts[0], $parts[1]) + } + } +} + +$env:FOUNDRYGATE_DB_PATH = $DbPath + +& $PythonExe -m foundrygate --config $ConfigPath diff --git a/docs/examples/foundrygate-task-scheduler.xml b/docs/examples/foundrygate-task-scheduler.xml new file mode 100644 index 0000000..304d619 --- /dev/null +++ b/docs/examples/foundrygate-task-scheduler.xml @@ -0,0 +1,45 @@ + + + + Start FoundryGate at user logon + \FoundryGate + + + + true + + + + + InteractiveToken + LeastPrivilege + + + + IgnoreNew + false + false + true + true + false + + false + false + + true + true + false + false + false + true + false + PT0S + 7 + + + + powershell.exe + -NoProfile -ExecutionPolicy Bypass -File "%APPDATA%\FoundryGate\foundrygate-start.ps1" + + + diff --git a/foundrygate/config.py b/foundrygate/config.py index eaa8f5d..872a897 100644 --- a/foundrygate/config.py +++ b/foundrygate/config.py @@ -1245,6 +1245,13 @@ def load_config(path: str | Path | None = None) -> Config: """Load config.yaml, expand env vars, return Config object.""" load_dotenv() + if path is None: + env_path = os.environ.get("FOUNDRYGATE_CONFIG_FILE") or os.environ.get( + "FOUNDRYGATE_CONFIG_PATH" + ) + if env_path: + path = env_path + if path is None: # Look next to the package, then cwd candidates = [ diff --git a/foundrygate/main.py b/foundrygate/main.py index 85e1972..7f1cee0 100644 --- a/foundrygate/main.py +++ b/foundrygate/main.py @@ -8,8 +8,10 @@ from __future__ import annotations +import argparse import json import logging +import os import re import time from base64 import b64encode @@ -1564,6 +1566,23 @@ def main(): """Run with: python -m foundrygate""" import uvicorn + parser = argparse.ArgumentParser( + prog="foundrygate", + description="Run the FoundryGate gateway service.", + ) + parser.add_argument( + "--config", + help="Path to config.yaml. Also accepted via FOUNDRYGATE_CONFIG_FILE.", + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + ) + args = parser.parse_args() + if args.config: + os.environ["FOUNDRYGATE_CONFIG_FILE"] = args.config + config = load_config() uvicorn.run( "foundrygate.main:app", diff --git a/scripts/foundrygate-auto-update b/scripts/foundrygate-auto-update index 4328ea6..6a31f38 100755 --- a/scripts/foundrygate-auto-update +++ b/scripts/foundrygate-auto-update @@ -3,6 +3,7 @@ set -euo pipefail api_url="${FOUNDRYGATE_UPDATE_API_URL:-http://127.0.0.1:8090/api/update?force=true}" mode="${1:-}" +python_bin="${FOUNDRYGATE_PYTHON:-python3}" operator_action="auto-update-check" if [ "$mode" = "--apply" ]; then operator_action="auto-update-apply" @@ -21,7 +22,7 @@ fi export FOUNDRYGATE_UPDATE_PAYLOAD="$payload" mapfile -t parsed < <( -python3 - <<'PY' +"$python_bin" - <<'PY' import json import os @@ -86,7 +87,7 @@ if [ "$mode" = "--apply" ]; then echo "Running post-update verification via: ${verify_command}" export FOUNDRYGATE_VERIFY_COMMAND="$verify_command" export FOUNDRYGATE_VERIFY_TIMEOUT="$verify_timeout" - if ! python3 - <<'PY' + if ! "$python_bin" - <<'PY' import os import subprocess import sys diff --git a/scripts/foundrygate-doctor b/scripts/foundrygate-doctor index 8237ce1..f9c8b77 100755 --- a/scripts/foundrygate-doctor +++ b/scripts/foundrygate-doctor @@ -4,6 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" env_file="${FOUNDRYGATE_ENV_FILE:-$repo_root/.env}" config_file="${FOUNDRYGATE_CONFIG_FILE:-$repo_root/config.yaml}" +python_bin="${FOUNDRYGATE_PYTHON:-python3}" status=0 export FOUNDRYGATE_ENV_FILE="$env_file" @@ -57,7 +58,7 @@ else warn "no provider API key detected in $env_file" fi -python3 - <<'PY' +"$python_bin" - <<'PY' import os from foundrygate.onboarding import collect_provider_env_requirements diff --git a/scripts/foundrygate-install b/scripts/foundrygate-install index abd45cf..85be61a 100755 --- a/scripts/foundrygate-install +++ b/scripts/foundrygate-install @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/foundrygate-service-lib.sh" + +repo_root="$(foundrygate_repo_root)" helpers=( foundrygate-bootstrap foundrygate-doctor @@ -20,17 +22,36 @@ helpers=( foundrygate-uninstall ) -if ! id -u foundrygate >/dev/null 2>&1; then - sudo useradd --system --home /opt/foundrygate --shell /usr/sbin/nologin foundrygate -fi +case "$(foundrygate_platform)" in + Darwin) + mkdir -p "$(foundrygate_mac_config_dir)" "$(foundrygate_mac_logs_dir)" + if [ ! -f "$(foundrygate_mac_config_path)" ]; then + cp "$repo_root/config.yaml" "$(foundrygate_mac_config_path)" + echo "copied config template to: $(foundrygate_mac_config_path)" + fi + if [ ! -f "$(foundrygate_mac_config_dir)/foundrygate.env" ]; then + cp "$repo_root/.env.example" "$(foundrygate_mac_config_dir)/foundrygate.env" + echo "copied env template to: $(foundrygate_mac_config_dir)/foundrygate.env" + fi + foundrygate_render_mac_plist + foundrygate_install_helper_links "${helpers[@]}" + foundrygate_launchctl_start + foundrygate_launchctl_status | sed -n "1,120p" + ;; + *) + if ! id -u foundrygate >/dev/null 2>&1; then + sudo useradd --system --home /opt/foundrygate --shell /usr/sbin/nologin foundrygate + fi -sudo install -d -o foundrygate -g foundrygate -m 755 /var/lib/foundrygate -sudo install -m 644 "$repo_root/foundrygate.service" /etc/systemd/system/foundrygate.service + sudo install -d -o foundrygate -g foundrygate -m 755 /var/lib/foundrygate + sudo install -m 644 "$repo_root/foundrygate.service" /etc/systemd/system/foundrygate.service -for helper in "${helpers[@]}"; do - sudo ln -sf "$repo_root/scripts/$helper" "/usr/local/bin/$helper" -done + for helper in "${helpers[@]}"; do + sudo ln -sf "$repo_root/scripts/$helper" "/usr/local/bin/$helper" + done -sudo systemctl daemon-reload -sudo systemctl enable --now foundrygate.service -sudo systemctl status foundrygate.service --no-pager -l | sed -n "1,120p" + sudo systemctl daemon-reload + sudo systemctl enable --now foundrygate.service + sudo systemctl status foundrygate.service --no-pager -l | sed -n "1,120p" + ;; +esac diff --git a/scripts/foundrygate-logs b/scripts/foundrygate-logs index 819db4b..e58e3da 100755 --- a/scripts/foundrygate-logs +++ b/scripts/foundrygate-logs @@ -1,3 +1,15 @@ #!/usr/bin/env bash set -euo pipefail -sudo journalctl -u foundrygate.service -f -o cat + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/foundrygate-service-lib.sh" + +case "$(foundrygate_platform)" in + Darwin) + mkdir -p "$(foundrygate_mac_logs_dir)" + touch "$(foundrygate_mac_logs_dir)/stdout.log" "$(foundrygate_mac_logs_dir)/stderr.log" + tail -f "$(foundrygate_mac_logs_dir)/stdout.log" "$(foundrygate_mac_logs_dir)/stderr.log" + ;; + *) + sudo journalctl -u foundrygate.service -f -o cat + ;; +esac diff --git a/scripts/foundrygate-onboarding-report b/scripts/foundrygate-onboarding-report index 623b447..453adb3 100644 --- a/scripts/foundrygate-onboarding-report +++ b/scripts/foundrygate-onboarding-report @@ -5,12 +5,13 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" config_file="${FOUNDRYGATE_CONFIG_FILE:-$repo_root/config.yaml}" env_file="${FOUNDRYGATE_ENV_FILE:-$repo_root/.env}" mode="${1:-text}" +python_bin="${FOUNDRYGATE_PYTHON:-python3}" export FOUNDRYGATE_ONBOARDING_CONFIG="$config_file" export FOUNDRYGATE_ONBOARDING_ENV="$env_file" export FOUNDRYGATE_ONBOARDING_MODE="$mode" -python3 - <<'PY' +"$python_bin" - <<'PY' import json import os diff --git a/scripts/foundrygate-onboarding-validate b/scripts/foundrygate-onboarding-validate index f1ab83b..103e3b4 100644 --- a/scripts/foundrygate-onboarding-validate +++ b/scripts/foundrygate-onboarding-validate @@ -5,12 +5,13 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" config_file="${FOUNDRYGATE_CONFIG_FILE:-$repo_root/config.yaml}" env_file="${FOUNDRYGATE_ENV_FILE:-$repo_root/.env}" mode="${1:-text}" +python_bin="${FOUNDRYGATE_PYTHON:-python3}" export FOUNDRYGATE_ONBOARDING_CONFIG="$config_file" export FOUNDRYGATE_ONBOARDING_ENV="$env_file" export FOUNDRYGATE_ONBOARDING_MODE="$mode" -python3 - <<'PY' +"$python_bin" - <<'PY' import json import os import sys diff --git a/scripts/foundrygate-restart b/scripts/foundrygate-restart index 5569ce6..457db4b 100755 --- a/scripts/foundrygate-restart +++ b/scripts/foundrygate-restart @@ -1,3 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -sudo systemctl restart foundrygate.service + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/foundrygate-service-lib.sh" + +case "$(foundrygate_platform)" in + Darwin) + foundrygate_launchctl_start + ;; + *) + sudo systemctl restart foundrygate.service + ;; +esac diff --git a/scripts/foundrygate-service-lib.sh b/scripts/foundrygate-service-lib.sh new file mode 100644 index 0000000..b09ab00 --- /dev/null +++ b/scripts/foundrygate-service-lib.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail + +foundrygate_repo_root() { + cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd +} + +foundrygate_platform() { + uname -s +} + +foundrygate_mac_label() { + printf '%s\n' "com.typelicious.foundrygate" +} + +foundrygate_mac_gui_domain() { + printf 'gui/%s\n' "$(id -u)" +} + +foundrygate_mac_agent_dir() { + printf '%s\n' "${FOUNDRYGATE_MAC_AGENT_DIR:-$HOME/Library/LaunchAgents}" +} + +foundrygate_mac_plist_path() { + printf '%s/%s.plist\n' "$(foundrygate_mac_agent_dir)" "$(foundrygate_mac_label)" +} + +foundrygate_mac_config_dir() { + printf '%s\n' "${FOUNDRYGATE_MAC_CONFIG_DIR:-$HOME/Library/Application Support/FoundryGate}" +} + +foundrygate_mac_logs_dir() { + printf '%s\n' "${FOUNDRYGATE_MAC_LOGS_DIR:-$HOME/Library/Logs/FoundryGate}" +} + +foundrygate_mac_db_path() { + printf '%s/foundrygate.db\n' "$(foundrygate_mac_config_dir)" +} + +foundrygate_mac_python() { + printf '%s/.venv/bin/python\n' "$(foundrygate_repo_root)" +} + +foundrygate_mac_config_path() { + printf '%s/config.yaml\n' "$(foundrygate_mac_config_dir)" +} + +foundrygate_bin_dir() { + case "$(foundrygate_platform)" in + Darwin) + printf '%s\n' "${FOUNDRYGATE_BIN_DIR:-$HOME/.local/bin}" + ;; + *) + printf '%s\n' "${FOUNDRYGATE_BIN_DIR:-/usr/local/bin}" + ;; + esac +} + +foundrygate_install_helper_links() { + local repo_root target_bin helper + repo_root="$(foundrygate_repo_root)" + target_bin="$(foundrygate_bin_dir)" + mkdir -p "$target_bin" + for helper in "$@"; do + ln -sf "$repo_root/scripts/$helper" "$target_bin/$helper" + done + printf 'helper links installed in: %s\n' "$target_bin" +} + +foundrygate_remove_helper_links() { + local target_bin helper + target_bin="$(foundrygate_bin_dir)" + for helper in "$@"; do + rm -f "$target_bin/$helper" + done + printf 'removed helper links from: %s\n' "$target_bin" +} + +foundrygate_render_mac_plist() { + local repo_root template target config_dir logs_dir + repo_root="$(foundrygate_repo_root)" + template="$repo_root/docs/examples/com.typelicious.foundrygate.plist" + target="$(foundrygate_mac_plist_path)" + config_dir="$(foundrygate_mac_config_dir)" + logs_dir="$(foundrygate_mac_logs_dir)" + mkdir -p "$(foundrygate_mac_agent_dir)" "$config_dir" "$logs_dir" + sed "s|/Users/REPLACE_ME|$HOME|g" "$template" >"$target" + printf 'launchd plist written to: %s\n' "$target" +} + +foundrygate_launchctl_bootout() { + local domain label plist + domain="$(foundrygate_mac_gui_domain)" + label="$(foundrygate_mac_label)" + plist="$(foundrygate_mac_plist_path)" + launchctl bootout "$domain/$label" >/dev/null 2>&1 || launchctl bootout "$domain" "$plist" >/dev/null 2>&1 || true +} + +foundrygate_launchctl_start() { + local domain label plist + domain="$(foundrygate_mac_gui_domain)" + label="$(foundrygate_mac_label)" + plist="$(foundrygate_mac_plist_path)" + foundrygate_launchctl_bootout + launchctl bootstrap "$domain" "$plist" + launchctl kickstart -k "$domain/$label" +} + +foundrygate_launchctl_stop() { + foundrygate_launchctl_bootout +} + +foundrygate_launchctl_status() { + local domain label + domain="$(foundrygate_mac_gui_domain)" + label="$(foundrygate_mac_label)" + launchctl print "$domain/$label" +} diff --git a/scripts/foundrygate-start b/scripts/foundrygate-start index 1c3298b..d731984 100755 --- a/scripts/foundrygate-start +++ b/scripts/foundrygate-start @@ -1,3 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -sudo systemctl start foundrygate.service + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/foundrygate-service-lib.sh" + +case "$(foundrygate_platform)" in + Darwin) + foundrygate_launchctl_start + ;; + *) + sudo systemctl start foundrygate.service + ;; +esac diff --git a/scripts/foundrygate-status b/scripts/foundrygate-status index 22178a4..e64b25a 100755 --- a/scripts/foundrygate-status +++ b/scripts/foundrygate-status @@ -1,5 +1,17 @@ #!/usr/bin/env bash set -euo pipefail -sudo systemctl status foundrygate.service --no-pager -l -echo -sudo ss -ltnp | grep -E "127\\.0\\.0\\.1:8090\\b" || true + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/foundrygate-service-lib.sh" + +case "$(foundrygate_platform)" in + Darwin) + foundrygate_launchctl_status + echo + lsof -nP -iTCP:8090 -sTCP:LISTEN || true + ;; + *) + sudo systemctl status foundrygate.service --no-pager -l + echo + sudo ss -ltnp | grep -E "127\\.0\\.0\\.1:8090\\b" || true + ;; +esac diff --git a/scripts/foundrygate-stop b/scripts/foundrygate-stop index 604ff8d..bf56828 100755 --- a/scripts/foundrygate-stop +++ b/scripts/foundrygate-stop @@ -1,3 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -sudo systemctl stop foundrygate.service + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/foundrygate-service-lib.sh" + +case "$(foundrygate_platform)" in + Darwin) + foundrygate_launchctl_stop + ;; + *) + sudo systemctl stop foundrygate.service + ;; +esac diff --git a/scripts/foundrygate-uninstall b/scripts/foundrygate-uninstall index b646b89..48f3347 100755 --- a/scripts/foundrygate-uninstall +++ b/scripts/foundrygate-uninstall @@ -1,7 +1,12 @@ #!/usr/bin/env bash set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/foundrygate-service-lib.sh" + helpers=( foundrygate-install + foundrygate-bootstrap + foundrygate-doctor foundrygate-onboarding-report foundrygate-onboarding-validate foundrygate-start @@ -15,11 +20,21 @@ helpers=( foundrygate-uninstall ) -sudo systemctl disable --now foundrygate.service || true -sudo rm -f /etc/systemd/system/foundrygate.service -for helper in "${helpers[@]}"; do - sudo rm -f "/usr/local/bin/$helper" -done -sudo systemctl daemon-reload -echo "removed: /etc/systemd/system/foundrygate.service" -echo "removed helper links from: /usr/local/bin" +case "$(foundrygate_platform)" in + Darwin) + foundrygate_launchctl_stop + rm -f "$(foundrygate_mac_plist_path)" + foundrygate_remove_helper_links "${helpers[@]}" + echo "removed: $(foundrygate_mac_plist_path)" + ;; + *) + sudo systemctl disable --now foundrygate.service || true + sudo rm -f /etc/systemd/system/foundrygate.service + for helper in "${helpers[@]}"; do + sudo rm -f "/usr/local/bin/$helper" + done + sudo systemctl daemon-reload + echo "removed: /etc/systemd/system/foundrygate.service" + echo "removed helper links from: /usr/local/bin" + ;; +esac diff --git a/tests/test_config.py b/tests/test_config.py index def29d1..afa2d77 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -75,6 +75,30 @@ def test_metrics_db_path_uses_env_override(monkeypatch): assert cfg.metrics["db_path"] == "/var/lib/foundrygate/test.db" +def test_load_config_uses_explicit_config_env_file(tmp_path, monkeypatch): + path = tmp_path / "custom-config.yaml" + path.write_text( + """ +server: + host: "127.0.0.1" + port: 9001 +providers: + cloud-default: + backend: openai-compat + base_url: "https://api.example.com/v1" + api_key: "secret" + model: "chat-model" +fallback_chain: [] +metrics: + enabled: false +""" + ) + + monkeypatch.setenv("FOUNDRYGATE_CONFIG_FILE", str(path)) + cfg = load_config() + assert cfg.server["port"] == 9001 + + def test_metrics_db_path_never_dot_slash(monkeypatch): """cfg.metrics['db_path'] must never start with './' regardless of config.yaml content.""" monkeypatch.delenv("FOUNDRYGATE_DB_PATH", raising=False) diff --git a/tests/test_main_cli.py b/tests/test_main_cli.py new file mode 100644 index 0000000..fb40db9 --- /dev/null +++ b/tests/test_main_cli.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import pytest + +import foundrygate.main as main_module + + +def test_main_uses_explicit_config_arg(monkeypatch): + captured: dict[str, object] = {} + + def _fake_load_config(): + class _Cfg: + server = {"host": "127.0.0.1", "port": 9011, "log_level": "warning"} + + captured["config_env"] = main_module.os.environ.get("FOUNDRYGATE_CONFIG_FILE") + return _Cfg() + + def _fake_uvicorn_run(app, **kwargs): + captured["app"] = app + captured["kwargs"] = kwargs + + monkeypatch.setattr(main_module, "load_config", _fake_load_config) + monkeypatch.setattr("uvicorn.run", _fake_uvicorn_run) + monkeypatch.setattr(main_module, "__version__", "1.1.0-test") + monkeypatch.setattr( + main_module.argparse.ArgumentParser, + "parse_args", + lambda self: type("Args", (), {"config": "/tmp/foundrygate-config.yaml"})(), + ) + + main_module.main() + + assert captured["config_env"] == "/tmp/foundrygate-config.yaml" + assert captured["app"] == "foundrygate.main:app" + assert captured["kwargs"] == { + "host": "127.0.0.1", + "port": 9011, + "log_level": "warning", + "reload": False, + } + + +def test_main_supports_version_flag(monkeypatch, capsys): + monkeypatch.setattr(main_module, "__version__", "1.2.3") + + parser_parse_args = main_module.argparse.ArgumentParser.parse_args + + def _parse_args(self): + return parser_parse_args(self, ["--version"]) + + monkeypatch.setattr(main_module.argparse.ArgumentParser, "parse_args", _parse_args) + + with pytest.raises(SystemExit) as exc: + main_module.main() + + assert exc.value.code == 0 + assert "1.2.3" in capsys.readouterr().out diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index f51d7f4..a8663be 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -1,5 +1,9 @@ from __future__ import annotations +import json +import os +import subprocess +import sys from pathlib import Path from foundrygate.onboarding import ( @@ -224,6 +228,65 @@ def test_onboarding_validation_passes_for_ready_multi_provider_setup(tmp_path: P assert validation["blockers"] == [] +def test_onboarding_report_helper_supports_explicit_python_and_config_env(tmp_path: Path): + env_file = tmp_path / ".env" + env_file.write_text("DEEPSEEK_API_KEY=sk-demo\n", encoding="utf-8") + + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +fallback_chain: + - deepseek-chat +providers: + deepseek-chat: + backend: openai-compat + base_url: "https://api.deepseek.com/v1" + api_key: "${DEEPSEEK_API_KEY}" + model: "deepseek-chat" + tier: default +client_profiles: + enabled: true + default: generic + presets: ["openclaw"] + profiles: + generic: {} + rules: [] +routing_policies: + enabled: false + rules: [] +request_hooks: + enabled: false + hooks: [] +update_check: + enabled: false +auto_update: + enabled: false +""".strip(), + encoding="utf-8", + ) + + repo_root = Path(__file__).resolve().parent.parent + script = repo_root / "scripts" / "foundrygate-onboarding-report" + env = os.environ.copy() + env["FOUNDRYGATE_CONFIG_FILE"] = str(config_file) + env["FOUNDRYGATE_ENV_FILE"] = str(env_file) + env["FOUNDRYGATE_PYTHON"] = sys.executable + env["PYTHONPATH"] = str(repo_root) + + completed = subprocess.run( + ["bash", str(script), "--json"], + cwd=repo_root, + env=env, + check=True, + capture_output=True, + text=True, + ) + + report = json.loads(completed.stdout) + assert report["providers"]["ready"] == 1 + assert report["clients"]["presets"] == ["openclaw"] + + def test_onboarding_report_marks_all_builtin_integrations_ready(tmp_path: Path): env_file = tmp_path / ".env" env_file.write_text("DEEPSEEK_API_KEY=sk-demo\n", encoding="utf-8")