feat(dev): LOCAL_DEV_MODE for one-command local dev + magic-link fixes (#32)
* feat(dev): add LOCAL_DEV_MODE for one-command local dev When LOCAL_DEV_MODE=1, every protected route auto-authenticates as a seeded admin user (default dev@localhost) — no login screen, no Google OAuth config, no magic-link roundtrip. Startup logs a loud warning to make misuse obvious. Also fixes two preexisting bugs in the magic-link flow that surfaced while wiring up the dev fallback: - /auth/email/verify only accepted POST, but the URL embedded in emails is a GET link — clicking from any mail client returned 405. Added a GET variant that consumes the token, sets the auth cookie, and redirects to /dashboard. - Token expiry check compared an offset-aware datetime.now(timezone.utc) against an offset-naive value from DuckDB, raising TypeError on every valid link. Normalize the stored timestamp to UTC before subtracting. Dev-only fallback (scoped strictly to LOCAL_DEV_MODE to keep test and production behavior identical): send-link logs the magic link to stderr and returns it as dev_link in the JSON response when no SMTP is configured. Usage: ./scripts/run-local-dev.sh open http://localhost:8000 # lands on /dashboard as admin * fix(dev): URL-encode magic-link email + avoid /login redirect loop Two issues surfaced by Devin review on PR #32. 1. _build_magic_link interpolated email into the URL unescaped. For addresses with '+' (e.g. user+tag@gmail.com) Starlette's query parser decoded '+' as a space on the GET /verify side, so repo.get_by_email returned None and every click yielded 401 "Invalid link". quote(email, safe='') fixes both the email transport and the dev_link fallback. 2. /login in LOCAL_DEV_MODE unconditionally redirected to /dashboard. If dev-user seeding failed at startup (main.py wraps seed in try/except), /dashboard 401'd, the HTML redirect handler bounced to /login, and the loop repeated until the browser aborted. Now /login checks the dev user actually exists before short-circuiting; otherwise it falls through to the normal login form so the missing seed is visible.
This commit is contained in:
parent
d2c76cb221
commit
9b5214ea6f
6 changed files with 219 additions and 33 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
"""FastAPI auth dependencies — current user, role checking."""
|
"""FastAPI auth dependencies — current user, role checking."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
@ -10,6 +12,21 @@ from src.db import get_system_db
|
||||||
from src.rbac import Role, ROLE_HIERARCHY
|
from src.rbac import Role, ROLE_HIERARCHY
|
||||||
from src.repositories.users import UserRepository
|
from src.repositories.users import UserRepository
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default dev user used when LOCAL_DEV_MODE=1. Seeded at startup by app/main.py.
|
||||||
|
LOCAL_DEV_DEFAULT_EMAIL = "dev@localhost"
|
||||||
|
|
||||||
|
|
||||||
|
def is_local_dev_mode() -> bool:
|
||||||
|
"""True when LOCAL_DEV_MODE=1 — unsafe for production, bypasses auth."""
|
||||||
|
return os.environ.get("LOCAL_DEV_MODE", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_dev_email() -> str:
|
||||||
|
"""Email of the auto-logged-in dev user. Configurable via LOCAL_DEV_USER_EMAIL."""
|
||||||
|
return os.environ.get("LOCAL_DEV_USER_EMAIL", LOCAL_DEV_DEFAULT_EMAIL)
|
||||||
|
|
||||||
|
|
||||||
def _get_db():
|
def _get_db():
|
||||||
conn = get_system_db()
|
conn = get_system_db()
|
||||||
|
|
@ -39,12 +56,30 @@ def _client_ip(request: Optional[Request]) -> Optional[str]:
|
||||||
return getattr(client, "host", None) if client else None
|
return getattr(client, "host", None) if client else None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_local_dev_user(conn: duckdb.DuckDBPyConnection) -> Optional[dict]:
|
||||||
|
"""Return the seeded dev user when LOCAL_DEV_MODE is on, else None."""
|
||||||
|
repo = UserRepository(conn)
|
||||||
|
user = repo.get_by_email(get_local_dev_email())
|
||||||
|
if not user:
|
||||||
|
logger.error(
|
||||||
|
"LOCAL_DEV_MODE is on but dev user %s is not seeded; expected app startup to seed it",
|
||||||
|
get_local_dev_email(),
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request = None,
|
request: Request = None,
|
||||||
authorization: Optional[str] = Header(None),
|
authorization: Optional[str] = Header(None),
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Extract and validate JWT from Authorization header or cookie. Returns user dict."""
|
"""Extract and validate JWT from Authorization header or cookie. Returns user dict."""
|
||||||
|
if is_local_dev_mode():
|
||||||
|
user = _get_local_dev_user(conn)
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
# Fall through to normal auth if seed missing — surfaces the bug instead of hiding it.
|
||||||
|
|
||||||
token = None
|
token = None
|
||||||
|
|
||||||
# Try Authorization header first
|
# Try Authorization header first
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,15 @@ import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import duckdb
|
import duckdb
|
||||||
|
|
||||||
from app.auth.jwt import create_access_token
|
from app.auth.jwt import create_access_token
|
||||||
from app.auth.dependencies import _get_db
|
from app.auth.dependencies import _get_db, is_local_dev_mode
|
||||||
from src.repositories.users import UserRepository
|
from src.repositories.users import UserRepository
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -29,15 +31,36 @@ class MagicLinkVerify(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
def is_available() -> bool:
|
||||||
|
# In dev mode the link is rendered to logs + response, so the provider is "available"
|
||||||
|
# even without SMTP/SendGrid. Keeps the login UI showing the magic-link option.
|
||||||
|
if is_local_dev_mode():
|
||||||
|
return True
|
||||||
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
|
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
|
||||||
|
|
||||||
|
|
||||||
|
def _has_email_transport() -> bool:
|
||||||
|
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_magic_link(email: str, token: str) -> str:
|
||||||
|
# URL-encode email: a literal '+' in a query string decodes to space per
|
||||||
|
# application/x-www-form-urlencoded, which would break addresses like
|
||||||
|
# "user+tag@gmail.com" on the GET /verify side.
|
||||||
|
server_url = os.environ.get("SERVER_URL", "http://localhost:8000")
|
||||||
|
return f"{server_url}/auth/email/verify?email={quote(email, safe='')}&token={token}"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/send-link")
|
@router.post("/send-link")
|
||||||
async def send_magic_link(
|
async def send_magic_link(
|
||||||
request: MagicLinkRequest,
|
request: MagicLinkRequest,
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
"""Send a magic link to the user's email."""
|
"""Send a magic link to the user's email.
|
||||||
|
|
||||||
|
When SMTP/SendGrid is not configured, or LOCAL_DEV_MODE=1, the link is
|
||||||
|
logged to stderr and returned in the response body so a developer can
|
||||||
|
click it without an email transport.
|
||||||
|
"""
|
||||||
repo = UserRepository(conn)
|
repo = UserRepository(conn)
|
||||||
user = repo.get_by_email(request.email)
|
user = repo.get_by_email(request.email)
|
||||||
|
|
||||||
|
|
@ -53,57 +76,106 @@ async def send_magic_link(
|
||||||
reset_token_created=datetime.now(timezone.utc),
|
reset_token_created=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send email (best effort)
|
link = _build_magic_link(request.email, token)
|
||||||
|
send_error: str | None = None
|
||||||
|
if _has_email_transport():
|
||||||
try:
|
try:
|
||||||
_send_email(request.email, token)
|
_send_email(request.email, token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send magic link email: {e}")
|
send_error = str(e)
|
||||||
|
logger.error("Failed to send magic link email to %s: %s", request.email, e)
|
||||||
|
|
||||||
|
# Dev fallback: expose the link in logs + response so you can click it without SMTP.
|
||||||
|
# Scoped strictly to LOCAL_DEV_MODE so test and production behavior are unchanged.
|
||||||
|
if is_local_dev_mode():
|
||||||
|
logger.warning("=" * 60)
|
||||||
|
logger.warning("Magic link for %s (LOCAL_DEV_MODE fallback):", request.email)
|
||||||
|
logger.warning(" %s", link)
|
||||||
|
logger.warning("=" * 60)
|
||||||
|
response: dict = {
|
||||||
|
"message": "Magic link generated (LOCAL_DEV_MODE) — click dev_link to log in.",
|
||||||
|
"dev_link": link,
|
||||||
|
}
|
||||||
|
if send_error:
|
||||||
|
response["send_error"] = send_error
|
||||||
|
return response
|
||||||
|
|
||||||
return {"message": "If this email is registered, you will receive a login link."}
|
return {"message": "If this email is registered, you will receive a login link."}
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_token(conn: duckdb.DuckDBPyConnection, email: str, token: str) -> dict:
|
||||||
|
"""Validate & consume a magic-link token. Returns the user dict or raises 401."""
|
||||||
|
repo = UserRepository(conn)
|
||||||
|
user = repo.get_by_email(email)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid link")
|
||||||
|
|
||||||
|
if user.get("reset_token") != token:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired link")
|
||||||
|
|
||||||
|
created = user.get("reset_token_created")
|
||||||
|
if created:
|
||||||
|
if isinstance(created, str):
|
||||||
|
created = datetime.fromisoformat(created)
|
||||||
|
# DuckDB returns TIMESTAMP as offset-naive; we stored it as UTC, so assume UTC.
|
||||||
|
if created.tzinfo is None:
|
||||||
|
created = created.replace(tzinfo=timezone.utc)
|
||||||
|
if (datetime.now(timezone.utc) - created).total_seconds() > MAGIC_LINK_EXPIRY:
|
||||||
|
raise HTTPException(status_code=401, detail="Link expired")
|
||||||
|
|
||||||
|
# Clear token (one-time use)
|
||||||
|
repo.update(id=user["id"], reset_token=None, reset_token_created=None)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify")
|
@router.post("/verify")
|
||||||
async def verify_magic_link(
|
async def verify_magic_link(
|
||||||
request: MagicLinkVerify,
|
request: MagicLinkVerify,
|
||||||
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
):
|
):
|
||||||
"""Verify a magic link token and issue JWT."""
|
"""Verify a magic link token and issue JWT (JSON API for programmatic clients)."""
|
||||||
repo = UserRepository(conn)
|
user = _consume_token(conn, request.email, request.token)
|
||||||
user = repo.get_by_email(request.email)
|
|
||||||
if not user:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid link")
|
|
||||||
|
|
||||||
if user.get("reset_token") != request.token:
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid or expired link")
|
|
||||||
|
|
||||||
# Check expiry
|
|
||||||
created = user.get("reset_token_created")
|
|
||||||
if created:
|
|
||||||
if isinstance(created, str):
|
|
||||||
created = datetime.fromisoformat(created)
|
|
||||||
if (datetime.now(timezone.utc) - created).total_seconds() > MAGIC_LINK_EXPIRY:
|
|
||||||
raise HTTPException(status_code=401, detail="Link expired")
|
|
||||||
|
|
||||||
# Clear token (one-time use)
|
|
||||||
repo.update(id=user["id"], reset_token=None, reset_token_created=None)
|
|
||||||
|
|
||||||
jwt_token = create_access_token(user["id"], user["email"], user["role"])
|
jwt_token = create_access_token(user["id"], user["email"], user["role"])
|
||||||
return {"access_token": jwt_token, "token_type": "bearer", "email": user["email"], "role": user["role"]}
|
return {"access_token": jwt_token, "token_type": "bearer", "email": user["email"], "role": user["role"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/verify")
|
||||||
|
async def verify_magic_link_get(
|
||||||
|
email: str,
|
||||||
|
token: str,
|
||||||
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
||||||
|
):
|
||||||
|
"""Click-through variant — verifies token, sets cookie, redirects to /dashboard.
|
||||||
|
|
||||||
|
This is the URL we embed in outgoing emails (and the dev-fallback link), so
|
||||||
|
clicking it in a mail client logs the user in without a separate API call.
|
||||||
|
"""
|
||||||
|
user = _consume_token(conn, email, token)
|
||||||
|
jwt_token = create_access_token(user["id"], user["email"], user["role"])
|
||||||
|
# secure=False when DOMAIN is unset so the cookie is actually sent on plain HTTP (dev).
|
||||||
|
use_secure = os.environ.get("DOMAIN", "") != ""
|
||||||
|
response = RedirectResponse(url="/dashboard", status_code=302)
|
||||||
|
response.set_cookie(
|
||||||
|
key="access_token", value=jwt_token,
|
||||||
|
httponly=True, max_age=86400, samesite="lax",
|
||||||
|
secure=use_secure,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def _send_email(email: str, token: str):
|
def _send_email(email: str, token: str):
|
||||||
"""Send magic link email via SMTP or SendGrid."""
|
"""Send magic link email via SMTP or SendGrid."""
|
||||||
|
link = _build_magic_link(email, token)
|
||||||
sendgrid_key = os.environ.get("SENDGRID_API_KEY")
|
sendgrid_key = os.environ.get("SENDGRID_API_KEY")
|
||||||
if sendgrid_key:
|
if sendgrid_key:
|
||||||
import sendgrid
|
import sendgrid
|
||||||
from sendgrid.helpers.mail import Mail
|
from sendgrid.helpers.mail import Mail
|
||||||
sg = sendgrid.SendGridAPIClient(api_key=sendgrid_key)
|
sg = sendgrid.SendGridAPIClient(api_key=sendgrid_key)
|
||||||
server_url = os.environ.get("SERVER_URL", "http://localhost:8000")
|
|
||||||
message = Mail(
|
message = Mail(
|
||||||
from_email=os.environ.get("EMAIL_FROM_ADDRESS", "noreply@example.com"),
|
from_email=os.environ.get("EMAIL_FROM_ADDRESS", "noreply@example.com"),
|
||||||
to_emails=email,
|
to_emails=email,
|
||||||
subject="Login Link",
|
subject="Login Link",
|
||||||
html_content=f'<p>Click to login: <a href="{server_url}/auth/email/verify?email={email}&token={token}">Login</a></p>',
|
html_content=f'<p>Click to login: <a href="{link}">Login</a></p>',
|
||||||
)
|
)
|
||||||
sg.send(message)
|
sg.send(message)
|
||||||
return
|
return
|
||||||
|
|
@ -112,8 +184,7 @@ def _send_email(email: str, token: str):
|
||||||
if smtp_host:
|
if smtp_host:
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
server_url = os.environ.get("SERVER_URL", "http://localhost:8000")
|
msg = MIMEText(f"Login link: {link}")
|
||||||
msg = MIMEText(f"Login link: {server_url}/auth/email/verify?email={email}&token={token}")
|
|
||||||
msg["Subject"] = "Login Link"
|
msg["Subject"] = "Login Link"
|
||||||
msg["From"] = os.environ.get("SMTP_FROM", "noreply@example.com")
|
msg["From"] = os.environ.get("SMTP_FROM", "noreply@example.com")
|
||||||
msg["To"] = email
|
msg["To"] = email
|
||||||
|
|
|
||||||
14
app/main.py
14
app/main.py
|
|
@ -95,11 +95,21 @@ def create_app() -> FastAPI:
|
||||||
SCHEMA_VERSION,
|
SCHEMA_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Seed admin user for testing/CI (when SEED_ADMIN_EMAIL is set).
|
# LOCAL_DEV_MODE: bypass authentication for local development. DO NOT enable in prod.
|
||||||
|
# When on, every protected route auto-logs in as a seeded admin user (default dev@localhost).
|
||||||
|
from app.auth.dependencies import is_local_dev_mode, get_local_dev_email
|
||||||
|
if is_local_dev_mode():
|
||||||
|
logger.warning("=" * 60)
|
||||||
|
logger.warning("LOCAL_DEV_MODE is ON — authentication is bypassed.")
|
||||||
|
logger.warning("All requests auto-authenticate as: %s", get_local_dev_email())
|
||||||
|
logger.warning("NEVER enable this in a deployment reachable from the internet.")
|
||||||
|
logger.warning("=" * 60)
|
||||||
|
|
||||||
|
# Seed admin user for testing/CI (when SEED_ADMIN_EMAIL is set) OR for local dev.
|
||||||
# Optional: SEED_ADMIN_PASSWORD sets password_hash on first seed so the user
|
# Optional: SEED_ADMIN_PASSWORD sets password_hash on first seed so the user
|
||||||
# can log in immediately without bootstrap. Only applied if the user has no
|
# can log in immediately without bootstrap. Only applied if the user has no
|
||||||
# password_hash yet — never overwrites an existing password.
|
# password_hash yet — never overwrites an existing password.
|
||||||
seed_email = os.environ.get("SEED_ADMIN_EMAIL")
|
seed_email = os.environ.get("SEED_ADMIN_EMAIL") or (get_local_dev_email() if is_local_dev_mode() else None)
|
||||||
if seed_email:
|
if seed_email:
|
||||||
try:
|
try:
|
||||||
from src.db import get_system_db
|
from src.db import get_system_db
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,19 @@ async def setup_wizard(request: Request, conn: duckdb.DuckDBPyConnection = Depen
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
async def login_page(request: Request):
|
async def login_page(request: Request):
|
||||||
|
from app.auth.dependencies import is_local_dev_mode, _get_local_dev_user
|
||||||
|
if is_local_dev_mode():
|
||||||
|
# Only short-circuit to /dashboard if the dev user is actually seeded.
|
||||||
|
# Otherwise the 401 from /dashboard would bounce back to /login and loop.
|
||||||
|
from src.db import get_system_db
|
||||||
|
conn = get_system_db()
|
||||||
|
try:
|
||||||
|
if _get_local_dev_user(conn):
|
||||||
|
return RedirectResponse(url="/dashboard", status_code=302)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
# Fall through to the normal login form so the missing-seed error is visible.
|
||||||
|
|
||||||
next_path = request.query_params.get("next", "")
|
next_path = request.query_params.get("next", "")
|
||||||
if not next_path.startswith("/") or next_path.startswith("//"):
|
if not next_path.startswith("/") or next_path.startswith("//"):
|
||||||
next_path = ""
|
next_path = ""
|
||||||
|
|
|
||||||
32
docker-compose.local-dev.yml
Normal file
32
docker-compose.local-dev.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Local-dev overlay — auth bypass + no real email delivery required.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.local-dev.yml up
|
||||||
|
# or:
|
||||||
|
# scripts/run-local-dev.sh
|
||||||
|
#
|
||||||
|
# Effect:
|
||||||
|
# - LOCAL_DEV_MODE=1 makes every protected route auto-login as dev@localhost (role=admin).
|
||||||
|
# - Magic-link emails are logged to stderr and returned in the response (no SMTP needed).
|
||||||
|
# - Secrets (JWT, session) auto-generate into /data/state on first boot.
|
||||||
|
# - No .env file required; nothing else is mandatory.
|
||||||
|
#
|
||||||
|
# NEVER deploy this overlay to an environment reachable from the internet.
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
env_file: [] # Drop the project .env requirement — everything dev-relevant is inline below.
|
||||||
|
environment:
|
||||||
|
- DATA_DIR=/data
|
||||||
|
- LOCAL_DEV_MODE=1
|
||||||
|
- LOCAL_DEV_USER_EMAIL=dev@localhost
|
||||||
|
- SERVER_URL=http://localhost:8000
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Scheduler also reads LOCAL_DEV flag so its auto-auth uses the same seed user.
|
||||||
|
scheduler:
|
||||||
|
env_file: []
|
||||||
|
environment:
|
||||||
|
- DATA_DIR=/data
|
||||||
|
- API_URL=http://app:8000
|
||||||
|
- LOCAL_DEV_MODE=1
|
||||||
|
- SEED_ADMIN_EMAIL=dev@localhost
|
||||||
25
scripts/run-local-dev.sh
Executable file
25
scripts/run-local-dev.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run Agnes locally with auth bypass + dev-mode magic links.
|
||||||
|
#
|
||||||
|
# Stacks three compose files:
|
||||||
|
# 1. docker-compose.yml — base services
|
||||||
|
# 2. docker-compose.override.yml — hot-reload + source bind mount (dev default)
|
||||||
|
# 3. docker-compose.local-dev.yml — LOCAL_DEV_MODE=1, drops .env requirement
|
||||||
|
#
|
||||||
|
# After startup visit http://localhost:8000 — you'll land on /dashboard
|
||||||
|
# logged in as dev@localhost (role=admin). No login screen, no email delivery needed.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Ensure docker-compose.yml does not require a .env file. We override env_file in the
|
||||||
|
# local-dev overlay, but compose still touches the file path during config validation.
|
||||||
|
if [[ ! -f .env ]]; then
|
||||||
|
touch .env
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec docker compose \
|
||||||
|
-f docker-compose.yml \
|
||||||
|
-f docker-compose.override.yml \
|
||||||
|
-f docker-compose.local-dev.yml \
|
||||||
|
up "$@"
|
||||||
Loading…
Reference in a new issue