diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..541b67d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.12-slim AS builder + +ENV POETRY_VERSION=1.8.4 \ + POETRY_HOME=/opt/poetry \ + POETRY_VIRTUALENVS_IN_PROJECT=true \ + PIP_NO_CACHE_DIR=1 + +RUN pip install --no-cache-dir "poetry==${POETRY_VERSION}" + +WORKDIR /app +COPY pyproject.toml poetry.lock ./ +RUN /opt/poetry/bin/poetry install --only main --no-root + +COPY broker_sync ./broker_sync +RUN /opt/poetry/bin/poetry install --only main + + +FROM python:3.12-slim + +WORKDIR /app + +RUN useradd --system --uid 10001 --home /app --shell /usr/sbin/nologin broker && \ + mkdir -p /data && chown -R broker:broker /data + +COPY --from=builder --chown=broker:broker /app /app + +ENV PATH="/app/.venv/bin:${PATH}" \ + PYTHONUNBUFFERED=1 + +USER broker +ENTRYPOINT ["broker-sync"] +CMD ["version"] diff --git a/broker_sync/cli.py b/broker_sync/cli.py new file mode 100644 index 0000000..a98d124 --- /dev/null +++ b/broker_sync/cli.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import os +import sys + +import typer + +app = typer.Typer(help="broker-sync: pull brokerage activity into Wealthfolio") + + +@app.command("version") +def version() -> None: + """Print version and exit — used by the no-op Phase 0 CronJob as a liveness check.""" + from broker_sync import __version__ + typer.echo(f"broker-sync {__version__}") + + +@app.command("auth-spike") +def auth_spike( + wf_base_url: str = typer.Option(..., envvar="WF_BASE_URL", help="Wealthfolio base URL"), + wf_username: str = typer.Option(..., envvar="WF_USERNAME"), + wf_password: str = typer.Option(..., envvar="WF_PASSWORD"), + session_path: str = typer.Option("/data/wealthfolio_session.json", envvar="WF_SESSION_PATH"), +) -> None: + """Phase 0.5 — prove end-to-end auth + 1 activity import against live Wealthfolio.""" + import asyncio + + from broker_sync.sinks.wealthfolio import WealthfolioSink + + async def _run() -> None: + sink = WealthfolioSink( + base_url=wf_base_url, + username=wf_username, + password=wf_password, + session_path=session_path, + ) + try: + await sink.login() + accounts = await sink.list_accounts() + typer.echo(f"Logged in. {len(accounts)} account(s) visible.") + finally: + await sink.close() + + try: + asyncio.run(_run()) + except Exception as e: + typer.echo(f"auth-spike failed: {e}", err=True) + sys.exit(1) + + +def main() -> None: + # Entry point called by the console-script in pyproject.toml. + app() + + +if __name__ == "__main__": + # Guard env for readability when running under `python -m broker_sync.cli`. + os.environ.setdefault("COLUMNS", "120") + main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..d05a3e7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,12 @@ +from typer.testing import CliRunner + +from broker_sync import __version__ +from broker_sync.cli import app + +runner = CliRunner() + + +def test_version_prints_package_version() -> None: + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert __version__ in result.stdout