One installation per project, and no login to reach it

Two things a local install should not have asked for.

`fluksio serve` now signs you in. Logging in to your own machine was a
formality — the password was printed by the same process that would have
checked it, and the database it authenticates against sits in the directory
the token goes into — so `serve` mints the token itself and says where it put
it. `fluksio login` is left for an engine somewhere else.

And an installation is `.fluksio` beside the code, found the way `.git` is,
rather than one `~/.fluksio` for the machine. A repository with its own venv
was already getting its own engine; it now gets its own flows, run history and
token too, instead of three repositories sharing one database and fighting
over one port. `--global` asks for the shared one, `--data-dir` still names
any directory, and when both exist the banner says which you are looking at
and how to reach the other.

The directory ignores itself from within — a `.gitignore` of `*`, the way uv
writes one into `.venv` — because it holds a credential and a database, and
neither belongs in anybody's history. The token is written mode 600. A login
an older version wrote to ~/.config/fluksio is still read, so nothing that
worked stops working.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012ue1tkFWB1bcGy3aWhCKpU
This commit is contained in:
2026-08-24 16:13:35 +02:00
co-authored by Claude Fable 5
parent e2b25c9d5a
commit 99f6530698
9 changed files with 405 additions and 57 deletions
+97 -12
View File
@@ -17,21 +17,72 @@ from typing import Any
from fluksio.sdk import MARKER, Flow, SyncError
__all__ = ["Client", "RunHandle", "SyncReport", "config_path", "login", "sync"]
__all__ = [
"Client",
"RunHandle",
"SyncReport",
"config_path",
"data_dir",
"find_data_dir",
"ignore_self",
"login",
"sync",
"write_config",
]
API = "/api/v1"
#: A run is over when it reaches one of these.
DONE = frozenset({"ok", "error", "cancelled", "abandoned"})
def config_path() -> Path:
"""Where ``fluksio login`` leaves the engine it talked to."""
#: What a project-local installation is called, beside `.venv` and `.git`.
DATA_DIR_NAME = ".fluksio"
#: The shared one, for a machine that wants a single engine rather than one
#: per repository. `fluksio serve --global` is how you ask for it.
GLOBAL_DATA_DIR = Path("~/.fluksio")
def find_data_dir(start: Path | None = None) -> Path | None:
"""The nearest project-local installation, walking up from ``start``.
The same search `.git` and `.venv` get, and for the same reason: which
installation you mean is a fact about where you are standing, not about
which machine you are on. Several repositories on one device each keep
their own flows, runs and token this way rather than sharing one.
"""
directory = (start or Path.cwd()).resolve()
for candidate in (directory, *directory.parents):
local = candidate / DATA_DIR_NAME
if local.is_dir():
return local
return None
def data_dir(start: Path | None = None) -> Path:
"""The installation this working directory belongs to."""
found = find_data_dir(start)
return found if found is not None else GLOBAL_DATA_DIR.expanduser()
def config_path(directory: Path | None = None) -> Path:
"""Where the token for an installation lives — beside the data it opens.
Not a single file per machine: a token is for one engine, and with an
installation per repository there is more than one. Keeping it inside the
data directory means the client finds the credential for the engine whose
directory it is standing in, without either having to be told.
"""
return (directory or data_dir()) / "client.json"
def _legacy_config_path() -> Path:
"""Where `fluksio login` used to write, before installations were local."""
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(base) / "fluksio" / "client.json"
def _stored() -> dict[str, str]:
path = config_path()
def _read(path: Path) -> dict[str, str]:
if not path.exists():
return {}
try:
@@ -41,6 +92,15 @@ def _stored() -> dict[str, str]:
return data if isinstance(data, dict) else {}
def _stored() -> dict[str, str]:
"""The nearest credential: this project's, then the machine's."""
for path in (config_path(), _legacy_config_path()):
found = _read(path)
if found:
return found
return {}
class ApiError(Exception):
"""The engine refused, with what it said."""
@@ -247,7 +307,37 @@ class RunHandle:
return f"RunHandle({self.id!r}, status={self.status!r})"
def login(url: str, email: str, password: str, timeout: float = 30.0) -> str:
def ignore_self(directory: Path) -> None:
"""Keep an installation out of the repository it sits in.
It holds a token and a database, neither of which belongs in anybody's
history. A `.gitignore` of `*` inside the directory ignores it from within,
so nothing has to be added to the project's own — the same thing `uv` does
for the venv it builds.
"""
marker = directory / ".gitignore"
if not marker.exists():
directory.mkdir(parents=True, exist_ok=True)
marker.write_text("# A Fluksio installation: a database, and a token.\n*\n")
def write_config(url: str, token: str, directory: Path | None = None) -> Path:
"""Store the credential for an engine, readable only by its owner."""
path = config_path(directory)
path.parent.mkdir(parents=True, exist_ok=True)
ignore_self(path.parent)
path.write_text(json.dumps({"url": url.rstrip("/"), "token": token}, indent=2))
path.chmod(0o600)
return path
def login(
url: str,
email: str,
password: str,
timeout: float = 30.0,
directory: Path | None = None,
) -> Path:
"""Exchange credentials for a token and remember the engine."""
import httpx
@@ -258,12 +348,7 @@ def login(url: str, email: str, password: str, timeout: float = 30.0) -> str:
)
if response.status_code >= 400:
raise ApiError(response.status_code, _detail(response))
token = str(response.json()["access_token"])
path = config_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"url": url.rstrip("/"), "token": token}, indent=2))
path.chmod(0o600)
return token
return write_config(url, str(response.json()["access_token"]), directory)
# ---------------------------------------------------------------------------