Files
DocsGPT/docsgpt/deploy/envfile.py
T
Alex aa0e4ea280 feat: docsgpt up runs and manages DocsGPT on Docker
`docsgpt up` copies the standalone Compose file shipped with this package
version into the stack directory (~/.docsgpt/server by default), writes its
.env and starts the stack on the images of the same version. A first run
asks who should reach DocsGPT (this computer, the network with a token, or a
domain with HTTPS) and which model provider to use; flags answer the same
questions for scripts. Re-running keeps secrets and settings and moves the
image tag, and the database password is only generated for a new database.

Also: down, status, logs, token, open, env, upgrade (uv tool installs
upgrade themselves and run `up` again) and uninstall (keeps settings and
data unless --purge). The commands import no Flask, Celery or settings.

The wheel carries deployment/docker-compose-standalone.yaml as
docsgpt/deploy/docker-compose.yaml; the sdist includes the source file.
2026-09-15 22:57:00 +01:00

75 lines
2.8 KiB
Python

"""Read and update a ``.env`` file in place, keeping the lines the user wrote."""
from __future__ import annotations
import os
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Optional
_ASSIGNMENT = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$")
# Values written without quotes: nothing Compose would interpolate, strip or treat as a comment.
_PLAIN = re.compile(r"^[A-Za-z0-9_./:@+,=-]*$")
def _parse_value(raw: str) -> str:
"""The value of one assignment, with Compose's quoting rules."""
value = raw.strip()
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1]
if len(value) >= 2 and value[0] == value[-1] == '"':
return re.sub(r'\\(["\\])', r"\1", value[1:-1])
return value.split(" #", 1)[0].rstrip()
def _format_value(value: str) -> str:
"""``value`` quoted so it reads back unchanged."""
if "\n" in value or "\r" in value:
raise ValueError("a .env value cannot contain a newline")
if _PLAIN.match(value):
return value
if "'" not in value:
return f"'{value}'"
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
def read(path: Path) -> dict[str, str]:
"""The assignments in ``path`` (empty when it does not exist); a repeated key keeps its last value."""
path = Path(path)
if not path.is_file():
return {}
values: dict[str, str] = {}
for line in path.read_text(encoding="utf-8").splitlines():
match = _ASSIGNMENT.match(line)
if match:
values[match.group(1)] = _parse_value(match.group(2))
return values
def update(path: Path, values: Mapping[str, Optional[str]]) -> None:
"""Set each key in place (``None`` removes it), append new keys, and leave every other line alone.
A new file is created readable by its owner only: it holds secrets.
"""
formatted = {key: None if value is None else _format_value(value) for key, value in values.items()}
path = Path(path)
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
written: set[str] = set()
out: list[str] = []
for line in lines:
match = _ASSIGNMENT.match(line)
key = match.group(1) if match else None
if key not in formatted:
out.append(line)
continue
if key not in written and formatted[key] is not None:
out.append(f"{key}={formatted[key]}")
written.add(key)
out.extend(f"{key}={value}" for key, value in formatted.items() if key not in written and value is not None)
path.parent.mkdir(parents=True, exist_ok=True)
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
handle.write("\n".join(out) + "\n" if out else "")