wizelit-client 0.1.18__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wizelit_client-0.1.18/.gitignore +47 -0
- wizelit_client-0.1.18/PKG-INFO +26 -0
- wizelit_client-0.1.18/README.md +5 -0
- wizelit_client-0.1.18/pyproject.toml +41 -0
- wizelit_client-0.1.18/wizelit_client/__init__.py +5 -0
- wizelit_client-0.1.18/wizelit_client/auth.py +54 -0
- wizelit_client-0.1.18/wizelit_client/credentials.py +67 -0
- wizelit_client-0.1.18/wizelit_client/device_login.py +142 -0
- wizelit_client-0.1.18/wizelit_client/transport_security.py +74 -0
- wizelit_client-0.1.18/wizelit_client/user_config.py +84 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.cli_bundle/
|
|
9
|
+
.agent/
|
|
10
|
+
.venv/
|
|
11
|
+
*.egg
|
|
12
|
+
|
|
13
|
+
# Node
|
|
14
|
+
node_modules/
|
|
15
|
+
dist/
|
|
16
|
+
|
|
17
|
+
# Environment
|
|
18
|
+
.env
|
|
19
|
+
!.env.template
|
|
20
|
+
|
|
21
|
+
# IDE
|
|
22
|
+
.vscode/
|
|
23
|
+
.idea/
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Data
|
|
32
|
+
data/
|
|
33
|
+
|
|
34
|
+
# Test
|
|
35
|
+
.pytest_cache/
|
|
36
|
+
.coverage
|
|
37
|
+
htmlcov/
|
|
38
|
+
videos/
|
|
39
|
+
|
|
40
|
+
# Build
|
|
41
|
+
*.whl
|
|
42
|
+
|
|
43
|
+
# AWS CDK
|
|
44
|
+
cdk.out/
|
|
45
|
+
cdk.context.json
|
|
46
|
+
|
|
47
|
+
local_*
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: wizelit-client
|
|
3
|
+
Version: 0.1.18
|
|
4
|
+
Summary: Wizelit client auth — device-link pairing and credential storage for CLI and ACP
|
|
5
|
+
Author-email: Wizeline <engineering@wizeline.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: acp,auth,cli,wizelit
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: httpx>=0.27.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# wizelit-client
|
|
23
|
+
|
|
24
|
+
Shared device-link authentication and credential storage for Wizelit CLI and ACP clients.
|
|
25
|
+
|
|
26
|
+
Credentials are stored in `~/.wizelit/credentials.json` after browser pairing.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "wizelit-client"
|
|
3
|
+
version = "0.1.18"
|
|
4
|
+
description = "Wizelit client auth — device-link pairing and credential storage for CLI and ACP"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
authors = [{ name = "Wizeline", email = "engineering@wizeline.com" }]
|
|
9
|
+
keywords = ["wizelit", "auth", "cli", "acp"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Programming Language :: Python :: 3.13",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"httpx>=0.27.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["wizelit_client"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.sdist]
|
|
31
|
+
include = ["LICENSE", "README.md", "wizelit_client/**/*.py"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
asyncio_mode = "auto"
|
|
35
|
+
testpaths = ["tests"]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"pytest>=8.0.0",
|
|
40
|
+
"pytest-asyncio>=0.24.0",
|
|
41
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Shared hub authentication for Wizelit CLI and ACP clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
|
|
8
|
+
from wizelit_client.credentials import delete_credentials, load_credentials
|
|
9
|
+
from wizelit_client.device_login import pair_device
|
|
10
|
+
from wizelit_client.user_config import resolve_hub_api_url
|
|
11
|
+
|
|
12
|
+
StatusCallback = Callable[[str], Awaitable[None] | None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def resolve_credentials() -> tuple[str, str] | None:
|
|
16
|
+
"""Return saved ``(token, api_base)`` when they match the resolved hub URL."""
|
|
17
|
+
creds = load_credentials()
|
|
18
|
+
if not creds:
|
|
19
|
+
return None
|
|
20
|
+
if creds["api_base"].rstrip("/") != resolve_hub_api_url().rstrip("/"):
|
|
21
|
+
return None
|
|
22
|
+
return creds["token"], creds["api_base"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _emit_status(on_status: StatusCallback | None, msg: str) -> None:
|
|
26
|
+
if on_status is None:
|
|
27
|
+
return
|
|
28
|
+
result = on_status(msg)
|
|
29
|
+
if asyncio.iscoroutine(result):
|
|
30
|
+
await result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def ensure_authenticated(
|
|
34
|
+
*,
|
|
35
|
+
client_name: str,
|
|
36
|
+
on_status: StatusCallback | None = None,
|
|
37
|
+
force_pair: bool = False,
|
|
38
|
+
) -> tuple[str, str] | None:
|
|
39
|
+
"""Load saved credentials or run device-link pairing."""
|
|
40
|
+
if not force_pair:
|
|
41
|
+
resolved = resolve_credentials()
|
|
42
|
+
if resolved is not None:
|
|
43
|
+
return resolved
|
|
44
|
+
|
|
45
|
+
if force_pair:
|
|
46
|
+
delete_credentials()
|
|
47
|
+
await _emit_status(
|
|
48
|
+
on_status,
|
|
49
|
+
"Hub credentials were rejected. Starting a new device-link pairing…",
|
|
50
|
+
)
|
|
51
|
+
else:
|
|
52
|
+
await _emit_status(on_status, "No hub credentials found. Starting device-link pairing…")
|
|
53
|
+
|
|
54
|
+
return await pair_device(client_name=client_name, on_status=on_status)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Read/write hub credentials for Wizelit CLI and ACP clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TypedDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class WizelitCredentials(TypedDict):
|
|
12
|
+
token: str
|
|
13
|
+
api_base: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def credentials_path() -> Path:
|
|
17
|
+
override = os.environ.get("WIZELIT_CREDENTIALS_FILE")
|
|
18
|
+
if override:
|
|
19
|
+
return Path(override).expanduser()
|
|
20
|
+
return Path.home() / ".wizelit" / "credentials.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_credentials() -> WizelitCredentials | None:
|
|
24
|
+
path = credentials_path()
|
|
25
|
+
try:
|
|
26
|
+
raw = path.read_text(encoding="utf-8")
|
|
27
|
+
data = json.loads(raw)
|
|
28
|
+
token = data.get("token")
|
|
29
|
+
api_base = data.get("api_base")
|
|
30
|
+
if isinstance(token, str) and token.startswith("wiz_") and isinstance(api_base, str):
|
|
31
|
+
return {"token": token, "api_base": api_base.rstrip("/")}
|
|
32
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
33
|
+
return None
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _restrict_credentials_dir(path: Path) -> None:
|
|
38
|
+
"""Tighten ``~/.wizelit`` so only the owner can traverse it."""
|
|
39
|
+
if os.environ.get("WIZELIT_CREDENTIALS_FILE") is not None:
|
|
40
|
+
return
|
|
41
|
+
try:
|
|
42
|
+
os.chmod(path, 0o700)
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_credentials(token: str, api_base: str) -> Path:
|
|
48
|
+
path = credentials_path()
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
50
|
+
_restrict_credentials_dir(path.parent)
|
|
51
|
+
payload = {"token": token, "api_base": api_base.rstrip("/")}
|
|
52
|
+
data = json.dumps(payload, indent=2) + "\n"
|
|
53
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
54
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
55
|
+
handle.write(data)
|
|
56
|
+
return path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def delete_credentials() -> bool:
|
|
60
|
+
path = credentials_path()
|
|
61
|
+
try:
|
|
62
|
+
path.unlink()
|
|
63
|
+
return True
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
return False
|
|
66
|
+
except OSError:
|
|
67
|
+
return False
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Device-link login for Wizelit CLI and ACP clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import webbrowser
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from wizelit_client.credentials import delete_credentials, load_credentials, save_credentials
|
|
17
|
+
from wizelit_client.transport_security import require_secure_transport
|
|
18
|
+
from wizelit_client.user_config import resolve_hub_api_url
|
|
19
|
+
|
|
20
|
+
StatusCallback = Callable[[str], Awaitable[None] | None]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def api_url() -> str:
|
|
24
|
+
return resolve_hub_api_url()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _log(msg: str) -> None:
|
|
28
|
+
print(f"wizelit: {msg}", file=sys.stderr, flush=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def _notify(on_status: StatusCallback | None, msg: str) -> None:
|
|
32
|
+
_log(msg)
|
|
33
|
+
if on_status is None:
|
|
34
|
+
return
|
|
35
|
+
result = on_status(msg)
|
|
36
|
+
if asyncio.iscoroutine(result):
|
|
37
|
+
await result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def pair_device(
|
|
41
|
+
*,
|
|
42
|
+
client_name: str | None = None,
|
|
43
|
+
on_status: StatusCallback | None = None,
|
|
44
|
+
open_browser: bool = True,
|
|
45
|
+
) -> tuple[str, str] | None:
|
|
46
|
+
"""Start device-link pairing and return ``(token, api_base)`` when approved."""
|
|
47
|
+
api_base = api_url()
|
|
48
|
+
try:
|
|
49
|
+
require_secure_transport(api_base)
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
await _notify(on_status, str(exc))
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
payload: dict[str, str] = {}
|
|
55
|
+
if client_name:
|
|
56
|
+
payload["clientName"] = client_name
|
|
57
|
+
|
|
58
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
59
|
+
start_resp = await client.post(f"{api_base}/api/auth/device", json=payload or None)
|
|
60
|
+
if start_resp.status_code != 200:
|
|
61
|
+
await _notify(
|
|
62
|
+
on_status,
|
|
63
|
+
f"Device pairing failed to start ({start_resp.status_code}). "
|
|
64
|
+
"Is the Wizelit backend running?",
|
|
65
|
+
)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
start = start_resp.json()
|
|
69
|
+
device_code = start["deviceCode"]
|
|
70
|
+
verification_uri = start.get("verificationUriComplete") or start["verificationUri"]
|
|
71
|
+
interval = max(1, int(start.get("interval", 5)))
|
|
72
|
+
expires_in = int(start.get("expiresIn", 600))
|
|
73
|
+
|
|
74
|
+
await _notify(
|
|
75
|
+
on_status,
|
|
76
|
+
f"Approve pairing in your browser: {verification_uri} (expires in {expires_in}s)",
|
|
77
|
+
)
|
|
78
|
+
if open_browser:
|
|
79
|
+
try:
|
|
80
|
+
webbrowser.open(verification_uri)
|
|
81
|
+
except Exception:
|
|
82
|
+
await _notify(
|
|
83
|
+
on_status,
|
|
84
|
+
"Could not open a browser automatically — open the URL above manually.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
deadline = time.monotonic() + expires_in
|
|
88
|
+
while time.monotonic() < deadline:
|
|
89
|
+
await asyncio.sleep(interval)
|
|
90
|
+
poll_resp = await client.post(
|
|
91
|
+
f"{api_base}/api/auth/device/token",
|
|
92
|
+
json={"deviceCode": device_code},
|
|
93
|
+
)
|
|
94
|
+
if poll_resp.status_code == 200:
|
|
95
|
+
token = poll_resp.json()["token"]
|
|
96
|
+
cred_path = save_credentials(token, api_base)
|
|
97
|
+
await _notify(on_status, f"Pairing complete. Credentials saved to {cred_path}")
|
|
98
|
+
return token, api_base
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
detail: Any = poll_resp.json().get("detail")
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
detail = poll_resp.text
|
|
104
|
+
if isinstance(detail, dict):
|
|
105
|
+
err = detail.get("error")
|
|
106
|
+
if err == "authorization_pending":
|
|
107
|
+
continue
|
|
108
|
+
if err == "slow_down":
|
|
109
|
+
retry = float(detail.get("retryAfter", interval))
|
|
110
|
+
interval = min(interval + 5, 60)
|
|
111
|
+
await asyncio.sleep(max(retry, 0.1))
|
|
112
|
+
continue
|
|
113
|
+
await _notify(on_status, f"Device poll failed ({poll_resp.status_code}): {detail}")
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
await _notify(on_status, "Timed out waiting for browser authorization.")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def run_device_login(*, client_name: str | None = None) -> int:
|
|
121
|
+
paired = await pair_device(client_name=client_name)
|
|
122
|
+
return 0 if paired is not None else 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def run_device_logout() -> int:
|
|
126
|
+
creds = load_credentials()
|
|
127
|
+
if creds:
|
|
128
|
+
try:
|
|
129
|
+
require_secure_transport(creds["api_base"])
|
|
130
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
131
|
+
await client.post(
|
|
132
|
+
f"{creds['api_base']}/api/auth/ide-tokens/revoke-current",
|
|
133
|
+
headers={"Authorization": f"Bearer {creds['token']}"},
|
|
134
|
+
)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
_log(f"server revoke skipped: {exc}")
|
|
137
|
+
|
|
138
|
+
if delete_credentials():
|
|
139
|
+
_log("removed saved credentials")
|
|
140
|
+
else:
|
|
141
|
+
_log("no credentials file found")
|
|
142
|
+
return 0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Transport security checks before sending hub bearer tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipaddress
|
|
6
|
+
import os
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def allow_insecure_transport() -> bool:
|
|
11
|
+
"""Opt-in for sending credentials over plain HTTP/WS to remote hosts."""
|
|
12
|
+
return os.environ.get("WIZELIT_ALLOW_INSECURE", "").strip().lower() in {
|
|
13
|
+
"1",
|
|
14
|
+
"true",
|
|
15
|
+
"yes",
|
|
16
|
+
"on",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_loopback_host(host: str | None) -> bool:
|
|
21
|
+
if not host:
|
|
22
|
+
return False
|
|
23
|
+
normalized = host.strip("[]").lower()
|
|
24
|
+
if normalized == "localhost":
|
|
25
|
+
return True
|
|
26
|
+
try:
|
|
27
|
+
return ipaddress.ip_address(normalized).is_loopback
|
|
28
|
+
except ValueError:
|
|
29
|
+
return False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_insecure_remote_plaintext(url: str) -> bool:
|
|
33
|
+
"""True when ``url`` uses plain HTTP or WS to a non-loopback host."""
|
|
34
|
+
parsed = urlparse(url)
|
|
35
|
+
if parsed.scheme not in {"http", "ws"}:
|
|
36
|
+
return False
|
|
37
|
+
return not is_loopback_host(parsed.hostname)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def require_secure_transport(url: str) -> None:
|
|
41
|
+
"""Refuse cleartext hub token transport to remote hosts unless opted in."""
|
|
42
|
+
if not is_insecure_remote_plaintext(url):
|
|
43
|
+
return
|
|
44
|
+
if allow_insecure_transport():
|
|
45
|
+
return
|
|
46
|
+
secure_scheme = "wss://" if url.startswith("ws://") else "https://"
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"Refusing to send hub token over plaintext to non-loopback host ({url}). "
|
|
49
|
+
f"Use {secure_scheme} or set WIZELIT_ALLOW_INSECURE=1 to override (not recommended)."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_insecure_remote_http(base_url: str) -> bool:
|
|
54
|
+
"""True when ``base_url`` is plain HTTP to a non-loopback host."""
|
|
55
|
+
parsed = urlparse(base_url)
|
|
56
|
+
if parsed.scheme != "http":
|
|
57
|
+
return False
|
|
58
|
+
return not is_loopback_host(parsed.hostname)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def bearer_auth_headers(base_url: str, token: str | None) -> dict[str, str]:
|
|
62
|
+
"""Build Authorization headers, refusing cleartext token to remote hosts."""
|
|
63
|
+
if not token:
|
|
64
|
+
return {}
|
|
65
|
+
if is_insecure_remote_http(base_url):
|
|
66
|
+
if allow_insecure_transport():
|
|
67
|
+
pass
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Refusing to send hub token over plain HTTP to non-loopback host "
|
|
71
|
+
f"({base_url}). Use https:// or set WIZELIT_ALLOW_INSECURE=1 to "
|
|
72
|
+
"override (not recommended)."
|
|
73
|
+
)
|
|
74
|
+
return {"Authorization": f"Bearer {token}"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""User-local Wizelit client configuration (``~/.wizelit/config.json``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from wizelit_client.credentials import credentials_path, load_credentials
|
|
11
|
+
|
|
12
|
+
_CONFIG_VERSION = 1
|
|
13
|
+
_DEFAULT_API_URL = "http://localhost:8000"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def config_dir() -> Path:
|
|
17
|
+
return credentials_path().parent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def config_path() -> Path:
|
|
21
|
+
override = os.environ.get("WIZELIT_CONFIG_FILE")
|
|
22
|
+
if override:
|
|
23
|
+
return Path(override).expanduser()
|
|
24
|
+
return config_dir() / "config.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_config() -> dict[str, Any]:
|
|
28
|
+
path = config_path()
|
|
29
|
+
try:
|
|
30
|
+
raw = path.read_text(encoding="utf-8")
|
|
31
|
+
data = json.loads(raw)
|
|
32
|
+
return data if isinstance(data, dict) else {}
|
|
33
|
+
except (OSError, json.JSONDecodeError):
|
|
34
|
+
return {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save_config(data: dict[str, Any]) -> Path:
|
|
38
|
+
path = config_path()
|
|
39
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
40
|
+
try:
|
|
41
|
+
os.chmod(path.parent, 0o700)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
payload = {"version": _CONFIG_VERSION, **data}
|
|
45
|
+
text = json.dumps(payload, indent=2) + "\n"
|
|
46
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
47
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
48
|
+
handle.write(text)
|
|
49
|
+
return path
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_hub_api_url() -> str:
|
|
53
|
+
"""Hub REST base URL: env → config.json → credentials.json → default."""
|
|
54
|
+
if raw := os.environ.get("WIZELIT_API_URL"):
|
|
55
|
+
return raw.rstrip("/")
|
|
56
|
+
|
|
57
|
+
config = load_config()
|
|
58
|
+
api_url = config.get("api_url")
|
|
59
|
+
if isinstance(api_url, str) and api_url.strip():
|
|
60
|
+
return api_url.strip().rstrip("/")
|
|
61
|
+
|
|
62
|
+
creds = load_credentials()
|
|
63
|
+
if creds:
|
|
64
|
+
return creds["api_base"].rstrip("/")
|
|
65
|
+
|
|
66
|
+
return _DEFAULT_API_URL
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def apply_config_to_environ() -> None:
|
|
70
|
+
"""Load ``config.json`` values into the process env (existing env wins)."""
|
|
71
|
+
config = load_config()
|
|
72
|
+
|
|
73
|
+
api_url = config.get("api_url")
|
|
74
|
+
if isinstance(api_url, str) and api_url.strip() and not os.getenv("WIZELIT_API_URL"):
|
|
75
|
+
os.environ["WIZELIT_API_URL"] = api_url.strip().rstrip("/")
|
|
76
|
+
|
|
77
|
+
env_block = config.get("env")
|
|
78
|
+
if not isinstance(env_block, dict):
|
|
79
|
+
return
|
|
80
|
+
for key, value in env_block.items():
|
|
81
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
82
|
+
continue
|
|
83
|
+
if not os.getenv(key):
|
|
84
|
+
os.environ[key] = value
|