File size: 5,136 Bytes
ff3c2e9
 
 
 
ab8ae7f
ff3c2e9
 
 
 
ab8ae7f
 
ff3c2e9
 
 
 
 
 
ab8ae7f
ff3c2e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ab8ae7f
 
 
ff3c2e9
 
 
 
 
 
 
 
 
ab8ae7f
ff3c2e9
 
 
 
 
ab8ae7f
 
 
 
ff3c2e9
 
ab8ae7f
 
ff3c2e9
 
 
 
 
 
ab8ae7f
 
ff3c2e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
"""
check_env.py β€” resolve and report the database configuration WITHOUT connecting.

Answers "is my .env actually valid?" before you run alembic. Stdlib only, so it
works before `uv sync`.  Passwords are never printed.

    python3 scripts/check_env.py

Common trap this catches: DATABASE_URL is a URL, so a password containing
@ : / ? # % must be percent-encoded β€” Neon's own connection string is already
encoded correctly, so this usually only bites if the password was edited by hand.
"""

from __future__ import annotations

import sys
from pathlib import Path
from urllib.parse import unquote, urlsplit

BACKEND = Path(__file__).resolve().parent.parent
REPO_ROOT = BACKEND.parent

NEEDS_ENCODING = set("@:/?#[]% ")


def load_env_file(path: Path) -> dict[str, str]:
    """Minimal .env reader β€” same precedence rule as config.py (first wins)."""
    values: dict[str, str] = {}
    if not path.exists():
        return values
    for raw in path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, val = line.partition("=")
        val = val.strip()
        if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'":
            val = val[1:-1]
        values.setdefault(key.strip(), val)
    return values


def main() -> int:
    env = load_env_file(BACKEND / ".env")
    for k, v in load_env_file(REPO_ROOT / ".env").items():
        env.setdefault(k, v)

    if not (BACKEND / ".env").exists():
        print(f"ERROR: {BACKEND / '.env'} does not exist. Copy .env.example to .env.")
        return 1

    problems: list[str] = []
    url = env.get("DATABASE_URL", "").strip()

    print(f"backend .env : {BACKEND / '.env'}")
    print(f"root .env    : {REPO_ROOT / '.env'}"
          f"{'' if (REPO_ROOT / '.env').exists() else '  (absent)'}")
    print()

    if not url:
        problems.append("DATABASE_URL is empty β€” paste the Neon connection string in")
    else:
        parts = urlsplit(url)
        driver, host, port = parts.scheme, parts.hostname, parts.port
        user = unquote(parts.username or "")
        raw_pw = parts.password or ""
        db = parts.path.lstrip("/")
        query = parts.query

        print(f"driver       : {driver}")
        print(f"host         : {host}")
        print(f"port         : {port or 5432}")
        print(f"user         : {user}")
        print(f"password     : {'set (' + str(len(raw_pw)) + ' chars as written)' if raw_pw else 'MISSING'}")
        print(f"database     : {db}")
        print(f"query        : {query or '(none)'}")

        if driver != "postgresql+psycopg":
            problems.append(
                f"driver is {driver!r}; this backend expects postgresql+psycopg (psycopg v3)"
            )
        if not host:
            problems.append("no host in DATABASE_URL")
        if host and "neon.tech" not in host:
            print("             (host does not look like a Neon endpoint β€” fine if intentional)")
        if not user:
            problems.append("no user in DATABASE_URL")
        if not raw_pw:
            problems.append("no password in DATABASE_URL")
        if not db:
            problems.append("no database name in DATABASE_URL (the path after the host)")
        if "sslmode=require" not in query and "sslmode=verify-full" not in query:
            problems.append("sslmode=require missing β€” Neon refuses plain-text connections")
        # A raw special character in the password truncates the URL silently.
        decoded = unquote(raw_pw)
        if decoded != raw_pw and any(c in NEEDS_ENCODING for c in decoded):
            print("             (password contains percent-encoded characters β€” good)")
        elif any(c in NEEDS_ENCODING for c in raw_pw):
            problems.append(
                "password contains a character that must be percent-encoded "
                f"({''.join(sorted(set(c for c in raw_pw if c in NEEDS_ENCODING)))}) β€” "
                "urlsplit has probably mis-parsed this URL"
            )

    print()
    print("service settings")
    for key, default in (
        ("PUBLIC_BASE_URL", "http://127.0.0.1:8000"),
        ("CORS_ORIGINS", "http://localhost:3000,http://127.0.0.1:3000"),
        ("SCREENSHOT_DIR", "screenshots"),
    ):
        val = env.get(key, "").strip()
        print(f"  {key:16s}: {val or default}{'  (default)' if not val else ''}")
    print(f"  {'API_KEY':16s}: {'set β€” X-API-Key required' if env.get('API_KEY','').strip() else 'empty β€” auth disabled (fine for localhost)'}")

    print()
    print("third-party keys (presence only)")
    for key in ("GEMINI_API_KEY", "HUNTER_API_KEY", "SIGNALHIRE_API_KEY", "APOLLO_API_KEY", "CALENDAR_LINK"):
        print(f"  {key:20s}: {'set' if env.get(key, '').strip() else 'not set'}")

    print()
    if problems:
        print(f"{len(problems)} problem(s):")
        for p in problems:
            print(f"  - {p}")
        return len(problems)

    print("config looks valid. Next: uv run alembic upgrade head")
    return 0


if __name__ == "__main__":
    sys.exit(main())